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

nordisk/gunnar

installtest/tests/installer_options.rs

//! **The two capabilities a customer may say no to, held against gunnar's own
//! code.**
//!
//! `.nornir/build-variants.toml` declares two `[[build.installer.options]]`
//! rows — `dhcp` (feature `dhcp-client`) and `wireguard` (feature `wg-client`)
//! — and neither is visible anywhere else in this repository:
//! `crates/gunnar-build-thing` reads through the PUBLISHED
//! `nornir-build-thing 0.1.7`, which has never heard of the key and drops the
//! whole block benignly. That is what keeps `tests/toml_parity.rs` green, and
//! it is also what would let the block be quietly WRONG forever.
//!
//! So this file is where the block is READ, through the 0.1.8 path dep, and
//! held against three things it cannot be allowed to disagree with:
//!
//! 1. **the app variant** — `validate_against_variant` refuses an option that
//!    defaults ON whose feature the `gunnar-server` row does not enable, and
//!    refuses that row entirely unless it sets `no_default_features = true`.
//!    Without the third rule the crate's own `default = [...]` would pull the
//!    feature back in behind the toggle's back and every OFF build would still
//!    contain the code, silently;
//! 2. **the declared parameters** — with WireGuard off, `hub_conf` is refused
//!    BY NAME rather than staged onto a medium whose binary cannot read it;
//! 3. **gunnar's own parser** — the DHCP option BAKES a `korp.env.GUNNAR_NET`
//!    value, and whether that value is a spelling the appliance accepts is a
//!    fact about `gunnar_server::pid1::NetConfig::parse` and nothing else.
//!    Asserting a string literal here would be a second opinion about gunnar's
//!    grammar, which is exactly how a declaration and a binary drift apart.
//!
//! `gunnar-server` is linked here with `--no-default-features --features
//! server,engine-znippy` — i.e. **without** `dhcp-client`. That is deliberate
//! and it buys the second arm of the proof for free: in this build
//! `NetConfig::parse("eth0:dhcp")` must REFUSE, naming the missing feature,
//! while the static bake must parse. One test binary, both directions.
//!
//! Not `#[ignore]`d and needing no hardware: it is a declaration test.

use std::collections::BTreeMap;

use appliance_pid1::net::{NetConfig, NetPlan};
use nornir_build_thing::installer::InstallerBuild;
use nornir_build_thing::options::{self, OptionSpec};
use nornir_build_thing::params::{AppliedParam, ParamKind, ParamSpec};
use nornir_build_thing::variant::{BuildVariant, BuildVariantSet};

/// gunnar's repo root, from this crate's manifest directory.
fn repo_root() -> std::path::PathBuf {
    std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("installtest has a parent")
        .to_path_buf()
}

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

fn installer(set: &BuildVariantSet) -> &InstallerBuild {
    set.installers
        .iter()
        .find(|i| i.name == "gunnar-installer")
        .expect("the [[build.installer]] variant is declared")
}

fn app<'a>(set: &'a BuildVariantSet, inst: &InstallerBuild) -> &'a BuildVariant {
    set.get(&inst.variant).unwrap_or_else(|| {
        panic!(
            "the installer names variant '{}', which is not declared",
            inst.variant
        )
    })
}

fn opt<'a>(options: &'a [OptionSpec], name: &str) -> &'a OptionSpec {
    options
        .iter()
        .find(|o| o.name == name)
        .unwrap_or_else(|| panic!("option '{name}' is not declared"))
}

fn param<'a>(params: &'a [ParamSpec], name: &str) -> &'a ParamSpec {
    params
        .iter()
        .find(|p| p.name == name)
        .unwrap_or_else(|| panic!("parameter '{name}' is not declared"))
}

