diff --git a/crates/mind-body/src/cli.rs b/crates/mind-body/src/cli.rs index ba89647..edd59aa 100644 --- a/crates/mind-body/src/cli.rs +++ b/crates/mind-body/src/cli.rs @@ -7,8 +7,12 @@ //! mind-control --instance brake hold (also stops her running units) //! mind-control --instance brake release //! mind-control --instance dial set --heat H --base-cooldown-s S --base-run-usd U +//! mind-control [--root ] --instance check //! ``` //! +//! `check` exits 0 only when the brake is released and the dial in bounds; a +//! systemd unit runs it as its `ExecCondition`. It writes nothing. +//! //! `brake hold` writes the brake held and then, whatever the write's outcome, //! stops the instance's running units through the `stop_units` parameter. It //! fails if either step failed. No other command stops anything. @@ -22,11 +26,11 @@ use chrono::{DateTime, Utc}; use eureka_pipeline::{RunTurn, Slug}; use rust_decimal::Decimal; -use crate::control::{BurnRate, ControlWriter, FileSource, control_path, derive, load_state}; +use crate::control::{BurnRate, ControlWriter, Effective, 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"; + "usage: mind-control [--root ] --instance show | check | brake hold | brake release | dial set --heat H --base-cooldown-s S --base-run-usd U"; /// Who made the change: `SUDO_USER`, else `USER`, else `unknown`. An empty /// variable counts as unset. @@ -59,6 +63,7 @@ pub fn run( let words: Vec<&str> = command.iter().map(String::as_str).collect(); match words.as_slice() { ["show"] => show(root, &instance, out), + ["check"] => check(&path, &instance, out), ["brake", "hold"] => { let written = ControlWriter::open(&path).and_then(|mut writer| writer.set_brake(false, now, set_by)); let stopped = stop_units(&instance); @@ -109,6 +114,20 @@ fn need<'a>(value: Option<&'a str>, name: &str) -> Result<&'a str> { value.ok_or_else(|| anyhow!("{name} is required")) } +/// The gate a unit runs before it starts: Ok only when the one derivation +/// (`derive`, the reader behind `read_effective`) says Released. It is a pure +/// read through `FileSource`: it opens no writer and creates no file or +/// directory. Any other reading is an error, which `main` turns into exit 2, +/// and systemd's `ExecCondition` skips the unit on 1-254. +fn check(path: &Path, instance: &Slug, out: &mut impl Write) -> Result<()> { + let effective = derive(&FileSource::new(path), instance); + writeln!(out, "effective: {effective:?}")?; + match effective { + Effective::Released(_) => Ok(()), + Effective::Held { reason } => bail!("held: {reason:?}"), + } +} + fn show(root: &Path, instance: &Slug, out: &mut impl Write) -> Result<()> { let source = FileSource::new(control_path(root, instance)?); writeln!(out, "instance: {}", instance.0)?; diff --git a/crates/mind-body/tests/cli.rs b/crates/mind-body/tests/cli.rs index 9721e81..1be4171 100644 --- a/crates/mind-body/tests/cli.rs +++ b/crates/mind-body/tests/cli.rs @@ -128,6 +128,43 @@ fn the_binary_exits_nonzero_with_the_usage_when_refused() { assert!(output.stdout.is_empty()); } +#[test] +fn the_binary_exits_2_for_check_while_held_and_0_after_release_with_a_dial() { + let root = tempfile::tempdir().unwrap(); + let run = |args: &[&str]| { + std::process::Command::new(env!("CARGO_BIN_EXE_mind-control")) + .arg("--root") + .arg(root.path()) + .args(["--instance", "eureka"]) + .args(args) + .output() + .unwrap() + }; + // Absent store: refused, and the gate wrote nothing. + let output = run(&["check"]); + assert_eq!(output.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&output.stderr).contains("held: BrakeAbsent")); + assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0); + + // Released with no dial is still refused; with a dial it opens. + assert!(run(&["brake", "release"]).status.success()); + assert_eq!(run(&["check"]).status.code(), Some(2)); + assert!(run(&["dial", "set", "--heat", "0.5", "--base-cooldown-s", "3600", "--base-run-usd", "5"]).status.success()); + let open = run(&["check"]); + assert_eq!(open.status.code(), Some(0)); + assert!(String::from_utf8_lossy(&open.stdout).contains("Released")); + + // Holding closes it again. The brake is written through the library: the + // binary's `brake hold` would also ask systemd to stop units. + mind_body::control::ControlWriter::open(root.path().join("eureka").join("control.cc")) + .unwrap() + .set_brake(false, Utc.timestamp_opt(1_800_000_009, 0).unwrap(), "op") + .unwrap(); + let closed = run(&["check"]); + assert_eq!(closed.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&closed.stderr).contains("held: BrakeHeld")); +} + #[test] fn a_leading_root_flag_replaces_the_default_root() { let default = tempfile::tempdir().unwrap(); @@ -152,6 +189,76 @@ fn a_root_flag_without_a_directory_or_an_instance_is_refused_and_writes_nothing( assert!(!std::path::Path::new("/nonexistent-root-for-test").exists()); } +fn check_out(root: &std::path::Path) -> (anyhow::Result<()>, String) { + go(root, &["--instance", "eureka", "check"], "op", 0) +} + +#[test] +fn check_refuses_every_reading_but_released() { + let root = tempfile::tempdir().unwrap(); + let held = |root: &std::path::Path, reason: &str| { + let (result, out) = check_out(root); + let error = format!("{:#}", result.unwrap_err()); + assert!(error.contains(reason), "{reason}: {error}"); + assert!(out.contains(reason), "{reason}: {out}"); + }; + held(root.path(), "BrakeAbsent"); + go(root.path(), &["--instance", "eureka", "brake", "hold"], "op", 1).0.unwrap(); + held(root.path(), "BrakeHeld"); + go(root.path(), &["--instance", "eureka", "brake", "release"], "op", 2).0.unwrap(); + held(root.path(), "DialAbsent"); + go(root.path(), &["--instance", "eureka", "dial", "set", "--heat", "1000", "--base-cooldown-s", "3600", "--base-run-usd", "5"], "op", 3) + .0 + .unwrap(); + held(root.path(), "DialOutOfBounds"); + // An undecodable store is a held reading too. + let broken = tempfile::tempdir().unwrap(); + std::fs::create_dir(broken.path().join("eureka")).unwrap(); + std::fs::write(broken.path().join("eureka").join("control.cc"), b"not a store").unwrap(); + held(broken.path(), "Undecodable"); +} + +#[test] +fn check_opens_on_release_with_a_dial_and_is_per_instance() { + let root = tempfile::tempdir().unwrap(); + go(root.path(), &["--instance", "eureka", "brake", "release"], "op", 0).0.unwrap(); + go(root.path(), &["--instance", "eureka", "dial", "set", "--heat", "0.5", "--base-cooldown-s", "3600", "--base-run-usd", "5"], "op", 1) + .0 + .unwrap(); + let (result, out) = check_out(root.path()); + result.unwrap(); + assert!(out.contains("Released"), "{out}"); + // Another instance's store is a different file: still absent, still refused. + let (other, _) = go(root.path(), &["--instance", "other", "check"], "op", 2); + assert!(format!("{:#}", other.unwrap_err()).contains("BrakeAbsent")); +} + +#[test] +fn check_writes_nothing_and_creates_no_directory() { + let root = tempfile::tempdir().unwrap(); + assert!(check_out(root.path()).0.is_err()); + assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0); + + go(root.path(), &["--instance", "eureka", "brake", "release"], "op", 0).0.unwrap(); + let file = root.path().join("eureka").join("control.cc"); + let before = (std::fs::read(&file).unwrap(), std::fs::metadata(&file).unwrap().modified().unwrap()); + let _ = check_out(root.path()); + let _ = check_out(root.path()); + assert_eq!((std::fs::read(&file).unwrap(), std::fs::metadata(&file).unwrap().modified().unwrap()), before); + assert_eq!(std::fs::read_dir(root.path().join("eureka")).unwrap().count(), 1); +} + +#[test] +fn check_takes_no_extra_word_and_needs_an_instance() { + let root = tempfile::tempdir().unwrap(); + go(root.path(), &["--instance", "eureka", "brake", "release"], "op", 0).0.unwrap(); + for args in [&["--instance", "eureka", "check", "now"][..], &["--instance", "eureka", "check", "show"][..], &["check"][..]] { + let (result, out) = go(root.path(), args, "op", 1); + assert!(format!("{:#}", result.unwrap_err()).contains("usage: mind-control"), "{args:?}"); + assert!(out.is_empty(), "{args:?}: {out}"); + } +} + fn shown(root: &std::path::Path) -> String { go(root, &["--instance", "eureka", "show"], "op", 0).1 }