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

nordisk/gunnar

crates/gunnar-recipe/tests/appliance_reachable.rs

//! **Every appliance variant gunnar DECLARES must be administrable once installed.**
//!
//! `reachability::check_reachable` is the rule; this is the rule applied to the
//! declaration actually on disk, loaded by `BuildVariantSet::load_file` — the
//! same loader dwarves' forge uses — so what is asserted here is what the forge
//! would build, not a fixture that resembles it.
//!
//! The defect it was written against was measured on installed metal, not
//! inferred: an appliance from `gunnar-installer` booted, took a DHCP lease,
//! answered `SSH-2.0-gunnar_1.1.3`, and logged *"Nobody can reach this server"*
//! on every start, because both appliance variants declared `--no-grpc` and
//! neither declared an admin key.
//!
//! # And then it passed a medium that admits nobody, onto a public release page
//!
//! The rule it enforced was `disables_control_plane(argv) &&
//! !declares_admin_key(argv)` — a test of the argv's TEXT. The ISO variant spelled
//! `--admin-key @/data/admin.pub` unconditionally while its `admin_key` parameter
//! was `required = false`, so the literal satisfied the gate and no file had to
//! exist. `gunnar-appliance-iso-thin-nowg.iso` (16 611 328 B, release `v1.1.3`)
//! was BOOTED on 2026-08-27 and printed `reading the public key from
//! /data/admin.pub: No such file or directory`.
//!
//! So this file now passes the cell's TYP and its declared PARAMETER ROSTER to
//! the rule, because that is what decides whether the file the argv reads is
//! guaranteed to exist. The population guard below moved with it: the interesting
//! property is no longer "both variants say `--no-grpc`" (they no longer do — the
//! ISO takes the TOFU door) but "each variant leaves a DIFFERENT door open", which
//! is what makes the two subjects two subjects.

use std::path::{Path, PathBuf};

use gunnar_recipe::reachability::{
    admin_key_argument, app_argv, binds_control_plane_offbox, check_reachable,
    disables_control_plane,
};
use nornir_build_thing::params::ParamSpec;
use nornir_build_thing::variant::{ArtifactKind, BuildVariantSet};

fn repo_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(Path::parent)
        .expect("crates/gunnar-build-thing has two ancestors")
        .to_path_buf()
}

fn from_toml() -> BuildVariantSet {
    let path = BuildVariantSet::conventional_path(&repo_root());
    BuildVariantSet::load_file(&path).unwrap_or_else(|e| panic!("load {}: {e:#}", path.display()))
}

/// `(name, typ, argv, declared params, boot params)` of one appliance variant.
type Appliance = (
    String,
    ArtifactKind,
    Vec<String>,
    Vec<ParamSpec>,
    Vec<String>,
);

/// Every appliance variant the file declares — the one place this test file
/// reads the declaration, so the population guard and the gate cannot be
/// looking at two different sets.
fn appliances() -> Vec<Appliance> {
    let set = from_toml();
    let mut out = Vec::new();
    for iso in &set.isos {
        // The RAW boot params, not `app_argv` — `GUNNAR_NET` is a kernel
        // cmdline `korp.env.` assignment, not one of the app's own arguments,
        // so it is gone by the time the argv exists.
        out.push((
            iso.name.clone(),
            ArtifactKind::Iso,
            app_argv(&iso.boot_params, &[]),
            iso.params.clone(),
            iso.boot_params.clone(),
        ));
    }
    // The FRONT medium (2026-09-25) is not a git appliance: its payload is
    // `gunnar-front`, which has no control plane to administer. Its own guard
    // is `the_front_medium_lays_the_front_and_no_git_server` below.
    for inst in set.installers.iter().filter(|i| {
        i.variant != gunnar_recipe::FRONT_PAYLOAD_VARIANT
            && i.variant != gunnar_recipe::FRONT_METERED_PAYLOAD_VARIANT
    }) {
        out.push((
            inst.name.clone(),
            ArtifactKind::Installer,
            app_argv(&inst.boot_params, &inst.cmd),
            inst.params.clone(),
            inst.boot_params.clone(),
        ));
    }
    out
}

