installtest/tests/exe_drive.rs
//! **The `exe` form, DRIVEN: gunnar-server run as a plain binary through
//! draupnir, made to answer a real `git push` and a real `git clone`.**
//!
//! The declared `gunnar-server` variant is `kind = "exe"`
//! (`.nornir/build-variants.toml:391`). Until this file existed, "exe" was
//! proven only as far as BUILT — `cargo xtask install-test bare` runs a gunnar,
//! but it runs it with its own `Command::new`, so the constellation's one
//! stage-2 seam (`draupnir::exe::ExeBoot`, added 2026-08-15) had no gunnar
//! caller and gunnar's `gunnar-appliance/exe` matrix column was `Absent`.
//!
//! # What is proven, in the order a car is test-driven
//!
//! 1. **BUILT** — the artifact the DECLARATION names, at the path the
//! declaration's own `target`/`profile`/`bin` put it. Nothing here spells a
//! cargo flag: `BuildVariant::cargo_args()` is the one writer.
//! 2. **RUNS** — `BootSpec::exe` + `ExeBoot::boot`, which returns while the
//! process is still RUNNING. This is the whole reason the seam exists: a
//! server exits when it fails, so `Command::output()` on `serve` is a hang,
//! measured at 927 s on the container rung before it was fixed.
//! 3. **The appliance's OWN identity** — the cell's ceremony gate
//! (`GUNNAR_APPLIANCE=1`, the same `[[build.installer]]` boot parameter the
//! installed disk carries) is armed, and the canonical
//! `wg-appliance identity: product=gunnar pubkey=…` line is read off the
//! process's real stdout through `ExeBoot::await_marker`. The key in that
//! line is then compared against `wg/identity.pub` on disk — the log and the
//! filesystem must agree, or the line is decoration.
//! 4. **SERVES** — a bound listener that speaks SSH (a banner read off a real
//! TCP connection, never a log line), then the shared
//! `Probe::push_then_clone`: three commits, a nested tree, a second branch,
//! pushed over stock `git`+`ssh`, cloned back, and the two OBJECT GRAPHS
//! compared oid for oid plus `git fsck --full --strict`.
//! 5. **RED** — the control. A second lifetime over a data root whose
//! `wg/identity.key` is garbage must REFUSE by name and die; the same
//! `await_marker` call that returned the identity line above must come back
//! `Seen::Exited`. A green that cannot go red proves nothing.
//!
//! # Reuse, not a twin
//!
//! The bootstrap verb list, the key, the SSH readiness probe, the push/clone
//! exchange and the object-graph comparison are all `gunnar_installtest`'s,
//! shared with the bare/container/kvm arms. What is new here is exactly one
//! thing: the process is fired up and observed through **draupnir**, and the
//! result lands as `gunnar-appliance/exe` matrix rows.
//!
//! ```text
//! CARGO_TARGET_DIR=~/scratch/cargo/gunnar-install \
//! cargo test --manifest-path installtest/Cargo.toml --test exe_drive -- --ignored --nocapture
//! ```
use std::path::{Path, PathBuf};
use std::time::Duration;
use draupnir::exe::ExeBoot;
use draupnir::{Boot, BootSpec, Lifecycle, Seen};
use gunnar_installtest::{bare, free_port, keygen, wait_for_ssh, workspace_root, Fault, Probe};
use nornir_build_thing::variant::BuildVariantSet;
/// The declared cell this drive runs. `kind = "exe"`, `target =
/// x86_64-unknown-linux-musl`, `profile = release`.
const CELL: &str = "gunnar-server";
/// The canonical identity announcement, `wg_appliance_core::ceremony`'s
/// `IDENTITY_LINE_REGEX` in prefix form. PRODUCT-qualified deliberately: a bare
/// `pubkey=` would match any product's line, and a drive that cannot tell whose
/// appliance answered has proven nothing about gunnar's.
///
/// MEASURED 2026-08-15: `wg.rs::ceremony_lane` emits this line TWICE — once on
/// stdout via `println!` and once through `tracing::info!` — and which copy
/// `await_marker` lands on varies run to run with the interleave of the two
/// captured pipes. That is harmless here and deliberately not "fixed" by
/// narrowing the marker: both copies carry the same key, and the assertion
/// that settles it is the readback of `wg/identity.pub` below. The line is the
/// pointer; the file is the evidence.
const IDENTITY_PREFIX: &str = "wg-appliance identity: product=gunnar pubkey=";
/// The refusal the corrupt-identity arm must die on, from
/// `edda/crates/wg-appliance-core/src/ceremony.rs`.
const REFUSAL: &str = "refusing to re-mint";
/// Scratch on DISK. `/tmp` is RAM on this box and a 23 MB static binary plus a
/// znippy store has no business there.
const SCRATCH: &str = "~/scratch/gunnar-exe-drive";
/// Where a nested `cargo build` of the declared variant writes. Overridable so
/// the drive can be pointed at whichever warm target dir the operator has;
/// never a fresh one, because disk is scarce.
fn build_target_dir() -> PathBuf {
std::env::var_os("GUNNAR_EXE_TARGET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| gunnar_installtest::expand_home("~/scratch/cargo/gunnar"))
}
/// One matrix row in the appliance rollup's convention:
/// `<product>-appliance/<form>` — form `exe`, per
/// `edda/crates/nornir-testmatrix/src/appliance.rs`.
fn matrix(check: &str, ok: bool, detail: &str) {
nornir_testmatrix::functional_status("gunnar-appliance/exe", check, ok, detail);
}
/// The artifact path the DECLARATION implies, and the argv that produces it.
/// One writer: `cargo_args()` is the variant's own, the directory is composed
/// from the variant's own `target` + `profile.label()` + `bin`.
fn declared_artifact(root: &Path) -> (PathBuf, Vec<String>, String) {
let set = BuildVariantSet::discover(root)
.expect("read .nornir/build-variants.toml")
.unwrap_or_else(|| panic!("gunnar declares a build space at {}", root.display()));
let v = set
.get(CELL)
.unwrap_or_else(|| panic!("gunnar declares a `{CELL}` variant"));
assert_eq!(
v.kind.as_str(),
"exe",
"this drive is the EXE form; `{CELL}` is declared as `{}`",
v.kind.as_str()
);
let bin = v
.bin
.clone()
.unwrap_or_else(|| panic!("`{CELL}` names a bin"));
let mut dir = build_target_dir();
if let Some(triple) = v.target.as_deref() {
dir = dir.join(triple);
}
dir = dir.join(v.profile.label());
(dir.join(&bin), v.cargo_args(), v.command_line())
}
/// A clean scratch subdirectory on disk.
fn fresh(tag: &str) -> PathBuf {
let d = gunnar_installtest::expand_home(SCRATCH).join(tag);
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap_or_else(|e| panic!("{}: {e}", d.display()));
d
}
#[test]
#[ignore = "heavy: builds (or reuses) the declared musl gunnar-server, runs it through draupnir and pushes+clones real git over ssh"]
fn the_declared_exe_cell_runs_announces_its_identity_and_serves_git() {
for tool in ["git", "ssh", "ssh-keygen"] {
if !std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default())
.any(|d| d.join(tool).is_file())
{
eprintln!(
"SKIP exe_drive: no {tool} on PATH — the interop peer is stock git over \
stock ssh, so gunnar-appliance/exe is NOT proven here"
);
matrix(
"tooling_present",
false,
&format!("no {tool} on PATH — skipped"),
);
return;
}
}
let root = workspace_root();
let (bin, argv, cmdline) = declared_artifact(&root);
// ── 1. BUILT ─────────────────────────────────────────────────────────
if !bin.is_file() {
eprintln!("building the declared cell: {cmdline}");
let out = std::process::Command::new(std::env::var("CARGO").unwrap_or("cargo".into()))
.args(&argv)
.current_dir(&root)
.env("CARGO_TARGET_DIR", build_target_dir())
.output()
.expect("spawn cargo");
assert!(
out.status.success(),
"`{cmdline}` failed ({}):\n{}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
}
let bytes = std::fs::metadata(&bin)
.unwrap_or_else(|e| {
panic!(
"the declared artifact is not at {} ({e}); `{cmdline}` with \
CARGO_TARGET_DIR={} is what puts it there",
bin.display(),
build_target_dir().display()
)
})
.len();
eprintln!("BUILT: {} — {bytes} bytes", bin.display());
matrix(
"built",
true,
&format!("{} ({bytes} bytes) via `{cmdline}`", bin.display()),
);
// ── 2. the fixture, through gunnar's OWN verbs ───────────────────────
let work = fresh("green");
let data = work.join("data");
let key = work.join("id_ed25519");
std::fs::create_dir_all(&data).unwrap();
keygen(&key).expect("client key");
bare::provision(&bin, &data, &key).expect("bootstrap the data root with gunnar's own verbs");
// ── 3. RUNS — through draupnir, which returns while it is still up ───
let port = free_port().expect("an ephemeral port");
let backend = ExeBoot::new();
let spec = BootSpec::exe(
"gunnar-exe-drive",
bin.to_string_lossy(),
[
"--data-root".to_string(),
data.display().to_string(),
"serve".to_string(),
"--ssh-bind".to_string(),
format!("127.0.0.1:{port}"),
"--no-grpc".to_string(),
],
)
// The ceremony gate, the SAME one `[[build.installer]]` puts on the
// installed disk's kernel command line. Passing it here is not the robot
// configuring the appliance: it is the drive putting the exe form in the
// shape the declaration ships.
.with_env("GUNNAR_APPLIANCE", "1");
let machine = backend
.boot(&spec)
.expect("draupnir fires up the declared binary");
// ── 4. the appliance announces its OWN key, on its own stdout ────────
let seen = backend
.await_marker(
&machine,
IDENTITY_PREFIX,
Duration::from_secs(60),
Duration::from_millis(200),
)
.expect("the machine draupnir just booted");
let line = match &seen {
Seen::Marker { line, after } => {
eprintln!("IDENTITY after {after:?}: {line}");
line.clone()
}
other => {
let _ = backend.power_off(&machine);
matrix("identity_minted", false, &other.detail());
panic!(
"the exe form never announced its identity: {}",
other.detail()
);
}
};
let pubkey = line
.split_once(IDENTITY_PREFIX)
.map(|(_, k)| k.trim().to_string())
.expect("the marker matched, so the prefix splits");
assert_eq!(
pubkey.len(),
44,
"an x25519 public key is 44 padded base64 chars: {pubkey:?}"
);
// The log and the filesystem must agree. A line printed by code that then
// wrote nothing is exactly the shape of proof this repo refuses.
let on_disk = std::fs::read_to_string(data.join("wg/identity.pub"))
.expect("the ceremony writes identity.pub next to the key");
assert_eq!(
on_disk.trim(),
pubkey,
"the announced key and {} disagree",
data.join("wg/identity.pub").display()
);
matrix(
"identity_minted",
true,
&format!("pubkey={pubkey} (announced == identity.pub)"),
);
// ── 5. SERVES — a bound listener that really speaks SSH ──────────────
let ready = wait_for_ssh(port, Duration::from_secs(60), || {
match backend.status(&machine) {
Ok(draupnir::PowerState::On) => Ok(()),
Ok(other) => Err(format!("the exe draupnir booted is {other:?}, not serving")),
Err(e) => Err(format!("draupnir status: {e}")),
}
});
let ready = match ready {
Ok(d) => d,
Err(e) => {
let tail = backend.output(&machine).unwrap_or_default();
let _ = backend.power_off(&machine);
matrix("ssh_listening", false, &e);
panic!("{e}\noutput:\n{tail}");
}
};
eprintln!("LISTENING on 127.0.0.1:{port} after {ready:?}");
matrix(
"ssh_listening",
true,
&format!("SSH banner on 127.0.0.1:{port} after {ready:?}"),
);
// ── 6. the deliverable: push, clone, object graphs compared ──────────
let probe = Probe {
port,
key: key.clone(),
work: work.join("client"),
user: "git".into(),
};
std::fs::create_dir_all(&probe.work).unwrap();
let exchange = probe.push_then_clone("demo/hello", Fault::None);
let exchange = match exchange {
Ok(x) => x,
Err(e) => {
let tail = backend.output(&machine).unwrap_or_default();
let _ = backend.power_off(&machine);
matrix("push_clone_graph", false, &e);
panic!("{e}\nserver output:\n{tail}");
}
};
eprintln!(
"SERVED: push + clone agree on {} objects, head {}, push {} ms, clone {} ms, host key {}",
exchange.objects, exchange.head, exchange.push_ms, exchange.clone_ms, exchange.host_key
);
matrix(
"push_clone_graph",
true,
&format!(
"{} objects, head={}, one server ({}), fsck --full --strict clean",
exchange.objects, exchange.head, exchange.host_key
),
);
// The lifecycle is draupnir's too — and it must actually let go.
backend.power_off(&machine).expect("power_off the exe");
assert!(
std::net::TcpStream::connect_timeout(
&([127, 0, 0, 1], port).into(),
Duration::from_millis(500),
)
.is_err(),
"127.0.0.1:{port} still answers after power_off — the process was not reaped"
);
matrix(
"stopped",
true,
&format!("port {port} refuses after power_off"),
);
// ── 7. RED: the same guard, sabotaged ────────────────────────────────
//
// The identity file is corrupted and the SAME `await_marker(IDENTITY_PREFIX)`
// call is made. It must NOT come back `Marker`: the ceremony refuses rather
// than re-mints, so the process dies naming the file.
let bad = work.join("data-corrupt");
copy_tree(&data, &bad);
std::fs::write(
bad.join("wg/identity.key"),
"this is not a base64 x25519 key\n",
)
.unwrap();
let red_port = free_port().expect("an ephemeral port");
let red_spec = BootSpec::exe(
"gunnar-exe-drive-red",
bin.to_string_lossy(),
[
"--data-root".to_string(),
bad.display().to_string(),
"serve".to_string(),
"--ssh-bind".to_string(),
format!("127.0.0.1:{red_port}"),
"--no-grpc".to_string(),
],
)
.with_env("GUNNAR_APPLIANCE", "1");
let red = backend
.boot(&red_spec)
.expect("draupnir fires up the corrupt-root binary");
let red_seen = backend
.await_marker(
&red,
IDENTITY_PREFIX,
Duration::from_secs(30),
Duration::from_millis(200),
)
.expect("the machine draupnir just booted");
let _ = backend.power_off(&red);
match &red_seen {
Seen::Exited { code, tail, .. } => {
assert!(
tail.contains(REFUSAL),
"the refusal must name the rule ({REFUSAL:?}):\n{tail}"
);
assert!(
tail.contains("identity.key"),
"the refusal must name the FILE the operator goes to look at:\n{tail}"
);
assert!(
!tail.contains(IDENTITY_PREFIX),
"a corrupt identity must never silently re-mint:\n{tail}"
);
assert_ne!(*code, Some(0), "a refusal exits non-zero");
eprintln!("RED (as demanded): exit {code:?}, refusal names identity.key");
matrix(
"corrupt_key_refused",
true,
&format!("exit {code:?}, named refusal, no {IDENTITY_PREFIX}"),
);
}
other => {
matrix("corrupt_key_refused", false, &other.detail());
panic!(
"a corrupt {} did NOT refuse: {}",
bad.join("wg/identity.key").display(),
other.detail()
);
}
}
}
/// Copy a directory tree. `std::fs` only — the red arm needs the GREEN arm's
/// minted key to corrupt, and copying it is how the two arms stay independent
/// without re-running the ceremony.
fn copy_tree(src: &Path, dst: &Path) {
std::fs::create_dir_all(dst).unwrap();
for e in std::fs::read_dir(src).unwrap().flatten() {
let from = e.path();
let to = dst.join(e.file_name());
if from.is_dir() {
copy_tree(&from, &to);
} else {
std::fs::copy(&from, &to).unwrap();
}
}
}