Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/Skidbladnir

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\"}");
}