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