/// Measured on this file's own subject matter.
///
/// A gate over "every appliance variant" is vacuous if the loader hands back zero
/// appliance variants — it would pass a repo that declares no ISO and no installer
/// at all, which is precisely the shape of a check that can never fail. This pins
/// the population AND pins that the two subjects are genuinely different cases:
/// one takes the TOFU door (control plane up, bound off the loopback, no admin
/// key), the other takes the seeded-key door (`--no-grpc` plus a `required`
/// parameter). If both ever collapsed onto one shape, the gate below would be
/// testing one case twice and reporting it as two.
#[test]
fn the_population_this_gate_runs_over_is_not_empty_and_holds_both_doors() {
    let set = from_toml();
    assert_eq!(
        set.isos.len(),
        1,
        "gunnar declares one [[build.iso]] variant"
    );
    // **The population BY NAME, not by count.**
    //
    // A count is the right guard — a variant that silently disappeared is
    // exactly what this test exists to catch — and a bare count is the wrong
    // way to spell it. This assertion read `3` and failed `left: 4 / right: 3`
    // for two days after `c12aac70` (2026-09-02) added `gunnar-installer-aws`,
    // because that message tells the next reader a number changed and nothing
    // about WHICH variant arrived or whether it was supposed to. The names make
    // the diff say it, and adding a variant now fails with the new name in the
    // output.
    let mut declared: Vec<&str> = set.installers.iter().map(|i| i.name.as_str()).collect();
    declared.sort_unstable();
    let mut expected = vec![
        gunnar_recipe::SEALED_INSTALLER,
        gunnar_recipe::OPEN_INSTALLER,
        gunnar_recipe::GCE_INSTALLER,
        gunnar_recipe::AWS_INSTALLER,
        gunnar_recipe::AZURE_INSTALLER,
        gunnar_recipe::METERED_INSTALLER,
        gunnar_recipe::METERED_AWS_INSTALLER,
        gunnar_recipe::METERED_AZURE_INSTALLER,
        gunnar_recipe::FRONT_INSTALLER,
        gunnar_recipe::FRONT_METERED_INSTALLER,
    ];
    expected.sort_unstable();
    assert_eq!(
        declared, expected,
        "gunnar's [[build.installer]] roster moved. The eight are: the sealed installer, its \
         keyless twin (2026-08-28), the keyless twin's virtio-scsi twin for a GCE-shaped \
         machine (2026-08-30), the AWS Nitro twin (`c12aac70`, 2026-09-02), the \
         Azure Hyper-V twin (2026-09-05) and the METERED twin (2026-09-05) — the first row \
         to name `gunnar-server-metered`, which had existed since the entitlement work with no \
         medium able to carry it — and the metered twins of the AWS and Azure rows \
         (2026-09-05), the same binary on the other two clouds' machines. A ninth row, \
         `gunnar-twin`, stood here from 2026-09-15 to 2026-09-17 and was DELETED: a twin is \
         a ROLE of an appliance, conferred over the wire (Bootstrap.BecomeTwin), not a medium, and as a medium it was \
         the keyless lineage's clone — so the metered appliance's twin ran the UNMETERED \
         binary and a promotion would have dropped entitlement enforcement in silence. The \
         FRONT medium `gunnar-front-installer-open` (2026-09-25) is a row and not an \
         appliance: vm1's own medium, payload `gunnar-front` (owner: main and twin share \
         the gunnar medium, the front boots its own); its paid twin `gunnar-front-installer-metered` (2026-09-25) is the same medium with the `metered` payload. If \
         you added one, name it here; if one vanished, that is \
         the bug this catches"
    );

    let mut tofu = Vec::new();
    let mut seeded = Vec::new();
    for (name, _kind, argv, _params, _bp) in appliances() {
        if admin_key_argument(&argv).is_some() {
            assert!(
                disables_control_plane(&argv),
                "{name}: a seeded-key appliance also closes the bootstrap window"
            );
            seeded.push(name);
        } else {
            assert!(
                binds_control_plane_offbox(&argv),
                "{name}: a TOFU appliance must bind the control plane off the loopback — \
                 gunnar has no shell and no port forwarding, so 127.0.0.1:50051 is nobody's \
                 door. argv: {argv:?}"
            );
            tofu.push(name);
        }
    }
    assert_eq!(
        tofu,
        vec![
            "gunnar-appliance-iso".to_string(),
            gunnar_recipe::OPEN_INSTALLER.to_string(),
            gunnar_recipe::GCE_INSTALLER.to_string(),
            gunnar_recipe::AWS_INSTALLER.to_string(),
            gunnar_recipe::AZURE_INSTALLER.to_string(),
            gunnar_recipe::METERED_INSTALLER.to_string(),
            gunnar_recipe::METERED_AWS_INSTALLER.to_string(),
            gunnar_recipe::METERED_AZURE_INSTALLER.to_string(),
        ],
        "the LIVE medium (RAM state) takes TOFU, and so does the KEYLESS installer — the \
         release-page medium whose customer's key is unknown at build time (2026-08-28) — \
         and so does its virtio-scsi twin, which is the SAME door on a different machine \
         machine: virtio-scsi for GCE (2026-08-30) and Nitro for AWS (`c12aac70`, \
         2026-09-02). The METERED twin is here too, and belongs here: metering is a \
         property of the BINARY, not of the bootstrap door — the customer's ADMIN key is \
         still unknown at build time, and the monetize key it does carry is a public \
         VERIFYING key that authenticates nobody to the control plane. Every keyless \
         medium belongs in this list, so a new twin is added HERE in the same commit that \
         declares it"
    );
    assert_eq!(
        seeded,
        vec!["gunnar-installer".to_string()],
        "the PERSISTENT medium (customer's disk) is the one sealed to a named key"
    );
}