/// The value side of `korp.env.GUNNAR_NET=<value>` out of a rendered bake list.
fn baked_net(bakes: &[String]) -> String {
    let token = bakes
        .iter()
        .find(|b| b.starts_with("korp.env.GUNNAR_NET="))
        .unwrap_or_else(|| panic!("no korp.env.GUNNAR_NET token was baked: {bakes:?}"));
    token
        .split_once('=')
        .expect("a token with a '='")
        .1
        .to_string()
}

/// **The block is READ, not merely written.**
///
/// The 0.1.7 loader every other reader in this repository uses drops
/// `[[build.installer.options]]` silently, so a count-only assertion here would
/// pass just as well against a file that declared nothing. This asserts the
/// loaded VALUES — the two names, the two features, the two default states and
/// the forbidden families — because those are the facts the rest of the file
/// then reasons from.
#[test]
fn the_two_options_load_and_say_what_the_file_says() {
    let set = declaration();
    let inst = installer(&set);
    let options = &inst.options;

    let names: Vec<&str> = options.iter().map(|o| o.name.as_str()).collect();
    assert_eq!(
        names,
        ["dhcp", "wireguard"],
        "the declared options, in declaration order — `render_bakes` and the \
         pane's checkbox list are both in this order"
    );

    let dhcp = opt(options, "dhcp");
    assert_eq!(dhcp.feature, "dhcp-client");
    assert!(
        dhcp.default,
        "DHCP is an OPT-OUT: it ships unless someone unticks it"
    );
    assert_eq!(dhcp.params_when_off, ["static_ip", "gateway"]);
    assert_eq!(
        dhcp.forbid_symbols,
        ["appliance_pid1::dhcp"],
        "the family is spelled with its crate prefix: a bare \"dhcp\" also matches \
         pid1's own log strings"
    );

    let wg = opt(options, "wireguard");
    assert_eq!(wg.feature, "wg-client");
    assert!(wg.default, "the tunnel is an OPT-OUT too");
    assert_eq!(wg.params_when_on, ["hub_conf"]);
    assert_eq!(
        wg.forbid_symbols,
        ["wg_appliance_core", "boringtun"],
        "NOT x25519 — MEASURED 2026-08-22, 58 Lcurve25519_x25519_* symbols survive \
         in a binary with no WireGuard at all (s2n-bignum under aws-lc under \
         rustls), so that family could never go green"
    );

    // The sharp shapes live in `param_kinds`, because `ParamKind` is a closed
    // enum in the 0.1.7 loader `crates/gunnar-build-thing` reads through and a
    // sharp `kind =` on a ROW would make this whole file unloadable there.
    assert_eq!(
        dhcp.param_kinds.get("static_ip"),
        Some(&ParamKind::Ipv4Cidr)
    );
    assert_eq!(dhcp.param_kinds.get("gateway"), Some(&ParamKind::Ipv4));
    assert_eq!(
        param(&inst.params, "static_ip").kind,
        ParamKind::Text,
        "the ROW must stay at a spelling 0.1.7 knows"
    );
}

/// **The by-construction half**: every option is legal against the variant it
/// decides, and that variant cannot have its default features pull the code
/// back in.
#[test]
fn every_option_validates_against_the_appliance_row_it_decides() {
    let set = declaration();
    let inst = installer(&set);
    let app = app(&set, inst);

    assert!(
        app.no_default_features,
        "the app variant MUST set no_default_features, or the crate's own \
         `default = [...]` enables the feature behind the toggle's back and every \
         OFF build still contains the code"
    );
    options::validate_options(&inst.options, &inst.params)
        .unwrap_or_else(|e| panic!("the option roster does not validate: {e:#}"));
    for o in &inst.options {
        o.validate_against_variant(app)
            .unwrap_or_else(|e| panic!("option '{}': {e:#}", o.name));
    }

    // The assertion above is only worth anything if it can say no. A variant that
    // inherits its crate's defaults is exactly the shape that would make the
    // whole axis decorative, and it must be refused by name.
    let mut hollow = app.clone();
    hollow.no_default_features = false;
    let e = format!(
        "{:#}",
        opt(&inst.options, "dhcp")
            .validate_against_variant(&hollow)
            .expect_err("validate_against_variant accepted a variant that inherits its defaults")
    );
    assert!(e.contains("no_default_features"), "{e}");

    // ...and an option whose feature the row does not enable is refused too,
    // which is what would happen if someone dropped `dhcp-client` from the
    // `gunnar-server` row and left the checkbox behind.
    let mut unfeatured = app.clone();
    unfeatured.features.retain(|f| f != "dhcp-client");
    let e = format!(
        "{:#}",
        opt(&inst.options, "dhcp")
            .validate_against_variant(&unfeatured)
            .expect_err("an option defaulting ON was accepted on a row without its feature")
    );
    assert!(e.contains("dhcp-client"), "{e}");
}

