use crate::{Boot, BootSpec, Error, ImageSource, Lifecycle, Machine, PowerState, Result, Seen};
use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
struct Running {
child: Child,
output: Arc<Mutex<String>>,
started: Instant,
}
#[derive(Default, Clone)]
pub struct ExeBoot {
live: Arc<Mutex<HashMap<String, Running>>>,
}
impl std::fmt::Debug for ExeBoot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExeBoot").finish_non_exhaustive()
}
}
impl ExeBoot {
pub fn new() -> Self {
Self::default()
}
pub fn program<'a>(&self, spec: &'a BootSpec) -> Result<(&'a str, &'a [String])> {
match &spec.image {
ImageSource::Executable { path, args } => Ok((path.as_str(), args.as_slice())),
other => Err(Error::Spec(format!(
"exe backend needs an ImageSource::Executable, got {other:?}"
))),
}
}
pub fn output(&self, machine: &Machine) -> Option<String> {
self.live
.lock()
.unwrap()
.get(&machine.id)
.map(|r| r.output.lock().unwrap().clone())
}
pub fn await_marker(
&self,
machine: &Machine,
marker: &str,
budget: Duration,
poll: Duration,
) -> Result<Seen> {
let started = Instant::now();
let deadline = started + budget;
loop {
let observed = {
let mut guard = self.live.lock().unwrap();
let running = guard.get_mut(&machine.id).ok_or_else(|| {
Error::Backend(format!("no live exe process for {}", machine.id))
})?;
let captured = running.output.lock().unwrap().clone();
let hit = captured
.lines()
.find(|l| l.contains(marker))
.map(str::to_string);
let exited = running
.child
.try_wait()
.map_err(|e| Error::Backend(format!("try_wait on {}: {e}", machine.id)))?;
(hit, exited, captured)
};
let (hit, exited, captured) = observed;
if let Some(line) = hit {
let seen = Seen::Marker {
line,
after: started.elapsed(),
};
self.record("await_marker", true, machine, &seen);
return Ok(seen);
}
if let Some(status) = exited {
std::thread::sleep(poll);
let captured = self
.live
.lock()
.unwrap()
.get(&machine.id)
.map(|r| r.output.lock().unwrap().clone())
.unwrap_or(captured);
if let Some(line) = captured.lines().find(|l| l.contains(marker)) {
let seen = Seen::Marker {
line: line.to_string(),
after: started.elapsed(),
};
self.record("await_marker", true, machine, &seen);
return Ok(seen);
}
let seen = Seen::Exited {
code: status.code(),
tail: captured,
after: started.elapsed(),
};
self.record("await_marker", false, machine, &seen);
return Ok(seen);
}
let now = Instant::now();
if now >= deadline {
let seen = Seen::StillRunning {
tail: captured,
waited: started.elapsed(),
};
self.record("await_marker", false, machine, &seen);
return Ok(seen);
}
std::thread::sleep(poll.min(deadline.saturating_duration_since(now)));
}
}
fn record(&self, check: &str, ok: bool, machine: &Machine, seen: &Seen) {
crate::functional_status(
"draupnir/exe",
check,
ok,
&format!("`{}`: {}", machine.id, seen.detail()),
);
}
}
impl Boot for ExeBoot {
fn boot(&self, spec: &BootSpec) -> Result<Machine> {
spec.validate()?;
let (path, args) = self.program(spec)?;
if !std::path::Path::new(path).is_file() {
return Err(Error::Spec(format!(
"executable `{path}` does not exist (spec `{}`): stage 1 must produce \
the binary before stage 2 can run it",
spec.name
)));
}
let mut cmd = Command::new(path);
cmd.args(args)
.envs(spec.env.iter())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd
.spawn()
.map_err(|e| Error::Backend(format!("spawning `{path}`: {e}")))?;
let output = Arc::new(Mutex::new(String::new()));
for pipe in [
child.stdout.take().map(PipeEnd::Out),
child.stderr.take().map(PipeEnd::Err),
]
.into_iter()
.flatten()
{
let sink = Arc::clone(&output);
drop(gatling::background::Job::spawn(move || {
let reader: Box<dyn std::io::Read + Send> = match pipe {
PipeEnd::Out(o) => Box::new(o),
PipeEnd::Err(e) => Box::new(e),
};
for line in BufReader::new(reader).lines().map_while(std::result::Result::ok) {
let mut buf = sink.lock().unwrap();
buf.push_str(&line);
buf.push('\n');
}
}));
}
let id = format!("exe-{}-{}", spec.name, child.id());
let machine = Machine::started(&id, spec);
self.live.lock().unwrap().insert(
id,
Running {
child,
output,
started: Instant::now(),
},
);
Ok(machine)
}
}
enum PipeEnd {
Out(std::process::ChildStdout),
Err(std::process::ChildStderr),
}
impl Lifecycle for ExeBoot {
fn power_on(&self, machine: &Machine) -> Result<()> {
let _ = machine;
Err(Error::Unsupported(
"an exe instance is fire-and-forget: re-run by calling draupnir::boot() again".into(),
))
}
fn power_off(&self, machine: &Machine) -> Result<()> {
let mut running = self
.live
.lock()
.unwrap()
.remove(&machine.id)
.ok_or_else(|| Error::Backend(format!("no live exe process for {}", machine.id)))?;
let _ = running.child.kill();
let _ = running.child.wait();
Ok(())
}
fn status(&self, machine: &Machine) -> Result<PowerState> {
let mut guard = self.live.lock().unwrap();
let Some(running) = guard.get_mut(&machine.id) else {
return Ok(PowerState::Unknown);
};
match running.child.try_wait() {
Ok(None) => Ok(PowerState::On),
Ok(Some(_)) => Ok(PowerState::Off),
Err(e) => Err(Error::Backend(format!("try_wait on {}: {e}", machine.id))),
}
}
}
impl ExeBoot {
pub fn uptime(&self, machine: &Machine) -> Option<Duration> {
self.live
.lock()
.unwrap()
.get(&machine.id)
.map(|r| r.started.elapsed())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Backend, BootOrder};
const TRUE_BIN: &str = "/bin/sh";
fn sh(name: &str, script: &str) -> BootSpec {
BootSpec::exe(name, TRUE_BIN, ["-c", script])
}
#[test]
fn an_exe_spec_validates_and_carries_its_argv_in_the_image() {
let spec = BootSpec::exe("hello", "/bin/echo", ["hi", "there"]);
spec.validate().unwrap();
assert_eq!(spec.backend, Backend::Exe);
let (path, args) = ExeBoot::new().program(&spec).unwrap();
assert_eq!(path, "/bin/echo");
assert_eq!(args, ["hi".to_string(), "there".to_string()]);
assert!(spec.cmd.is_empty());
}
#[test]
fn an_empty_executable_path_is_rejected_by_name() {
let spec = BootSpec::exe("blank", " ", Vec::<String>::new());
let err = spec.validate().unwrap_err();
assert!(
format!("{err}").contains("executable path"),
"the failure NAMES the field: {err}"
);
}
#[test]
fn a_missing_binary_fails_by_name_before_anything_is_spawned() {
let spec = BootSpec::exe("ghost", "/nonexistent/never/built", Vec::<String>::new());
let err = ExeBoot::new().boot(&spec).unwrap_err();
let msg = format!("{err}");
assert!(matches!(err, Error::Spec(_)), "a missing artifact is a SPEC fault: {msg}");
assert!(msg.contains("/nonexistent/never/built"), "names the path: {msg}");
assert!(msg.contains("stage 1"), "names whose job it was: {msg}");
}
#[test]
fn a_medium_or_boot_order_on_an_exe_spec_is_rejected() {
let m = BootSpec::exe("x", "/bin/true", Vec::<String>::new()).with_medium("/x.iso");
assert!(matches!(m.validate(), Err(Error::Spec(_))), "medium on exe rejected");
let b = BootSpec::exe("x", "/bin/true", Vec::<String>::new())
.with_boot_order(BootOrder::Disk);
assert!(matches!(b.validate(), Err(Error::Spec(_))), "boot order on exe rejected");
}
#[test]
fn a_marker_printed_by_a_process_that_then_serves_forever_is_seen_and_the_process_survives() {
let backend = ExeBoot::new();
let spec = sh("server", "echo APPLIANCE-READY; sleep 30");
let m = backend.boot(&spec).unwrap();
let seen = backend
.await_marker(&m, "APPLIANCE-READY", Duration::from_secs(10), Duration::from_millis(20))
.unwrap();
assert!(seen.saw_marker(), "marker seen, got {seen:?}");
match &seen {
Seen::Marker { line, .. } => assert_eq!(line, "APPLIANCE-READY"),
other => panic!("expected the matched LINE to be carried, got {other:?}"),
}
assert_eq!(backend.status(&m).unwrap(), PowerState::On, "still running");
assert!(backend.uptime(&m).is_some());
backend.power_off(&m).unwrap();
assert_eq!(backend.status(&m).unwrap(), PowerState::Unknown, "reaped");
}
#[test]
fn a_deadline_returns_what_was_seen_rather_than_a_verdict() {
let backend = ExeBoot::new();
let spec = sh("quiet", "echo SOMETHING-ELSE; sleep 30");
let m = backend.boot(&spec).unwrap();
let seen = backend
.await_marker(&m, "NEVER-PRINTED", Duration::from_millis(600), Duration::from_millis(20))
.unwrap();
match &seen {
Seen::StillRunning { tail, .. } => {
assert!(tail.contains("SOMETHING-ELSE"), "carries real output: {tail:?}");
}
other => panic!("expected StillRunning, got {other:?}"),
}
assert!(!seen.saw_marker());
assert!(seen.detail().contains("SOMETHING-ELSE"), "detail quotes it: {}", seen.detail());
backend.power_off(&m).unwrap();
}
#[test]
fn a_process_that_dies_before_the_marker_reports_the_exit_and_its_stderr() {
let backend = ExeBoot::new();
let spec = sh("dies", "echo 'bind: address already in use' >&2; exit 3");
let m = backend.boot(&spec).unwrap();
let seen = backend
.await_marker(&m, "APPLIANCE-READY", Duration::from_secs(10), Duration::from_millis(20))
.unwrap();
match &seen {
Seen::Exited { code, tail, .. } => {
assert_eq!(*code, Some(3), "the exit code survives");
assert!(tail.contains("address already in use"), "STDERR is captured: {tail:?}");
}
other => panic!("expected Exited, got {other:?}"),
}
assert!(seen.detail().contains("EXITED"), "{}", seen.detail());
backend.power_off(&m).unwrap();
}
#[test]
fn a_marker_printed_immediately_before_exit_is_still_seen() {
let backend = ExeBoot::new();
let spec = sh("flash", "echo APPLIANCE-READY");
let m = backend.boot(&spec).unwrap();
let seen = backend
.await_marker(&m, "APPLIANCE-READY", Duration::from_secs(10), Duration::from_millis(50))
.unwrap();
assert!(seen.saw_marker(), "marker printed just before exit is still seen: {seen:?}");
backend.power_off(&m).unwrap();
}
#[test]
fn env_from_the_spec_reaches_the_process() {
let backend = ExeBoot::new();
let spec = BootSpec::exe("envy", TRUE_BIN, ["-c", "echo GOT=$DRAUPNIR_PROBE"])
.with_env("DRAUPNIR_PROBE", "42");
let m = backend.boot(&spec).unwrap();
let seen = backend
.await_marker(&m, "GOT=42", Duration::from_secs(10), Duration::from_millis(20))
.unwrap();
assert!(seen.saw_marker(), "env reached the child: {seen:?}");
backend.power_off(&m).unwrap();
}
#[test]
fn a_chatty_process_does_not_deadlock_on_a_full_pipe() {
let backend = ExeBoot::new();
let spec = sh(
"chatty",
"i=0; while [ $i -lt 4000 ]; do echo \"filler line $i 0123456789012345678901234567890123456789\"; i=$((i+1)); done; echo APPLIANCE-READY; sleep 5",
);
let m = backend.boot(&spec).unwrap();
let seen = backend
.await_marker(&m, "APPLIANCE-READY", Duration::from_secs(20), Duration::from_millis(20))
.unwrap();
assert!(seen.saw_marker(), "marker after >64KiB of output: {seen:?}");
let captured = backend.output(&m).unwrap();
assert!(captured.len() > 64 * 1024, "really did exceed a pipe buffer: {}", captured.len());
backend.power_off(&m).unwrap();
}
#[test]
fn awaiting_a_machine_this_backend_never_started_is_an_error_not_a_verdict() {
let backend = ExeBoot::new();
let m = Machine {
id: "exe-not-live-1".into(),
spec_name: "x".into(),
backend: Backend::Exe,
power: PowerState::Unknown,
};
assert!(matches!(
backend.await_marker(&m, "x", Duration::from_millis(10), Duration::from_millis(5)),
Err(Error::Backend(_))
));
assert_eq!(backend.status(&m).unwrap(), PowerState::Unknown);
assert!(backend.output(&m).is_none());
}
}