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

nordisk/gunnar

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