probe/src/bin/skidbladnir-scenario.rs
//! `skidbladnir-scenario` — the **in-guest plan interpreter** of the VM systemd
//! suite.
//!
//! Injected as a static-musl binary, it reads a host-authored plan (see
//! [`skidbladnir_probe::plan`]), runs each case's steps against the guest's REAL
//! init system, and writes one applied-state facts line per case
//! ([`skidbladnir_probe::facts`]) to the report channel — normally the
//! `org.skidbladnir.report` virtio-serial port the host is tailing.
//!
//! It reports; it never grades. Every verdict is computed host-side by a pure
//! function that can be shown failing, so no assertion in this suite is one the
//! guest could quietly decide to pass.
//!
//! Usage:
//! ```text
//! skidbladnir-scenario --plan /mnt/skid/plan.txt \
//! --report /dev/virtio-ports/org.skidbladnir.report \
//! [--also /run/skid-report.jsonl]
//! ```
//! Exit is always 0 while a report channel is writable: a case that fails is DATA
//! (a facts line the host grades red), not a non-zero exit that would leave the host
//! with nothing to grade.
use std::io::Write;
use skidbladnir_probe::facts::{collect, Facts};
use skidbladnir_probe::plan::{parse_plan, Step};
use skidbladnir_probe::{hex_encode, run_argv};
fn arg(flag: &str) -> Option<String> {
let a: Vec<String> = std::env::args().collect();
a.iter().position(|x| x == flag).and_then(|i| a.get(i + 1).cloned())
}
/// `<version> (<git-hash>)` — the same build stamp the probe carries, so a facts
/// line can always be traced to the commit that produced it.
pub const VERSION_LINE: &str =
concat!(env!("CARGO_PKG_VERSION"), " (", env!("SKIDBLADNIR_PROBE_GIT_HASH"), ")");
/// Append `line` to every open report channel. Opened per write (append) so the
/// virtio-serial port is never held across a case — a wedged port must not silently
/// swallow later cases.
fn report(channels: &[String], line: &str) {
for ch in channels {
if let Ok(mut f) = std::fs::OpenOptions::new().append(true).create(true).open(ch) {
let _ = writeln!(f, "{line}");
let _ = f.flush();
}
}
}
/// Write `contents` to `path` with octal `mode`, creating parents. Returns the
/// `"<exit> <what>"` audit string the facts line carries.
fn write_file(path: &str, mode: u32, contents: &[u8]) -> String {
if let Some(parent) = std::path::Path::new(path).parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
return format!("-1 WRITE {path} (mkdir: {e})");
}
}
if let Err(e) = std::fs::write(path, contents) {
return format!("-1 WRITE {path} ({e})");
}
// `chmod` via std (no libc dep): set_permissions with a raw mode.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) {
return format!("-1 WRITE {path} (chmod: {e})");
}
}
format!("0 WRITE {path} ({} bytes, mode {mode:o})", contents.len())
}
fn main() {
if std::env::args().skip(1).any(|a| a == "--version" || a == "-V") {
println!("skidbladnir-scenario {VERSION_LINE}");
return;
}
let plan_path = match arg("--plan") {
Some(p) => p,
None => {
eprintln!("skidbladnir-scenario: --plan <file> is required");
std::process::exit(2);
}
};
let mut channels: Vec<String> = Vec::new();
if let Some(r) = arg("--report") {
channels.push(r);
}
if let Some(a) = arg("--also") {
channels.push(a);
}
if channels.is_empty() {
channels.push("/dev/stdout".to_string());
}
let text = match std::fs::read_to_string(&plan_path) {
Ok(t) => t,
Err(e) => {
// A plan we cannot read is a run that asserts NOTHING — say so on the
// channel so the host sees a red, not a silent absence it might read as
// "no failures".
report(&channels, &format!("{{\"error\":\"read plan {plan_path}: {e}\"}}"));
std::process::exit(0);
}
};
let plan = match parse_plan(&text) {
Ok(p) => p,
Err(e) => {
report(&channels, &format!("{{\"error\":\"parse plan: {e}\"}}"));
std::process::exit(0);
}
};
report(
&channels,
&format!(
"{{\"scenario_start\":\"{}\",\"cases\":{},\"plan_sha_hex_prefix\":\"{}\"}}",
VERSION_LINE,
plan.cases.len(),
&hex_encode(text.as_bytes())[..16.min(text.len() * 2)],
),
);
for case in &plan.cases {
let mut exits: Vec<String> = Vec::new();
let mut captures: Vec<String> = Vec::new();
let mut emitted = false;
for step in &case.steps {
match step {
Step::Run(argv) => {
let (code, _out, err) = run_argv(argv);
let mut line = format!("{code} {}", argv.join(" "));
if code != 0 {
let tail: String = err.lines().take(2).collect::<Vec<_>>().join("; ");
if !tail.is_empty() {
line.push_str(&format!(" :: {tail}"));
}
}
exits.push(line);
}
Step::Write { path, mode, contents } => {
exits.push(write_file(path, *mode, contents));
}
Step::Rm(p) => {
let r = std::fs::remove_file(p);
exits.push(match r {
Ok(()) => format!("0 RM {p}"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => format!("0 RM {p} (absent)"),
Err(e) => format!("-1 RM {p} ({e})"),
});
}
Step::RmTree(p) => {
let r = std::fs::remove_dir_all(p);
exits.push(match r {
Ok(()) => format!("0 RMTREE {p}"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => format!("0 RMTREE {p} (absent)"),
Err(e) => format!("-1 RMTREE {p} ({e})"),
});
}
Step::Sleep(ms) => {
std::thread::sleep(std::time::Duration::from_millis(*ms));
exits.push(format!("0 SLEEP {ms}"));
}
Step::Capture { key, argv } => {
let (code, out, err) = run_argv(argv);
// The BYTES, always — including when the command failed. A capture
// that silently became empty on a non-zero exit would be
// indistinguishable from a command that legitimately produced
// nothing, and the host judge would have no way to tell (LAW 2).
captures.push(format!("{key}={out}"));
let mut line = format!("{code} CAPTURE {key} {}", argv.join(" "));
if code != 0 {
let tail: String = err.lines().take(2).collect::<Vec<_>>().join("; ");
line.push_str(&format!(" :: {tail}"));
}
exits.push(line);
}
Step::WaitFor { timeout_ms, poll_ms, argv } => {
let t0 = std::time::Instant::now();
let (code, last_err) = loop {
let (c, _out, err) = run_argv(argv);
if c == 0 || t0.elapsed().as_millis() as u64 >= *timeout_ms {
break (c, err);
}
std::thread::sleep(std::time::Duration::from_millis(*poll_ms));
};
let waited = t0.elapsed().as_millis();
// The LATENCY is the fact — recorded whether it succeeded or timed
// out, so a case can assert "this appeared on its own, and it took
// N ms" instead of only "eventually, somehow".
let mut line =
format!("{code} WAITFOR {waited}ms/{timeout_ms}ms {}", argv.join(" "));
if code != 0 {
line.push_str(&format!(
" :: TIMED OUT :: {}",
last_err.lines().take(2).collect::<Vec<_>>().join("; ")
));
}
exits.push(line);
}
Step::Collect { unit, exec } => {
let f = collect(
&case.id,
unit,
exec,
std::mem::take(&mut exits),
std::mem::take(&mut captures),
);
report(&channels, &f.to_json_line());
emitted = true;
}
Step::PowerOff => {
report(&channels, &format!("{{\"scenario_end\":\"{}\"}}", case.id));
// SCHEDULE the power-off and RETURN — do not power off from here.
//
// On the first boot this process is a child of cloud-init's
// `scripts-user` module, and cloud-init writes that module's
// per-instance semaphore only after the module RETURNS. Powering
// off from inside it leaves the semaphore unwritten, so the NEXT
// boot re-runs the entire plan from cloud-init instead of being
// the fresh, already-installed boot the ordering case needs.
// (Measured on oden 2026-08-03: boot B replayed all eight boot-A
// cases and produced no ordering facts at all.)
let (code, _, err) = run_argv(&[
"systemd-run".into(),
"--on-active=20".into(),
"--timer-property=AccuracySec=1s".into(),
"systemctl".into(),
"poweroff".into(),
]);
if code != 0 {
report(
&channels,
&format!(
"{{\"note\":\"systemd-run unavailable ({}); powering off inline — \
the next boot may re-run cloud-init\"}}",
err.lines().next().unwrap_or("").replace('"', "'")
),
);
let _ = run_argv(&["systemctl".into(), "poweroff".into(), "--no-block".into()]);
}
return;
}
}
}
if !emitted {
// A case with steps but no COLLECT still reports its exits, so a setup
// case that failed halfway is visible rather than invisible.
let mut f = Facts { case: case.id.clone(), ..Default::default() };
f.exits = exits;
f.captures = captures;
f.at = skidbladnir_probe::iso8601_utc();
report(&channels, &f.to_json_line());
}
}
report(&channels, "{\"scenario_end\":\"plan-complete\"}");
}