/// **Everything ON — the row that already builds.**
#[test]
fn the_default_resolution_is_the_appliance_as_it_ships() {
    let set = declaration();
    let inst = installer(&set);
    let app = app(&set, inst);

    let resolved = options::resolve_options(&inst.options, &inst.params, &BTreeMap::new())
        .expect("the all-defaults resolution");

    assert!(
        resolved.features_off.is_empty(),
        "nobody unticked anything, so nothing may be subtracted: {:?}",
        resolved.features_off
    );
    assert_eq!(
        resolved.app_features(app),
        app.features,
        "the all-ON feature list must be the variant's own, byte for byte — if it \
         is not, the shipped appliance changed the day the options landed"
    );
    assert!(
        resolved.record.iter().all(|r| r.on && r.from_default),
        "every option should be ON from its own default: {:?}",
        resolved.record
    );

    // hub_conf is active; the static address is refused, because there is no
    // static address on an appliance that asks a DHCP server for one.
    assert!(resolved.active_params.contains("hub_conf"));
    for refused in ["static_ip", "gateway"] {
        assert!(
            resolved.refused_params.contains_key(refused),
            "'{refused}' must be refused while DHCP is ON: {:?}",
            resolved.refused_params
        );
    }

    let net = baked_net(
        &resolved
            .render_bakes(&inst.options, &[])
            .expect("the ON bake"),
    );
    // Both NICs: the ON bake is the cloud shape, `CLOUD_GUNNAR_NET` (eth1 is the
    // utility network every UpCloud server has).
    assert_eq!(net, "eth0:dhcp,eth1:dhcp");
}