/// The gate, applied to the declaration on disk.
///
/// Red on the declaration that shipped as v1.1.3:
///
/// ```text
/// appliance variant 'gunnar-appliance-iso' reads its bootstrap admin from
/// `--admin-key @/data/admin.pub`, but NO declared parameter is `required = true`
/// AND lands there — so a build that supplies nothing is legal and the medium it
/// produces boots a server that dies on `reading the public key from
/// /data/admin.pub: No such file or directory`
/// ```
#[test]
fn every_declared_appliance_variant_can_be_administered() {
    for (name, kind, argv, params, boot_params) in appliances() {
        check_reachable(&name, kind, &argv, &params, &boot_params)
            .unwrap_or_else(|e| panic!("{e:#}"));
    }
}

/// **The green above must be capable of red on THIS file's own inputs**, not
/// merely on hand-built fixtures in the unit tests. Each mutation below is one
/// edit away from the declaration on disk, and each must be refused — otherwise
/// `every_declared_appliance_variant_can_be_administered` is a green nobody has
/// seen red.
#[test]
fn one_edit_to_the_real_declaration_is_enough_to_make_the_gate_fire() {
    let all = appliances();

    // 1. The ISO goes back to the published v1.1.3 argv, with the roster it
    //    shipped beside it. This is the exact medium on the release page.
    let (iso_name, iso_kind, _, _, iso_bp) = all
        .iter()
        .find(|(_, k, _, _, _)| *k == ArtifactKind::Iso)
        .expect("one iso");
    let published = app_argv(
        &[
            "korp.entry=gunnar",
            "--",
            "serve",
            "--no-grpc",
            "--admin-key",
            "@/data/admin.pub",
        ]
        .map(str::to_string),
        &[],
    );
    let shipped_roster = vec![ParamSpec {
        lands: nornir_build_thing::params::Landing::Data,
        name: "admin_key".into(),
        label: "Bootstrap-admin (OpenSSH-publik nyckel)".into(),
        kind: nornir_build_thing::params::ParamKind::Text,
        required: false,
        boot_param: String::new(),
        staged_file: "admin.pub".into(),
    }];
    let err = check_reachable(iso_name, *iso_kind, &published, &shipped_roster, iso_bp)
        .expect_err("the published v1.1.3 medium must not build");
    assert!(err.to_string().contains("/data/admin.pub"), "{err}");

    // 2. The ISO keeps TOFU but loses the explicit bind — the naive fix, which
    //    ships a control plane nothing off the box can reach.
    let (_, _, iso_argv, iso_params, iso_bp2) = all
        .iter()
        .find(|(_, k, _, _, _)| *k == ArtifactKind::Iso)
        .expect("one iso");
    let unbound: Vec<String> = {
        let mut v = iso_argv.clone();
        if let Some(i) = v.iter().position(|a| a == "--grpc-bind") {
            v.drain(i..=i + 1);
        }
        v
    };
    assert_ne!(
        &unbound, iso_argv,
        "the real declaration does carry --grpc-bind"
    );
    let err = check_reachable(iso_name, *iso_kind, &unbound, iso_params, iso_bp2)
        .expect_err("a loopback control plane admits nobody");
    assert!(err.to_string().contains("LOOPBACK"), "{err}");

    // 3. The INSTALLER's admin_key drops to `required = false` — the one field
    //    whose flip is what let the ISO's defect through.
    let (inst_name, inst_kind, inst_argv, inst_params, inst_bp) = all
        .iter()
        .find(|(n, k, _, _, _)| *k == ArtifactKind::Installer && n == "gunnar-installer")
        .expect("the sealed installer");
    let relaxed: Vec<ParamSpec> = inst_params
        .iter()
        .map(|p| {
            let mut p = p.clone();
            if p.name == "admin_key" {
                p.required = false;
            }
            p
        })
        .collect();
    assert_ne!(
        &relaxed, inst_params,
        "the real installer does require admin_key"
    );
    let err = check_reachable(inst_name, *inst_kind, inst_argv, &relaxed, inst_bp)
        .expect_err("an optional admin key is no guarantee");
    assert!(err.to_string().contains("required = true"), "{err}");

    // 4. The INSTALLER's admin_key keeps `required = true` but its staged file
    //    moves one directory — the I7 defect, on the other medium.
    let misplaced: Vec<ParamSpec> = inst_params
        .iter()
        .map(|p| {
            let mut p = p.clone();
            if p.name == "admin_key" {
                p.staged_file = format!("etc/{}", p.staged_file);
            }
            p
        })
        .collect();
    let err = check_reachable(inst_name, *inst_kind, inst_argv, &misplaced, inst_bp)
        .expect_err("a required key landing somewhere else is not this argv's key");
    assert!(err.to_string().contains("/data/etc/admin.pub"), "{err}");

    // 5. The KEYLESS installer gets `--no-grpc` put back. It has no `admin_key`
    //    parameter at all (an optional one nothing reads would be the v1.1.3
    //    defect in a new place), so this is the 2026-08-25 brick: a persistent
    //    server that authorizes nobody, forever. Refused by name.
    let (open_name, open_kind, open_argv, open_params, open_bp) = all
        .iter()
        .find(|(n, _, _, _, _)| n == gunnar_recipe::OPEN_INSTALLER)
        .expect("the keyless installer");
    assert!(
        !open_params.iter().any(|p| p.name == "admin_key"),
        "the keyless installer declares no admin_key at all: {open_params:?}"
    );
    let sealed_again: Vec<String> = {
        let mut v = open_argv.clone();
        v.push("--no-grpc".to_string());
        v
    };
    let err = check_reachable(open_name, *open_kind, &sealed_again, open_params, open_bp)
        .expect_err("--no-grpc with no key behind it is the 2026-08-25 brick");
    assert!(err.to_string().contains("no control plane at all"), "{err}");

    // 6. The KEYLESS installer loses its explicit bind — the naive TOFU, on the
    //    persistent medium this time. Loopback on a box with no shell: nobody's.
    let unbound_open: Vec<String> = {
        let mut v = open_argv.clone();
        if let Some(i) = v.iter().position(|a| a == "--grpc-bind") {
            v.drain(i..=i + 1);
        }
        v
    };
    assert_ne!(
        &unbound_open, open_argv,
        "the real keyless declaration does carry --grpc-bind"
    );
    let err = check_reachable(open_name, *open_kind, &unbound_open, open_params, open_bp)
        .expect_err("a loopback control plane admits nobody");
    assert!(err.to_string().contains("LOOPBACK"), "{err}");
}

