diff --git a/crates/mind-body/src/cli.rs b/crates/mind-body/src/cli.rs index 7c80658..ba89647 100644 --- a/crates/mind-body/src/cli.rs +++ b/crates/mind-body/src/cli.rs @@ -19,10 +19,11 @@ use std::str::FromStr; use anyhow::{Context, Result, anyhow, bail}; use chrono::{DateTime, Utc}; -use eureka_pipeline::Slug; +use eureka_pipeline::{RunTurn, Slug}; use rust_decimal::Decimal; use crate::control::{BurnRate, ControlWriter, FileSource, control_path, derive, load_state}; +use crate::launch::unit; const USAGE: &str = "usage: mind-control [--root ] --instance show | brake hold | brake release | dial set --heat H --base-cooldown-s S --base-run-usd U"; @@ -63,7 +64,7 @@ pub fn run( let stopped = stop_units(&instance); match (written, stopped) { (Ok(()), Ok(())) => { - writeln!(out, "units: stopped mind-self@{0}:* mind-persona@{0}:*", instance.0)?; + writeln!(out, "units: stopped {} {}", unit(&instance, RunTurn::SelfRun)?, unit(&instance, RunTurn::PersonaTurn)?)?; Ok(()) } (Err(write), Ok(())) => Err(write.context("the brake was not written; the units were stopped")), diff --git a/crates/mind-body/src/launch.rs b/crates/mind-body/src/launch.rs index df89a74..e135c2c 100644 --- a/crates/mind-body/src/launch.rs +++ b/crates/mind-body/src/launch.rs @@ -107,8 +107,8 @@ impl Default for SystemdLauncher { } } -/// The unit template of one turn. Start, alive and stop all name units from -/// these two. +/// The unit template of one turn, named only through `unit()`, which start, +/// alive and stop all call. fn template(turn: RunTurn) -> &'static str { match turn { RunTurn::PersonaTurn => "mind-persona", @@ -124,23 +124,27 @@ pub(crate) fn unit(instance: &Slug, turn: RunTurn) -> Result { Ok(format!("{}@{}.service", template(turn), instance.0)) } -/// The arguments of the one synchronous stop: every unit of both turns of this -/// instance, matched by glob. The `:` after the instance keeps `eureka` from -/// matching `eureka-two`. No `--no-block`: `systemctl stop` returns when the -/// units are down. The instance is not validated here; `stop_all` does. -pub(crate) fn stop_args(instance: &Slug) -> Vec { +/// The arguments of the one synchronous stop: the exact unit `unit()` names +/// for each turn of this instance, the same names start and alive use. No +/// glob: a prefix glob also matches another instance (`eureka*` matches +/// `eureka-two`) and a glob that matches no loaded unit exits 0. No +/// `--no-block`: `systemctl stop` returns when the units are down. The +/// instance is validated by `unit()`. +pub(crate) fn stop_args(instance: &Slug) -> Result> { let mut args = vec!["stop".to_string(), "--".to_string()]; - args.extend([RunTurn::SelfRun, RunTurn::PersonaTurn].map(|turn| format!("{}@{}:*.service", template(turn), instance.0))); - args + for turn in [RunTurn::SelfRun, RunTurn::PersonaTurn] { + args.push(unit(instance, turn)?); + } + Ok(args) } impl SystemdLauncher { /// Stops every unit of both turns of one instance and waits for them. An /// inherent method, not on `Launcher`: only the operator's brake hold - /// calls it. A glob that matches no loaded unit is a success. + /// calls it. A unit that is not installed is systemctl's exit 5, and the + /// others still stop. pub fn stop_all(&self, instance: &Slug) -> Result<()> { - instance.validate_slug().map_err(|_| anyhow!("the instance is not a unit name"))?; - let args = stop_args(instance); + let args = stop_args(instance)?; let args: Vec<&str> = args.iter().map(String::as_str).collect(); let reply = self.systemctl.run(&args)?; if reply.success { @@ -1169,20 +1173,28 @@ mod tests { } #[test] - fn stop_args_is_one_synchronous_stop_scoped_to_one_instance() { - let args = stop_args(&eureka()); - assert_eq!(args, ["stop", "--", "mind-self@eureka:*.service", "mind-persona@eureka:*.service"]); - // The text before each glob star must not be a prefix of another instance's unit. - for (pattern, other) in [(&args[2], "mind-self@eureka-two:x.service"), (&args[3], "mind-persona@eureka-two:x.service")] { - assert!(!other.starts_with(pattern.split('*').next().unwrap()), "{pattern}"); + fn stop_args_is_one_synchronous_stop_of_exactly_the_units_start_names() { + for name in ["eureka", "eureka.test", "eureka-two"] { + let instance = Slug(name.into()); + let args = stop_args(&instance).unwrap(); + assert_eq!(args[..2], ["stop", "--"], "one synchronous stop, no --no-block"); + let (launcher, spy) = spied(); + let mut started = Vec::new(); + for turn in [RunTurn::SelfRun, RunTurn::PersonaTurn] { + launcher.start(&instance, turn).unwrap(); + started.push(spy.calls.borrow().last().unwrap().last().unwrap().clone()); + } + assert_eq!(args[2..], started[..], "stop names exactly the units start names, for both turns"); + assert!(args[2..].iter().all(|unit| !unit.contains(['*', '?', '['])), "a glob can match another instance or none: {args:?}"); } + assert_eq!(stop_args(&eureka()).unwrap(), ["stop", "--", "mind-self@eureka.service", "mind-persona@eureka.service"]); } #[test] fn stop_all_runs_the_one_stop_and_waits() { let (launcher, spy) = spied(); launcher.stop_all(&eureka()).unwrap(); - assert_eq!(*spy.calls.borrow(), vec![stop_args(&eureka())]); + assert_eq!(*spy.calls.borrow(), vec![stop_args(&eureka()).unwrap()]); } #[test] diff --git a/crates/mind-body/tests/cli.rs b/crates/mind-body/tests/cli.rs index 1529015..9721e81 100644 --- a/crates/mind-body/tests/cli.rs +++ b/crates/mind-body/tests/cli.rs @@ -168,7 +168,7 @@ fn brake_hold_stops_once_with_the_instance_and_the_store_already_held() { result.unwrap(); assert_eq!(calls, ["eureka"]); assert!(seen_at_stop.contains("BrakeHeld"), "the write comes before the stop: {seen_at_stop}"); - assert!(out.contains("units: stopped mind-self@eureka:* mind-persona@eureka:*"), "{out}"); + assert!(out.contains("units: stopped mind-self@eureka.service mind-persona@eureka.service"), "{out}"); } #[test] @@ -223,3 +223,44 @@ fn a_failing_write_still_stops_and_the_hold_fails() { assert_eq!(calls.len(), 1); assert!(error.contains("brake was not written") && error.contains("units were not stopped"), "{error}"); } + +/// Runs the real binary's `brake hold` with a `systemctl` of ours first on +/// PATH, so the stopper main.rs wires is the one under test. The fake records +/// its arguments, one per line, and exits with `exit`. +#[cfg(unix)] +fn hold_through_the_binary(exit: i32) -> (std::process::Output, Vec) { + use std::os::unix::fs::PermissionsExt; + let bin = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + let record = bin.path().join("recorded"); + let script = bin.path().join("systemctl"); + std::fs::write(&script, format!("#!/bin/sh\nprintf '%s\n' \"$@\" > '{}'\nexit {exit}\n", record.display())).unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + let output = std::process::Command::new(env!("CARGO_BIN_EXE_mind-control")) + .env("PATH", bin.path()) + .args(["--root", root.path().to_str().unwrap(), "--instance", "eureka", "brake", "hold"]) + .output() + .unwrap(); + let recorded = std::fs::read_to_string(&record).map(|text| text.lines().map(str::to_string).collect()).unwrap_or_default(); + (output, recorded) +} + +#[cfg(unix)] +#[test] +fn the_binary_hold_runs_systemctl_stop_on_exactly_the_units_it_starts() { + let (output, recorded) = hold_through_the_binary(0); + assert_eq!(output.status.code(), Some(0), "{}", String::from_utf8_lossy(&output.stderr)); + assert_eq!(recorded, ["stop", "--", "mind-self@eureka.service", "mind-persona@eureka.service"]); + assert!(String::from_utf8_lossy(&output.stdout).contains("units: stopped mind-self@eureka.service mind-persona@eureka.service")); +} + +#[cfg(unix)] +#[test] +fn the_binary_hold_fails_with_status_2_when_systemctl_does() { + let (output, recorded) = hold_through_the_binary(5); + assert_eq!(output.status.code(), Some(2)); + assert_eq!(recorded.len(), 4, "systemctl was run: {recorded:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("units were not stopped") && stderr.contains("exit status 5"), "{stderr}"); + assert!(!String::from_utf8_lossy(&output.stdout).contains("stopped")); +}