use std::collections::VecDeque;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::servers::Server;
#[derive(Debug, Clone, PartialEq)]
pub struct Outcome {
pub server: String,
pub code: Option<i32>,
pub stdout: String,
pub stderr: String,
pub elapsed: Duration,
pub error: Option<String>,
}
impl Outcome {
pub fn ok(&self) -> bool {
self.error.is_none() && self.code == Some(0)
}
}
pub struct Plan<'a> {
pub command: &'a str,
pub ssh_options: &'a (dyn Fn(&Server) -> Vec<String> + Sync),
pub parallel: usize,
pub timeout: Duration,
pub cancel: &'a AtomicBool,
}
pub fn run_all(servers: Vec<Server>, plan: &Plan, done: &(dyn Fn(&Outcome) + Sync)) -> Vec<Outcome> {
run_all_with("ssh", servers, plan, done)
}
fn run_all_with(ssh: &str, servers: Vec<Server>, plan: &Plan, done: &(dyn Fn(&Outcome) + Sync)) -> Vec<Outcome> {
let queue = Arc::new(Mutex::new(servers.into_iter().collect::<VecDeque<_>>()));
let results = Arc::new(Mutex::new(Vec::new()));
std::thread::scope(|scope| {
for _ in 0..plan.parallel.max(1) {
let queue = queue.clone();
let results = results.clone();
scope.spawn(move || {
loop {
if plan.cancel.load(Ordering::SeqCst) {
break;
}
let Some(server) = queue.lock().unwrap().pop_front() else { break };
let mut args = (plan.ssh_options)(&server);
args.extend(["-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-T"].map(String::from));
args.push(server.destination());
args.push(plan.command.to_string());
let outcome = run_one(ssh, &server.name, &args, plan.timeout, plan.cancel);
done(&outcome);
results.lock().unwrap().push(outcome);
}
});
}
});
Arc::try_unwrap(results).map(|m| m.into_inner().unwrap()).unwrap_or_default()
}
const OUTPUT_LIMIT: usize = 1 << 20;
fn run_one(ssh: &str, name: &str, ssh_args: &[String], timeout: Duration, cancel: &AtomicBool) -> Outcome {
let start = Instant::now();
let mut outcome = Outcome {
server: name.to_string(),
code: None,
stdout: String::new(),
stderr: String::new(),
elapsed: Duration::ZERO,
error: None,
};
match crate::exec::run_cancellable(Command::new(ssh).args(ssh_args), timeout, OUTPUT_LIMIT, cancel) {
Ok(out) => {
outcome.code = out.code;
outcome.stdout = out.stdout_text();
outcome.stderr = out.stderr_text();
if out.truncated {
outcome.stderr.push_str(&format!("[kraal: output cut at {} KiB]\n", OUTPUT_LIMIT >> 10));
}
if out.timed_out {
outcome.error = Some(format!("timed out after {}s", timeout.as_secs()));
}
if out.cancelled {
outcome.error = Some("cancelled".into());
}
}
Err(e) => outcome.error = Some(format!("cannot run ssh: {e}")),
}
outcome.elapsed = start.elapsed();
outcome
}
pub fn format_outcome(o: &Outcome, color: bool) -> String {
let (green, red, dim, reset) =
if color { ("\x1b[32m", "\x1b[31m", "\x1b[2m", "\x1b[0m") } else { ("", "", "", "") };
let status = match (&o.error, o.code) {
(Some(e), _) => format!("{red}✗ {e}{reset}"),
(None, Some(0)) => format!("{green}✓{reset}"),
(None, Some(255)) => format!("{red}✗ ssh failed (255){reset}"),
(None, Some(c)) => format!("{red}✗ exit {c}{reset}"),
(None, None) => format!("{red}✗ killed{reset}"),
};
let mut s = format!(
"{status} {}{} {dim}({:.1}s){reset}\n",
if color { "\x1b[1m" } else { "" },
o.server,
o.elapsed.as_secs_f64()
);
if color {
s = s.replacen(&o.server, &format!("{}{reset}", o.server), 1);
}
for line in o.stdout.lines() {
s.push_str(&format!(" {line}\n"));
}
for line in o.stderr.lines() {
s.push_str(&format!(" {red}{line}{reset}\n"));
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn runs_in_parallel_and_reports() {
let dir = std::env::temp_dir().join(format!("kraal-fleet-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let fake = dir.join("ssh");
crate::testing::write_script(
&fake,
"#!/bin/sh\nfor a; do dest=$cmd; cmd=$a; done\ncase $dest in *bad*) echo oops >&2; exit 3;; esac\necho \"$dest says $cmd\"\n",
);
let mk = |n: &str| Server { name: n.into(), host: format!("{n}.lan"), ..Default::default() };
let seen = Mutex::new(Vec::new());
let cancel = AtomicBool::new(false);
let plan = Plan {
command: "uptime",
ssh_options: &|_| Vec::new(),
parallel: 2,
timeout: Duration::from_secs(10),
cancel: &cancel,
};
let results = run_all_with(&fake.display().to_string(), vec![mk("a"), mk("b"), mk("bad")], &plan, &|o| {
seen.lock().unwrap().push(o.server.clone())
});
assert_eq!(results.len(), 3);
assert_eq!(seen.lock().unwrap().len(), 3);
let a = results.iter().find(|o| o.server == "a").unwrap();
assert!(a.ok());
assert_eq!(a.stdout.trim(), "a.lan says uptime");
let bad = results.iter().find(|o| o.server == "bad").unwrap();
assert_eq!((bad.ok(), bad.code), (false, Some(3)));
assert!(format_outcome(bad, false).contains("✗ exit 3 bad"));
assert!(format_outcome(bad, false).contains(" oops"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_cancelled_run_stops_everything_it_started() {
let dir = std::env::temp_dir().join(format!("kraal-fleet-cancel-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let fake = dir.join("ssh");
let pids = dir.join("pids");
crate::testing::write_script(
&fake,
&format!(
"#!/bin/sh
sleep 30 &
echo $! >> {}
wait
",
pids.display()
),
);
let mk = |n: &str| Server { name: n.into(), host: format!("{n}.lan"), ..Default::default() };
let cancel = AtomicBool::new(false);
let plan = Plan {
command: "true",
ssh_options: &|_| Vec::new(),
parallel: 2,
timeout: Duration::from_secs(60),
cancel: &cancel,
};
let start = Instant::now();
let results = std::thread::scope(|s| {
s.spawn(|| {
std::thread::sleep(Duration::from_millis(500));
cancel.store(true, Ordering::SeqCst);
});
run_all_with(&fake.display().to_string(), vec![mk("a"), mk("b"), mk("c")], &plan, &|_| {})
});
assert!(start.elapsed() < Duration::from_secs(10));
assert_eq!(results.len(), 2, "the third never started");
assert!(results.iter().all(|o| o.error.as_deref() == Some("cancelled")), "{results:?}");
for pid in std::fs::read_to_string(&pids).unwrap().lines() {
let pid: libc::pid_t = pid.trim().parse().unwrap();
let start = Instant::now();
while unsafe { libc::kill(pid, 0) } == 0 && start.elapsed() < Duration::from_secs(5) {
std::thread::sleep(Duration::from_millis(20));
}
assert_ne!(unsafe { libc::kill(pid, 0) }, 0, "sleep {pid} was left running");
}
let _ = std::fs::remove_dir_all(dir);
}
}