/// **The front medium lays the front and nothing of the git server** (owner
/// 2026-09-25: "twin/main gunnar share ISO, but NOT the ui/monetize"). Its
/// payload is `gunnar-front`, its entry execs that, it carries no control-plane
/// argv and no appliance gate, and it reaches the installer the way the gunnar
/// medium does: the same pairing seed, the same donor kernel, the same kernel
/// requirements (the gunnar medium's options' rows moved onto it).
#[test]
fn the_front_medium_lays_the_front_and_no_git_server() {
    let set = from_toml();
    let by = |n: &str| {
        set.installers
            .iter()
            .find(|i| i.name == n)
            .unwrap_or_else(|| panic!("{n} is not declared"))
    };
    let front = by(gunnar_recipe::FRONT_INSTALLER);
    let open = by(gunnar_recipe::OPEN_INSTALLER);
    assert_eq!(front.variant, gunnar_recipe::FRONT_PAYLOAD_VARIANT);
    assert_eq!(front.volume_id, gunnar_recipe::FRONT_VOLUME_ID);
    assert!(
        front
            .boot_params
            .iter()
            .any(|p| p == gunnar_recipe::FRONT_ENTRY),
        "{:?}",
        front.boot_params
    );
    assert!(
        !front
            .boot_params
            .iter()
            .any(|p| p.starts_with("korp.args=") || p.contains("GUNNAR_APPLIANCE")),
        "no git server argv, no appliance gate: {:?}",
        front.boot_params
    );
    assert!(
        front
            .params
            .iter()
            .any(|p| p.name == gunnar_recipe::PAIRING_SEED_PARAM
                && p.staged_file == gunnar_recipe::PAIRING_SEED_STAGED_FILE),
        "the front reaches the installer with the same seed"
    );
    assert_eq!(
        front.kernel_donor, open.kernel_donor,
        "the same donor kernel as the gunnar medium"
    );
    let symbols = |i: &nornir_build_thing::installer::InstallerBuild| {
        let mut s: Vec<String> = i
            .requires
            .iter()
            .map(|r| format!("{:?}", r.what))
            .filter_map(|w| {
                w.split("symbol: \"")
                    .nth(1)
                    .map(|t| t.split('"').next().unwrap_or("").to_string())
            })
            .collect();
        s.extend(
            i.options
                .iter()
                .flat_map(|o| o.kernel_requires.iter().map(|k| k.symbol.clone())),
        );
        s.sort();
        s.dedup();
        s
    };
    assert!(
        symbols(front).iter().any(|s| s == "CONFIG_TUN")
            && symbols(front).iter().any(|s| s == "CONFIG_KVM_GUEST"),
        "the closure was read: {:?}",
        symbols(front)
    );
    assert_eq!(
        symbols(front),
        symbols(open),
        "the front's kernel closure is the gunnar medium's"
    );
    // The paid front is the same medium with the metered payload: the CLI hole's reach is the
    // site's feed, never the medium's (RA1-14).
    let paid = by(gunnar_recipe::FRONT_METERED_INSTALLER);
    assert_eq!(paid.variant, gunnar_recipe::FRONT_METERED_PAYLOAD_VARIANT);
    assert_eq!(paid.volume_id, gunnar_recipe::FRONT_METERED_VOLUME_ID);
    assert_eq!(
        (&paid.boot_params, &paid.params, &paid.kernel_donor),
        (&front.boot_params, &front.params, &front.kernel_donor),
        "the paid front differs from the open one by its payload only"
    );
    // And the gunnar media never lay the front.
    for i in set.installers.iter().filter(|i| {
        i.name != gunnar_recipe::FRONT_INSTALLER && i.name != gunnar_recipe::FRONT_METERED_INSTALLER
    }) {
        assert_ne!(
            i.variant,
            gunnar_recipe::FRONT_PAYLOAD_VARIANT,
            "{} lays the front",
            i.name
        );
        assert!(
            !i.boot_params
                .iter()
                .any(|p| p == gunnar_recipe::FRONT_ENTRY),
            "{} execs the front",
            i.name
        );
    }
}