use std::process::Stdio;
use anyhow::{Result, bail};
use tokio::io::AsyncWriteExt;
const SSH_HOST: &str = "ssh.railway.com";
pub(crate) async fn exec_in_container(instance_id: &str, command: &str) -> Result<String> {
let target = format!("{instance_id}@{SSH_HOST}");
let mut child = tokio::process::Command::new("ssh")
.arg("-o")
.arg("StrictHostKeyChecking=accept-new")
.arg(&target)
.arg("sh")
.arg("-s")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(command.as_bytes()).await?;
stdin.write_all(b"\n").await?;
} else {
bail!("Failed to open stdin for SSH command");
}
let output = child.wait_with_output().await?;
if !output.status.success() {
let status = match output.status.code() {
Some(code) => format!("exit code {code}"),
None => output.status.to_string(),
};
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let detail = if stderr.trim().is_empty() {
stdout
.lines()
.rev()
.find(|line| line.contains("ERROR") || line.contains("FATAL"))
.or_else(|| stdout.lines().rev().find(|line| !line.trim().is_empty()))
.unwrap_or("")
.trim()
.to_string()
} else {
stderr.trim().to_string()
};
bail!("SSH command failed ({status}): {detail}");
}
Ok(String::from_utf8(output.stdout)?)
}
fn is_transient_exec_error(detail: &str) -> bool {
let lower = detail.to_ascii_lowercase();
const DETERMINISTIC: &[&str] = &[
"permission denied",
"publickey",
"host key verification failed",
"command not found",
"no such file",
"too many authentication failures",
];
if DETERMINISTIC.iter().any(|m| lower.contains(m)) {
return false;
}
const TRANSIENT: &[&str] = &[
"connection reset",
"connection refused",
"connection closed",
"connection timed out",
"timed out",
"timeout",
"broken pipe",
"network is unreachable",
"temporarily unavailable",
"kex_exchange",
"banner exchange",
"unexpected eof",
"connection to",
];
TRANSIENT.iter().any(|m| lower.contains(m))
}
const PROBE_ATTEMPTS: u32 = 3;
const PROBE_BACKOFF: [std::time::Duration; 2] = [
std::time::Duration::from_secs(1),
std::time::Duration::from_secs(3),
];
pub(crate) async fn exec_probe_in_container(
instance_id: &str,
command: &str,
per_attempt_timeout: std::time::Duration,
) -> Result<String> {
let mut last_error: Option<anyhow::Error> = None;
for attempt in 1..=PROBE_ATTEMPTS {
match tokio::time::timeout(per_attempt_timeout, exec_in_container(instance_id, command))
.await
{
Ok(Ok(output)) => return Ok(output),
Ok(Err(err)) => {
let transient = is_transient_exec_error(&format!("{err:#}"));
last_error = Some(err);
if !transient {
break;
}
}
Err(_elapsed) => {
last_error = Some(anyhow::anyhow!(
"probe timed out after {per_attempt_timeout:?}"
));
}
}
if attempt < PROBE_ATTEMPTS {
tokio::time::sleep(PROBE_BACKOFF[(attempt - 1) as usize]).await;
}
}
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("probe failed with no attempts made")))
}
#[cfg(test)]
mod tests {
use super::is_transient_exec_error;
#[test]
fn transient_and_deterministic_exec_errors_are_told_apart() {
assert!(is_transient_exec_error(
"SSH command failed (exit code 255): Connection reset by peer"
));
assert!(is_transient_exec_error(
"SSH command failed (exit code 255): kex_exchange_identification: read: Connection reset"
));
assert!(is_transient_exec_error("probe timed out after 5s"));
assert!(is_transient_exec_error(
"SSH command failed (exit code 255): Connection to ssh.railway.com closed by remote host"
));
assert!(!is_transient_exec_error(
"SSH command failed (exit code 255): paulo@ssh.railway.com: Permission denied (publickey)"
));
assert!(!is_transient_exec_error(
"SSH command failed (exit code 127): sh: curl: command not found"
));
assert!(!is_transient_exec_error(
"SSH command failed (exit code 255): Host key verification failed"
));
}
}