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

nordisk/gunnar

crates/gunnar-server/tests/appliance_container_robot.rs

//! **The container-form ceremony robot, gunnar side** — the DECLARED
//! `gunnar-scratch` cell built and run for real, its durability laws proven on
//! applied output (container stdout, files in the volume), never on exit codes.
//!
//! Emits the `gunnar-appliance/container` rows the appliance rollup
//! (`edda/crates/nornir-testmatrix/src/appliance.rs`) folds into gunnar's row of
//! the (product × proof) grid: `identity_minted`,
//! `identity_survives_replacement`, `identity_survives_upgrade`. Measured
//! 2026-08-15, before this file existed those three cells were `Absent` for
//! gunnar while holger's were green — the hole the rollup is designed to show.
//!
//! Heavy (a musl release build of gunnar-server plus two image builds), so the
//! arm is `#[ignore]`d and opted into explicitly:
//!
//! ```text
//! CARGO_TARGET_DIR=~/scratch/cargo/gunnar-appliance \
//!   cargo test -p gunnar-server --features testmatrix,container-robot \
//!   --test appliance_container_robot -- --ignored --nocapture
//! ```
//!
//! # One writer throughout
//!
//! Nothing here spells an image argv, a Containerfile, a cargo flag set or a
//! gate env. The image comes from the declared cell's own
//! `build_args(engine)` / `containerfile()`, the payload binary from the
//! `gunnar-server` variant's own `cargo_args()`, the ceremony gate from the
//! cell's declared `GUNNAR_APPLIANCE = "1"` env (so the robot passes NO extra
//! run arguments — if it had to, the robot would be configuring the appliance
//! rather than proving it), and the identity line is read back through the
//! shared `wg_appliance_core::robot` mechanics that holger's robot uses. The
//! one recorded deviation is the upgrade fixture's build label + tag, which is
//! the smallest honest way to get a genuinely DIFFERENT image.
//!
//! Nothing container-mechanical is implemented here either: engine discovery,
//! the rootless-podman `:U` userns readback, the scrub and the three rungs live
//! in `wg-appliance-core`'s `robot` module, shared with holger. What is local to
//! gunnar is the declaration lookup, the staging and the matrix rows.
//!
//! # The declaration is READ, not linked, and that is a cargo constraint
//!
//! This called `gunnar_build_thing::GunnarBuildThing.variants()` until
//! 2026-08-30 and had not compiled for days. `gunnar-build-thing` is a
//! `[build-dependencies]` of this package (`build.rs` writes the receipt), so a
//! `cargo test` reaches it as a host unit AND — as a dev-dependency — as a
//! target unit; its `[lib] crate-type` includes `cdylib`, for which cargo emits
//! no `--extra-filename`, so both units write
//! `target/release/deps/libgunnar_build_thing.rlib` and the last one wins. The
//! symptoms were `E0599 … multiple different versions of crate
//! nornir_build_thing` and `failed to get bitcode from object file for LTO`.
//! The long form is in `Cargo.toml`, beside the dependency that is not there.
//!
//! So the set comes from `.nornir/build-variants.toml`, loaded with
//! `nornir-build-thing`'s own `BuildVariantSet::load_file` — dwarves' loader,
//! not a second parser. That file is the recipe's EMITTED mirror (code → toml,
//! one-directional) and `gunnar-build-thing/tests/toml_parity.rs` is what holds
//! it to `GunnarBuildThing::variants` field by field, so "one writer" still
//! holds: this robot reads the description, and that guard fails red the day
//! the description and the code disagree.

use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};

/// One matrix row, in the rollup's naming convention: `<product>-appliance/<form>`.
#[inline]
fn matrix(check: &str, ok: bool, detail: &str) {
    gunnar_server::functional_status("gunnar-appliance/container", check, ok, detail);
}

/// The target directory this test binary was built into — the same one a nested
/// `cargo build` writes to, because both inherit `CARGO_TARGET_DIR`. Derived
/// from the binary's own path rather than assumed to be `<repo>/target`, which
/// is wrong the moment the scratch-target law is followed.
fn target_dir() -> PathBuf {
    let exe = std::env::current_exe().expect("the test binary knows where it is");
    exe.ancestors()
        .nth(3)
        .expect("<target>/<profile>/deps/<bin> has three parents")
        .to_path_buf()
}

/// The workspace root: this crate's manifest dir, up two.
fn repo_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .ancestors()
        .nth(2)
        .expect("crates/gunnar-server has two parents")
        .to_path_buf()
}

