use std::time::Duration;
use crate::ssh::{copy_file, exec_detached_get_pid, test_connection, SshTarget};
pub struct PushContext<'a> {
pub vm_name: &'a str,
pub vm_ip: &'a str,
pub target: SshTarget,
pub script: &'a str,
pub use_sudo: bool,
pub detached_exec_timeout: Duration,
}
pub struct PushFailure {
pub message: String,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub diagnostics: serde_json::Map<String, serde_json::Value>,
}
pub async fn push_and_run_detached(ctx: &PushContext<'_>) -> Result<u32, PushFailure> {
use std::io::Write;
use std::time::Instant;
log::info!("VM '{}' is ready with IP: {}", ctx.vm_name, ctx.vm_ip);
let mut tmp = match tempfile::NamedTempFile::new() {
Ok(f) => f,
Err(e) => return Err(simple_failure(format!("tempfile: {e}"), ctx)),
};
if let Err(e) = tmp.write_all(ctx.script.as_bytes()) {
return Err(simple_failure(format!("write tempfile: {e}"), ctx));
}
if let Err(msg) = wait_for_ssh(&ctx.target).await {
return Err(simple_failure(format!("SSH not reachable: {msg}"), ctx));
}
let remote_path = format!("/tmp/script_{}.sh", Instant::now().elapsed().as_secs());
if let Err(e) = copy_file(&ctx.target, tmp.path(), &remote_path).await {
return Err(simple_failure(format!("scp: {e}"), ctx));
}
let cmd = crate::script_cmd::detached_provision_cmd(&remote_path, ctx.use_sudo);
log::info!(
"exec start: vm={} ip={} detached=true timeout={}s",
ctx.vm_name,
ctx.vm_ip,
ctx.detached_exec_timeout.as_secs()
);
match exec_detached_get_pid(&ctx.target, &cmd, ctx.detached_exec_timeout).await {
Ok(ok) => {
log::info!(
"exec ok: vm={} pid={} elapsed_ms={}",
ctx.vm_name,
ok.pid,
ok.elapsed_ms
);
Ok(ok.pid)
}
Err(e) => {
let vm_diag = capture_diagnostics(&ctx.target).await;
log::warn!(
"exec failed: vm={} ip={} elapsed_ms={} ssh_error={} \n--- diagnostics ---\n{}",
ctx.vm_name,
ctx.vm_ip,
e.elapsed_ms,
e.message,
vm_diag
);
let mut diagnostics = base_diagnostics(ctx);
diagnostics.insert(
"exec_elapsed_ms".into(),
serde_json::Value::from(e.elapsed_ms as u64),
);
diagnostics.insert(
"exec_timeout_secs".into(),
serde_json::Value::from(ctx.detached_exec_timeout.as_secs()),
);
diagnostics.insert(
"ssh_error".into(),
serde_json::Value::from(e.message.clone()),
);
if !e.partial_stdout.is_empty() {
diagnostics.insert(
"partial_stdout".into(),
serde_json::Value::from(e.partial_stdout),
);
}
if !e.partial_stderr.is_empty() {
diagnostics.insert(
"partial_stderr".into(),
serde_json::Value::from(e.partial_stderr),
);
}
diagnostics.insert("vm_diagnostics".into(), serde_json::Value::from(vm_diag));
Err(PushFailure {
message: e.message,
diagnostics,
})
}
}
}
fn base_diagnostics(ctx: &PushContext<'_>) -> serde_json::Map<String, serde_json::Value> {
let mut m = serde_json::Map::new();
m.insert(
"vm_name".into(),
serde_json::Value::from(ctx.vm_name.to_string()),
);
m.insert(
"vm_ip".into(),
serde_json::Value::from(ctx.vm_ip.to_string()),
);
m.insert("use_sudo".into(), serde_json::Value::from(ctx.use_sudo));
m
}
fn simple_failure(message: String, ctx: &PushContext<'_>) -> PushFailure {
let mut diagnostics = base_diagnostics(ctx);
diagnostics.insert("phase".into(), serde_json::Value::from("pre_detached_exec"));
PushFailure {
message,
diagnostics,
}
}
async fn wait_for_ssh(target: &SshTarget) -> Result<(), String> {
const MAX_RETRIES: usize = 6;
let mut last_err: Option<String> = None;
for attempt in 1..=MAX_RETRIES {
match test_connection(target).await {
Ok(()) => {
log::info!("✔ SSH ready (attempt {}/{})", attempt, MAX_RETRIES);
return Ok(());
}
Err(e) => {
last_err = Some(e.to_string());
if attempt < MAX_RETRIES {
log::info!("SSH not ready (attempt {}/{}): {}", attempt, MAX_RETRIES, e);
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
}
}
Err(last_err.unwrap_or_else(|| "no attempts made".into()))
}
async fn capture_diagnostics(target: &SshTarget) -> String {
let probe = crate::script_cmd::diagnostic_capture_cmd();
match crate::ssh::exec(target, probe, Duration::from_secs(15)).await {
Ok(out) => out,
Err(e) => format!("(diagnostic capture failed: {e})"),
}
}