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]));
}