fn run(cmd: &mut Command) -> Output {
    cmd.output()
        .unwrap_or_else(|e| panic!("spawn {cmd:?}: {e}"))
}

#[test]
#[ignore = "heavy: musl-builds gunnar-server and podman-builds the declared gunnar-scratch image twice; opt in with --ignored"]
fn the_declared_cell_mints_once_and_keeps_that_identity() {
    #[cfg(not(feature = "testmatrix"))]
    eprintln!(
        "NOTE appliance_container_robot: built WITHOUT --features testmatrix, so the \
         rungs below still RUN and still assert, but they record NO matrix row — \
         gunnar's container cells stay Absent in the rollup"
    );

    use nornir_build_thing::variant::BuildVariantSet;
    use wg_appliance_core::robot::{self, ContainerCeremony};

    // ── the engine, or a LOUD skip by name ───────────────────────────────
    let Some(engine) = robot::engine() else {
        eprintln!(
            "SKIP appliance_container_robot: neither podman nor docker on this host — \
             gunnar's container-shape ceremony is NOT proven here"
        );
        matrix(
            "engine_present",
            false,
            "neither podman nor docker on PATH — skipped",
        );
        return;
    };

    // ── the declaration, as the ONE writer ───────────────────────────────
    //
    // `conventional_path` + `load_file`, so the path and the parse are both
    // dwarves', and `toml_parity.rs` is what makes this the recipe's own answer.
    // A set that came back empty would make every `find` below fail rather than
    // pass, which is the failure direction this must have.
    let root = repo_root();
    let declared = BuildVariantSet::conventional_path(&root);
    let set = BuildVariantSet::load_file(&declared).unwrap_or_else(|err| {
        panic!(
            "load the declared build space from {}: {err:#}",
            declared.display()
        )
    });
    assert!(
        !set.variants.is_empty() && !set.containers.is_empty(),
        "{} parsed to {} variants and {} container cells; the loader is not reading \
         what this robot thinks it is",
        declared.display(),
        set.variants.len(),
        set.containers.len()
    );
    let cell = set
        .containers
        .iter()
        .find(|c| c.name == "gunnar-scratch")
        .expect("the declared gunnar-scratch cell");
    assert_eq!(
        cell.env.get("GUNNAR_APPLIANCE").map(String::as_str),
        Some("1"),
        "the ceremony gate must be DECLARED by the cell, not passed by this robot — \
         a robot that arms the appliance itself proves nothing about the shipped image"
    );
    let variant_name = cell
        .variant_deps()
        .first()
        .map(|v| (*v).to_string())
        .expect("the cell chains a cargo variant");
    let variant = set
        .get(&variant_name)
        .expect("the chained variant is declared");
    let base_tag = cell
        .primary_tag()
        .expect("the cell declares a tag")
        .to_string();

    // ── the payload binary, built with the VARIANT'S OWN argv ────────────
    let build = run(Command::new("cargo")
        .args(variant.cargo_args())
        .current_dir(&root));
    assert!(
        build.status.success(),
        "the {variant_name} variant must build:\n{}",
        robot::sh(&build)
    );
    // The musl cell carries a `target`, so its artifact lands under the
    // triple's own release dir — the separation that lets it share the bin
    // NAME with a host-built tier without colliding.
    let release_dir = match variant.target.as_deref() {
        Some(triple) => target_dir().join(triple).join("release"),
        None => target_dir().join("release"),
    };
    let bin = release_dir.join(variant.bin.as_deref().expect("the variant names a bin"));
    assert!(
        bin.exists(),
        "the variant's binary landed at {}",
        bin.display()
    );

    // ── a clean volume, on DISK, never /tmp (RAM on this box) ────────────
    let work = target_dir().join("appliance-container-robot");
    robot::scrub(engine, &work).expect("scrub the previous run");
    let ctx = work.join("ctx");
    std::fs::create_dir_all(&ctx).unwrap();
    // The builder's staging contract: a `from_variant` layer COPYs
    // `./<variant>`, and COPY preserves the tar's mode. `scratch` has no shell
    // for a `RUN chmod`, so the mode has to be right in the context or the
    // image comes out holding a `/gunnar` it cannot exec.
    let staged = ctx.join(variant_name.as_str());
    std::fs::copy(&bin, &staged).unwrap();
    {
        use std::os::unix::fs::PermissionsExt as _;
        std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)).unwrap();
    }

    // ── the image, from the cell's own Containerfile + argv ──────────────
    let containerfile = cell.containerfile();
    let build_image = |extra: &[&str]| -> Output {
        // Extra args (the upgrade fixture's label + tag) go BEFORE the trailing
        // positional context the cell's argv ends with.
        let mut args = cell.build_args(engine);
        let ctx_arg = args.pop().expect("build_args ends with the context");
        args.extend(extra.iter().map(|s| (*s).to_string()));
        args.push(ctx_arg);
        let mut c = Command::new(engine);
        c.args(&args).current_dir(&ctx).stdin(Stdio::piped());
        let mut child = c
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .expect("spawn image build");
        use std::io::Write as _;
        child
            .stdin
            .take()
            .unwrap()
            .write_all(containerfile.as_bytes())
            .unwrap();
        child.wait_with_output().expect("image build")
    };
    let built = build_image(&[]);
    assert!(
        built.status.success(),
        "the declared gunnar-scratch image must build (base is `scratch`, so no pull \
         and no network is involved):\n{}",
        robot::sh(&built)
    );
    matrix(
        "image_built",
        true,
        &format!("{base_tag} via {engine}, argv from the cell"),
    );

    // ── the three durability rungs, through the SHARED mechanics ─────────
    let vol = work.join("vol");
    std::fs::create_dir_all(&vol).unwrap();
    let ceremony = ContainerCeremony {
        engine,
        tag: &base_tag,
        volume: &vol,
        // The cell's own workdir / declared data root — not a literal.
        data_dir: cell
            .workdir
            .as_deref()
            .expect("the cell declares a workdir"),
        // EMPTY, and that is the point: the gate rides the declaration.
        extra_run_args: &[],
    };

    let minted = ceremony.mint();
    matrix("identity_minted", minted.ok, &minted.detail);
    minted.expect_ok("a fresh volume must MINT and announce the canonical line");
    let key1 = minted.pubkey.clone().expect("a green mint carries the key");

    let replaced = ceremony.survives_replacement(&key1);
    matrix(
        "identity_survives_replacement",
        replaced.ok,
        &replaced.detail,
    );
    replaced.expect_ok("a replacement container must present the SAME identity");

    // A genuinely different image: a build-time label is the smallest honest
    // difference. Everything else — format, Containerfile, base — is the cell's.
    let upgraded_tag = format!("{base_tag}-upgrade");
    let up = build_image(&["--label", "gunnar.robot=upgrade", "-t", &upgraded_tag]);
    assert!(
        up.status.success(),
        "upgrade image build:\n{}",
        robot::sh(&up)
    );
    let upgraded = ceremony.survives_upgrade(&upgraded_tag, &key1);
    matrix("identity_survives_upgrade", upgraded.ok, &upgraded.detail);
    upgraded.expect_ok("an image upgrade must not re-mint");

    // ── the red twin: a CORRUPT identity must refuse, not re-mint ────────
    // The control for all three rungs above. Without it "no MINTED line" could
    // mean "the ceremony never ran".
    let vol_bad = work.join("vol-corrupt");
    std::fs::create_dir_all(vol_bad.join("wg")).unwrap();
    std::fs::write(vol_bad.join("wg/identity.key"), "garbage, not a key\n").unwrap();
    {
        use std::os::unix::fs::PermissionsExt as _;
        std::fs::set_permissions(
            vol_bad.join("wg/identity.key"),
            std::fs::Permissions::from_mode(0o600),
        )
        .unwrap();
    }
    let bad_ceremony = ContainerCeremony {
        volume: &vol_bad,
        ..ceremony
    };
    let bad = bad_ceremony
        .run_once(&base_tag, &[])
        .expect("run the corrupt-volume container");
    let all_bad = robot::sh(&bad);
    assert!(
        !bad.status.success(),
        "a corrupt identity must not serve:\n{all_bad}"
    );
    assert!(
        all_bad.contains("identity.key") && all_bad.contains("refusing to re-mint"),
        "the refusal names the file and the rule:\n{all_bad}"
    );
    assert!(
        !all_bad.contains(robot::MINTED_MARKER),
        "corrupt never silently re-mints:\n{all_bad}"
    );
    matrix(
        "corrupt_key_refused",
        true,
        "named refusal, nonzero, no re-mint",
    );

    // Cleanup: the volumes are subuid-owned after `:U`.
    let _ = robot::scrub(engine, &work);
    let _ = run(Command::new(engine).args(["rmi", "-f", &base_tag, &upgraded_tag]));
}