/// **DHCP unticked: the baked GUNNAR_NET is a spelling gunnar's own parser
/// accepts, and it means what the operator typed.**
///
/// This is the assertion the whole file exists for. A `when_off` template of
/// `eth0:static:{static_ip}:{gateway}:{dns}` — the shape the framework's own
/// module docs use as an example — renders a token `NetPlan::parse` REFUSES,
/// and the failure would surface on a customer's floor as an appliance that
/// stops at boot. The declaration and the parser are checked against each
/// other here instead.
///
/// PROVED RED 2026-08-22 by putting `static` back into the template: the parse
/// below fails with `GUNNAR_NET "eth0:static:10.0.2.15/24:10.0.2.2": "static"
/// is not <addr>/<prefix>`.
#[test]
fn the_static_bake_renders_a_spelling_netconfig_accepts() {
    let set = declaration();
    let inst = installer(&set);
    let app = app(&set, inst);

    let selection = options::parse_cli_options(&["dhcp=off"]).expect("--opt dhcp=off");
    let resolved = options::resolve_options(&inst.options, &inst.params, &selection)
        .expect("the DHCP-off resolution");

    assert_eq!(resolved.features_off, ["dhcp-client"]);
    let features = resolved.app_features(app);
    assert!(!features.iter().any(|f| f == "dhcp-client"), "{features:?}");
    assert!(
        features.iter().any(|f| f == "wg-client"),
        "unticking DHCP must not touch the tunnel: {features:?}"
    );

    // The values an operator supplies for the two parameters this state makes
    // active. Their SHAPE comes from the option's `param_kinds`, sharpened onto
    // the row by `effective_specs` — one validator, no second opinion about
    // what an IPv4 address is.
    let specs = resolved.effective_specs(&inst.options, &inst.params);
    let ip = param(&specs, "static_ip");
    let gw = param(&specs, "gateway");
    assert_eq!(ip.kind, ParamKind::Ipv4Cidr);
    assert_eq!(gw.kind, ParamKind::Ipv4);
    ip.validate_value("10.0.2.15/24")
        .expect("a legal interface address");
    assert!(
        ip.validate_value("10.0.2.15").is_err(),
        "an address with no prefix is not an interface address"
    );
    assert!(
        gw.validate_value("10.0.2.2/24").is_err(),
        "a gateway is a bare address, not a CIDR"
    );

    let applied = vec![
        AppliedParam {
            name: "static_ip".into(),
            value: "10.0.2.15/24".into(),
            fixture: None,
            fixture_note: String::new(),
        },
        AppliedParam {
            name: "gateway".into(),
            value: "10.0.2.2".into(),
            fixture: None,
            fixture_note: String::new(),
        },
    ];
    let net = baked_net(
        &resolved
            .render_bakes(&inst.options, &applied)
            .expect("the OFF bake"),
    );

    // ── THE CROSS-CHECK ──────────────────────────────────────────────────────
    //
    // gunnar's own parser, linked into this test binary, on the exact token the
    // declaration would put on the installed disk's kernel command line.
    // `parse_list`, the reader `from_env` uses: GUNNAR_NET is a comma-separated
    // list, and single-spec `parse` on it is a second grammar. The OFF bake must be
    // a list this dhcp-less binary accepts WHOLE; an `eth1:dhcp` riding along (the
    // bake until 2026-09-15) is refused here by name.
    let parsed = NetConfig::parse_list(&net).unwrap_or_else(|e| {
        panic!("the appliance REFUSES its own baked GUNNAR_NET {net:?}: {e:#}")
    });
    assert_eq!(
        parsed,
        vec![NetConfig::Static(NetPlan {
            interface: "eth0".into(),
            address: "10.0.2.15".parse().unwrap(),
            prefix: 24,
            gateway: Some("10.0.2.2".parse().unwrap()),
        })],
        "the baked token {net:?} parses, but not into the address the operator was \
         asked for — a wrong address is worse than a refused one"
    );

    // The other direction, free from this crate's `--no-default-features
    // --features server,engine-znippy` link: in a build with no `dhcp-client`,
    // the ON bake is REFUSED by name. Declaration and feature agree in both
    // states, which is what makes the toggle real rather than decorative.
    let e = format!(
        "{:#}",
        NetConfig::parse("eth0:dhcp")
            .expect_err("a dhcp-less build accepted eth0:dhcp — it has no client to run")
    );
    // The words are appliance-pid1's (edda): it names ITS feature, `dhcp`, which gunnar-server's
    // `dhcp-client` turns on.
    assert!(
        e.contains("`dhcp`") || e.contains("dhcp-client"),
        "the refusal must name the missing feature: {e}"
    );
}

