first pass of merging in warp (doesn't build)
This commit is contained in:
+106
-14
@@ -2,9 +2,31 @@ use std::path::Path;
|
||||
use std::process::Output;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use anyhow::anyhow;
|
||||
use command::r#async::Command;
|
||||
use galaxyui::r#async::FutureExt as _;
|
||||
use galaxyui_core::r#async::FutureExt as _;
|
||||
|
||||
use crate::transport::ControlPath;
|
||||
|
||||
/// Transport-level error from [`run_ssh_command`] or [`run_ssh_script`].
|
||||
///
|
||||
/// Distinguishes timeouts from other I/O failures so callers can promote
|
||||
/// timeouts to a per-method `TimedOut` variant on the trait error types.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SshCommandError {
|
||||
/// The SSH command or script did not complete within the timeout.
|
||||
#[error("Timed out after {timeout:?}")]
|
||||
TimedOut { timeout: Duration },
|
||||
/// The `ssh` process could not be spawned.
|
||||
#[error("Failed to spawn ssh: {0}")]
|
||||
SpawnFailed(std::io::Error),
|
||||
/// Writing to the SSH process's stdin failed.
|
||||
#[error("Failed to write to ssh stdin: {0}")]
|
||||
StdinWriteFailed(std::io::Error),
|
||||
/// The SSH process was spawned but `output()` returned an I/O error.
|
||||
#[error("SSH I/O error: {0}")]
|
||||
IoError(std::io::Error),
|
||||
}
|
||||
|
||||
/// Timeout for `ssh -O exit`. The command only talks to the local
|
||||
/// ControlMaster over a Unix socket, so it should return almost
|
||||
@@ -28,7 +50,7 @@ pub fn ssh_args(socket_path: &Path) -> Vec<String> {
|
||||
}
|
||||
|
||||
/// Runs `ssh -O exit -o ControlPath=<socket_path>` to force the local
|
||||
/// SSH `ControlMaster` managing `socket_path` to exit immediately,
|
||||
/// SSH `ControlMaster` behind `control_path` to exit immediately,
|
||||
/// without waiting for multiplexed channels to finish draining.
|
||||
///
|
||||
/// The user's interactive ssh is spawned with `-o ControlMaster=yes` by
|
||||
@@ -38,13 +60,30 @@ pub fn ssh_args(socket_path: &Path) -> Vec<String> {
|
||||
/// `ssh ... remote-server-proxy`) to finish cleanup on the remote
|
||||
/// side. Sending `-O exit` bypasses that wait.
|
||||
///
|
||||
/// Only [`ControlPath::WarpManaged`] masters are acted on: a
|
||||
/// [`ControlPath::UserOwned`] master (the SSH wrapper attached to a
|
||||
/// master the user already had running) is left untouched, and
|
||||
/// [`ControlPath::None`] is a no-op.
|
||||
///
|
||||
/// **Only safe to call once the user's shell has already exited** --
|
||||
/// this tears down the interactive ssh outright. In practice it is
|
||||
/// invoked from the `ExitShell` teardown path on the client.
|
||||
/// for Warp-managed masters this tears down the interactive ssh
|
||||
/// outright. In practice it is invoked from the `ExitShell` teardown
|
||||
/// path on the client.
|
||||
///
|
||||
/// Fire-and-forget. Errors are logged but not propagated: at teardown
|
||||
/// time there is nothing useful to do with them.
|
||||
pub async fn stop_control_master(socket_path: &Path) {
|
||||
pub async fn stop_control_master(control_path: &ControlPath) {
|
||||
let socket_path = match control_path {
|
||||
ControlPath::WarpManaged(socket_path) => socket_path,
|
||||
ControlPath::UserOwned(socket_path) => {
|
||||
log::info!(
|
||||
"stop_control_master: leaving user-owned ControlMaster at {} running",
|
||||
socket_path.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
ControlPath::None => return,
|
||||
};
|
||||
let args = ssh_args(socket_path);
|
||||
let result = async {
|
||||
Command::new("ssh")
|
||||
@@ -96,7 +135,7 @@ pub async fn run_ssh_command(
|
||||
socket_path: &Path,
|
||||
remote_command: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<Output> {
|
||||
) -> Result<Output, SshCommandError> {
|
||||
async {
|
||||
Command::new("ssh")
|
||||
.args(ssh_args(socket_path))
|
||||
@@ -107,8 +146,8 @@ pub async fn run_ssh_command(
|
||||
}
|
||||
.with_timeout(timeout)
|
||||
.await
|
||||
.map_err(|_| anyhow!("SSH command timed out after {timeout:?}"))?
|
||||
.map_err(|e| anyhow!("SSH command failed to execute: {e}"))
|
||||
.map_err(|_| SshCommandError::TimedOut { timeout })?
|
||||
.map_err(SshCommandError::IoError)
|
||||
}
|
||||
|
||||
/// Pipe a script into `bash -s` on the remote host via the ControlMaster
|
||||
@@ -122,7 +161,11 @@ pub async fn run_ssh_command(
|
||||
/// that would require complex, fragile escaping if passed as an argument.
|
||||
/// The `bash -s` + stdin approach avoids all escaping issues and has no
|
||||
/// argument length limits.
|
||||
pub async fn run_ssh_script(socket_path: &Path, script: &str, timeout: Duration) -> Result<Output> {
|
||||
pub async fn run_ssh_script(
|
||||
socket_path: &Path,
|
||||
script: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<Output, SshCommandError> {
|
||||
use std::process::Stdio;
|
||||
|
||||
let mut child = Command::new("ssh")
|
||||
@@ -133,7 +176,7 @@ pub async fn run_ssh_script(socket_path: &Path, script: &str, timeout: Duration)
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.map_err(|e| anyhow!("Failed to spawn SSH for script: {e}"))?;
|
||||
.map_err(SshCommandError::SpawnFailed)?;
|
||||
|
||||
// Write the script to stdin.
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
@@ -141,7 +184,7 @@ pub async fn run_ssh_script(socket_path: &Path, script: &str, timeout: Duration)
|
||||
stdin
|
||||
.write_all(script.as_bytes())
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to write script to stdin: {e}"))?;
|
||||
.map_err(SshCommandError::StdinWriteFailed)?;
|
||||
// Close stdin so the remote bash exits after reading the script.
|
||||
drop(stdin);
|
||||
}
|
||||
@@ -150,6 +193,55 @@ pub async fn run_ssh_script(socket_path: &Path, script: &str, timeout: Duration)
|
||||
.output()
|
||||
.with_timeout(timeout)
|
||||
.await
|
||||
.map_err(|_| anyhow!("Script timed out after {timeout:?}"))?
|
||||
.map_err(|e| anyhow!("Script failed: {e}"))
|
||||
.map_err(|_| SshCommandError::TimedOut { timeout })?
|
||||
.map_err(SshCommandError::IoError)
|
||||
}
|
||||
|
||||
impl From<SshCommandError> for crate::transport::Error {
|
||||
fn from(err: SshCommandError) -> Self {
|
||||
match err {
|
||||
SshCommandError::TimedOut { .. } => Self::TimedOut,
|
||||
other => Self::Other(other.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload a local file to the remote host via `scp`, reusing the
|
||||
/// ControlMaster socket for authentication. Returns `Ok(())` on success
|
||||
/// or an error describing the failure.
|
||||
pub async fn scp_upload(
|
||||
socket_path: &Path,
|
||||
local_path: &Path,
|
||||
remote_path: &str,
|
||||
timeout: Duration,
|
||||
) -> anyhow::Result<()> {
|
||||
let output = async {
|
||||
Command::new("scp")
|
||||
.arg("-o")
|
||||
.arg(format!("ControlPath={}", socket_path.display()))
|
||||
.arg("-o")
|
||||
.arg("ControlMaster=no")
|
||||
.arg("-o")
|
||||
.arg("ConnectTimeout=15")
|
||||
.arg(local_path.as_os_str())
|
||||
.arg(format!("placeholder@placeholder:{remote_path}"))
|
||||
.kill_on_drop(true)
|
||||
.output()
|
||||
.await
|
||||
}
|
||||
.with_timeout(timeout)
|
||||
.await
|
||||
.map_err(|_| anyhow!("scp timed out after {timeout:?}"))?
|
||||
.map_err(|e| anyhow!("scp failed to execute: {e}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
Err(anyhow!(
|
||||
"scp failed (exit {:?}): {}",
|
||||
output.status.code(),
|
||||
stderr
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user