/// **WireGuard unticked: `hub_conf` is refused BY NAME, not quietly ignored.**
///
/// A staged `wg/hub.conf` on P3 of a medium whose binary contains no WireGuard
/// is a value nothing reads — the two-files-deep lie this whole declaration
/// keeps closing.
#[test]
fn unticking_the_tunnel_refuses_the_hub_declaration_by_name() {
    let set = declaration();
    let inst = installer(&set);
    let app = app(&set, inst);

    let selection = options::parse_cli_options(&["wireguard=off"]).expect("--opt wireguard=off");
    let resolved = options::resolve_options(&inst.options, &inst.params, &selection)
        .expect("the wireguard-off resolution");

    assert_eq!(resolved.features_off, ["wg-client"]);
    let features = resolved.app_features(app);
    assert!(!features.iter().any(|f| f == "wg-client"), "{features:?}");
    assert!(
        features.iter().any(|f| f == "dhcp-client"),
        "unticking the tunnel must not touch DHCP: {features:?}"
    );

    assert!(
        resolved.refused_params.contains_key("hub_conf"),
        "hub_conf must be refused: {:?}",
        resolved.refused_params
    );
    let supplied: BTreeMap<String, String> = [("hub_conf".to_string(), "[Peer]\n".to_string())]
        .into_iter()
        .collect();
    let e = format!(
        "{:#}",
        resolved
            .refuse_forbidden(&supplied)
            .expect_err("a hub declaration was accepted for a binary with no tunnel")
    );
    assert!(e.contains("hub_conf"), "{e}");

    // The declared FIXTURE goes with it — `--param hub_conf=@fixture` must not
    // make the forge mint a hub for an appliance that cannot use one.
    assert!(
        resolved.effective_fixtures(&inst.fixtures).is_empty(),
        "the wg-hub fixture survived the tunnel being turned off: {:?}",
        resolved.effective_fixtures(&inst.fixtures)
    );

    // DHCP is still on, so its ON bake still renders and still says dhcp.
    let net = baked_net(&resolved.render_bakes(&inst.options, &[]).expect("the bake"));
    // Both NICs: the ON bake is the cloud shape, `CLOUD_GUNNAR_NET` (eth1 is the
    // utility network every UpCloud server has).
    assert_eq!(net, "eth0:dhcp,eth1:dhcp");
}

/// **Both unticked**: the leanest appliance the declaration can describe, and
/// the feature delta a receipt would record.
#[test]
fn unticking_both_subtracts_both_features_and_says_so() {
    let set = declaration();
    let inst = installer(&set);
    let app = app(&set, inst);

    let selection = options::parse_cli_options(&["dhcp=off", "wireguard=off"]).expect("both off");
    let resolved = options::resolve_options(&inst.options, &inst.params, &selection)
        .expect("the both-off resolution");

    // The literal is the point: `app_features` is `gunnar-server`'s declared row
    // minus the OFF features, so spelling the remainder out is what makes "and
    // nothing else moved" an assertion about the RECIPE rather than about the
    // subtraction. It has rotted exactly once, and correctly: `804d96a8` ("The
    // appliance replicates") added `replication` to the row, and this line went
    // red because the diet genuinely grew a fourth feature. The recipe is EMITTED
    // from `crates/gunnar-build-thing`, so the toml was never the thing to edit —
    // this expectation was (2026-08-30). `grow-runtime` joined the row
    // 2026-09-15 (runtime data-set growth): the same, correct rot. `live-app`
    // joined it with the live update (`4f2ae64d`, owner 2026-10-12): the same
    // again — this line was red on master from then until 2026-10-16.
    // `clock-fetch` joined 2026-10-25 (AIRGAP: estate-clock's HTTPS sources left the default
    // build, and every appliance row names them). `mirror-http` and `sso-egress` joined with
    // A32-18 (`34aff017`, `fb951902`: gunnar-server's ureq, the mirror pull's https half and the
    // SSO egress, behind default-off features every appliance row names): the same, correct rot.
    assert_eq!(
        resolved.app_features(app),
        [
            "server",
            "engine-znippy",
            "mimalloc",
            "clock-fetch",
            "resolver-probe",
            "fleet-pull",
            "mirror-http",
            "sso-egress",
            "replication",
            "grow-runtime",
            "live-app"
        ],
        "the both-off build is the appliance's diet minus the two capabilities, \
         and nothing else moved"
    );
    assert_eq!(
        resolved.feature_delta(),
        "features: −dhcp-client −wg-client"
    );
}