crates/gunnar-recipe/src/lib.rs
//! # gunnar-recipe — gunnar's recipe, as code
//!
//! The 2026-08-06 design (`workspace_nordisk/PLAN.md`, *"BUILD THING — MERGAD
//! DESIGN"*) makes a build thing a **published crate**. Not a wasm module riding
//! inside every artifact it built, and not an entry in a register somebody has to
//! search. dwarves either reads a finished artifact's receipt — which names this
//! crate by [`Coordinate`] — or, before any artifact exists, is pointed at the
//! coordinate by hand. There is no third way and no discovery step.
//!
//! ## What this crate answers
//!
//! ```text
//! ATOMS the five rows: gunnar-server, gunnar, gunnar-cli,
//! gunnar-ui (and gunnar-front)
//! COLUMNS exe, container, iso-installer, iso-live, rpm
//! Atom::assembles which (row, column) pairs are things that can exist
//! VARIANTS which of those anybody has actually declared
//! Variant::pane which panel the scene opens for the finished artifact
//! emit::write_receipt the RECEIPT a real build writes into the artifact
//! ```
//!
//! ## The receipt is written by this crate, from a build script
//!
//! [`emit::write_receipt`] is what `gunnar-cli/build.rs` calls. It resolves WHICH
//! variant is being built out of the features cargo resolved — the five cargo variants
//! have five distinct feature sets, which is what makes them five programs out of
//! one `main.rs` — and hands the answer to `nornir_build_thing::receipt`, which
//! puts it in the binary's `.nordisk.buildthing` section. Nothing declares the
//! variant a second time, because a second declaration is what the receipt exists to
//! stop trusting.
//!
//! ## The two questions this replaces a register with
//!
//! ```text
//! "how was THIS built?" you hold the artifact -> read its receipt
//! "what can I build HERE?" you stand in the tree -> this crate, in Cargo.toml
//! ```
//!
//! Neither needs a list of every recipe in the world, which is why there is no
//! keyword, no search and no convention file to find.
//!
//! ## Priority
//!
//! `gunnar-server` first — it is the service, and every other atom here is a
//! client of it. Then `gunnar`, the git binary that speaks the 2006 git protocol
//! against it. Columns: `exe`, `container`, `iso-installer`. `rpm` is
//! [last in the universe](atom::Column::Rpm) and nobody builds it.
//!
//! ## THE CODE IS THE SOURCE, and the toml is EMITTED from it
//!
//! `.nornir/build-variants.toml` moved in here and became [`VARIANTS`] plus
//! [`GunnarBuildThing`] — and for three weeks after that it stayed on disk as a
//! **hand-maintained mirror**, with `tests/toml_parity.rs` comparing the two. It
//! is not a mirror any more (2026-08-30): the file is this crate's
//! OUTPUT, written by `cargo run -p gunnar-build-thing -- emit`
//! ([`emit_recipe`]), and read back as authority by nothing.
//!
//! The mirror had to go because it was not merely redundant — it had already
//! lost declarations twice, and both losses were invisible:
//!
//! * An audit found `[build.installer.target]` and `[build.installer.data]` in
//! the toml and NOT in [`recipe`], with parity GREEN, because the pinned
//! loader dropped them before the comparison ran;
//! * A later audit measured the whole file refused by the published 0.1.10 reader over
//! one additive key, and had to write its `[nornir-build-thing] schema` header
//! by hand — which is what a file with no writer looks like.
//!
//! Both are impossible now. There is one declaration; the file is its
//! projection; the emitter refuses bytes that do not read back EQUAL through the
//! one loader (which carries R1/R2/R3, `nornir_build_thing::schema`), so a group
//! the reader would drop can no longer be emitted OR read. `toml_parity` is a
//! byte-for-byte assertion and fails for any reason the two differ.
//!
//! The reasoning that used to sit between the file's rows is kept verbatim in
//! the reasoning archive; new reasoning goes in the doc comments
//! here, never in the emitted file.
//!
//! `nornir-build-thing 0.1.5` reached crates.io on 2026-08-06, so the sibling
//! `path` dependency that kept this crate detached from gunnar's workspace is
//! gone and it is a normal member again. See `Cargo.toml`.
#![doc(
html_logo_url = "https://gunnar.rs/download/gunnar.webp",
html_favicon_url = "https://gunnar.rs/download/favicon.svg"
)]
pub mod atom;
pub mod emit;
/// `code → toml`: the recipe's own emission of `.nornir/build-variants.toml`.
pub mod emit_recipe;
pub mod reachability;
pub mod recipe;
/// Every cargo row and its `cargo install` line (G-BOTH-2).
pub mod twins;
pub mod variants;
pub use atom::{Assembly, Atom, Availability, Column, Suite, ATOMS, COLUMNS, SUITES};
pub use emit::{write_receipt, Attribution, Emitted, RECEIPT_ENV};
pub use emit_recipe::{emitted_toml, write_emitted, EMITTED_HEADER};
pub use reachability::{
admin_key_argument, app_argv, binds_control_plane_offbox, bound_interface_index,
check_reachable, declared_interface_count, declares_admin_key, disables_control_plane,
staged_runtime_path,
};
pub use recipe::{
admit_limit_param, admit_window_param, iso_kernel_donor, kernel_donor_of, metered_env,
monetize_pubkey_param, monetize_vital_param, pairing_seed_param, GunnarBuildThing,
ADMIT_LIMIT_DEFAULT, ADMIT_LIMIT_ENV, ADMIT_LIMIT_PARAM, ADMIT_WINDOW_ENV, ADMIT_WINDOW_PARAM,
AWS_INSTALLER, AZURE_INSTALLER, AZURE_VOLUME_ID, BILLING_URL_ENV, CONTENT_HASH, FRONT_ENTRY,
FRONT_INSTALLER, FRONT_METERED_INSTALLER, FRONT_METERED_PAYLOAD_VARIANT,
FRONT_METERED_VOLUME_ID, FRONT_PAYLOAD_VARIANT, FRONT_VOLUME_ID, GCE_INSTALLER, GCE_VOLUME_ID,
KRATE, METERED_AWS_INSTALLER, METERED_AWS_VOLUME_ID, METERED_AZURE_INSTALLER,
METERED_AZURE_VOLUME_ID, METERED_INSTALLER, METERED_VARIANT, METERED_VOLUME_ID,
MONETIZE_PUBKEY, MONETIZE_PUBKEY_ENV, MONETIZE_PUBKEY_PARAM, MONETIZE_VITAL_DEFAULT,
MONETIZE_VITAL_ENV, MONETIZE_VITAL_PARAM, OPEN_INSTALLER, OPEN_KORP_ARGS, PAIRING_SEED_PARAM,
PAIRING_SEED_STAGED_FILE, SEALED_INSTALLER, SEALED_KORP_ARGS,
};
pub use variants::{by_build_point, by_name, by_resolved_features, variants_at, Variant, VARIANTS};
/// `xtask` is written against `nornir_build_thing`'s resolver
/// (`suite_cli::{parse, resolve}`) and its one cargo-driving loop
/// (`build_variant`), and reaching them through this crate is what keeps
/// `xtask/Cargo.toml` at ONE dependency — the repository's own declaration —
/// rather than two. It also makes it impossible for the xtask and the recipe to
/// resolve to different versions of the declaration types. Same line, same
/// reason, as `gunnar-ui-recipe`.
pub use nornir_build_thing;
// **One source, two link targets — and since 2026-09-04, two crates.**
//
// Natively this crate is the `rlib` gunnar's build scripts link. Built for
// `wasm32-unknown-unknown` the same [`GunnarBuildThing`] is the `cdylib` module
// the forge loads and CALLS (dwarves `build_thing::code`, ⚒ and the CLI door
// alike), with no `cfg` fork in the impl, so the pane and the prompt cannot
// disagree about what gunnar can be built into. Template: edda
// `crates/nornir-buildspec-holger`.
//
// What moved is only WHERE the ABI is exported from.
// `nornir_build_thing::export_build_thing!(GunnarBuildThing)` now lives in
// `crates/gunnar-build-thing`, a shim whose entire content is that line plus a
// `pub use` of this crate — because a `crate-type` containing `cdylib` gets no
// `--extra-filename` from cargo, and this crate is reached both as a
// `[build-dependencies]` and as a normal dependency, which under resolver 2 is
// two units writing one filename. MEASURED: `cargo build --workspace` red 6 of
// 8 from cold. See that crate's manifest, and `[lib]` in this one.
//
// The impl stays here. The shim re-exports it, so `GunnarBuildThing` is one
// type with one definition and the two crates cannot drift.
// The contract types, re-exported so a caller needs one dependency rather than
// two to ask this crate a question and act on the answer.
pub use nornir_build_thing::contract::{Coordinate, PaneKind, Receipt, RECEIPT_SECTION};
#[cfg(test)]
mod tests {
use super::*;
use nornir_build_thing::plugin::BuildThing;
use nornir_build_thing::variant::ArtifactKind;
/// Measured, not asserted, on `variants::EVERY_VARIANT_IS_LEGAL`.
///
/// The `const` assertion stops the build but cannot say which variant it
/// stopped for — a const panic takes a literal. This is the half that names
/// it, and it is watched failing on variants built here rather than on the real
/// table: a check that only ever sees a legal table cannot tell a working
/// ledger from one that returns `true` unconditionally.
#[test]
fn the_legality_check_reports_red_on_an_illegal_variant() {
let template = VARIANTS[0];
// A git client shipped as a bootable ISO. Refused by the ledger.
let illegal_assembly = Variant {
name: "gunnar-cli-as-a-bootable-iso",
atom: Atom::GunnarCli,
column: Column::IsoLive,
kind: ArtifactKind::Iso,
..template
};
assert!(
!illegal_assembly.atom.supports(illegal_assembly.column),
"Atom::assembles calls (gunnar-cli, iso-live) legal, so the const \
assertion over VARIANTS can never fail"
);
assert!(
matches!(
illegal_assembly.atom.assembles(illegal_assembly.column),
Assembly::Refused(why) if !why.is_empty()
),
"the refusal carries no argument, so a caller cannot report why"
);
// An `exe` variant producing an ISO. Not a narrowing, a mislabel.
let mislabelled = Variant {
name: "an-exe-that-is-an-iso",
column: Column::Exe,
kind: ArtifactKind::Iso,
..template
};
assert!(
!mislabelled.column.admits(mislabelled.kind),
"Column::admits lets an `exe` variant produce an ISO, so a mislabelled \
variant compiles"
);
// And the narrowing that IS allowed still is — otherwise the check above
// would pass by refusing everything, which is the other way to be blind.
assert!(
Column::Exe.admits(ArtifactKind::Service),
"Column::admits refuses the gunnar-service narrowing, so it is not \
discriminating, it is just saying no"
);
assert!(
variants::every_variant_is_legal(),
"the real table is illegal"
);
}
/// The atoms the design names, in the order it names them — plus the one it
/// did not.
///
/// It was five until 2026-09-20. `gunnar-clock` is the SIXTH, and it is not
/// a widening of the design: the recipe's own `[build.allow_unbuilt]` said
/// "no variant row fits it until a front-host atom exists", so the atom is
/// the thing this table had already written down that it was missing. It
/// goes LAST, because nothing in this repository is a client of it.
///
/// `gunnar-front` is the SEVENTH (2026-09-25): vm1 boots its own medium,
/// owner's ruling, and that medium's one program is an atom a machine can
/// be. After the clock, which it links.
#[test]
fn the_atom_list_is_the_five_the_design_names_in_priority_order() {
let names: Vec<&str> = ATOMS.iter().map(|a| a.as_str()).collect();
assert_eq!(
names,
// Four since GUN-PRODUCTS (2026-10-30): the client-only `gunnar` git binary
// and `gunnar-solo` are the one program `gunnar-cli` with features now.
vec!["gunnar-server", "gunnar-cli", "gunnar-ui", "gunnar-front"]
);
let prios: Vec<u8> = ATOMS.iter().map(|a| a.priority()).collect();
assert!(
prios.windows(2).all(|w| w[0] < w[1]),
"ATOMS is not in priority order: {prios:?}"
);
assert_eq!(
ATOMS[0].priority(),
0,
"gunnar-server is not first, and it is the service"
);
for a in ATOMS {
assert_eq!(
Atom::parse(a.as_str()),
Some(*a),
"{a:?} does not round trip"
);
}
assert_eq!(
Atom::parse("gunnar-serverr"),
None,
"a typo resolved to an atom"
);
}
/// `rpm` is last, and stays last when a sixth column is added.
///
/// Probed: giving `Rpm` the value `4` — the "next number" a reader would
/// naturally write — passes the first assertion and fails the second, which
/// is exactly the trap the second one exists for.
#[test]
fn rpm_has_the_lowest_priority_in_the_universe() {
let rpm = Column::Rpm.priority();
for c in COLUMNS.iter().filter(|c| **c != Column::Rpm) {
assert!(c.priority() < rpm, "{} outranks rpm", c.as_str());
}
assert_eq!(
rpm,
u8::MAX,
"rpm is merely last among today's columns; a sixth column would \
overtake it"
);
assert!(
!VARIANTS.iter().any(|c| c.column == Column::Rpm),
"somebody declared an rpm variant; the column exists and nobody builds it"
);
for c in COLUMNS {
assert_eq!(
Column::parse(c.as_str()),
Some(*c),
"{c:?} does not round trip"
);
}
}
/// Every column maps to the pane the design's switch names, and it maps
/// through the contract rather than through a copy of the contract.
#[test]
fn every_column_opens_the_pane_the_design_names() {
assert_eq!(Column::Exe.pane(), PaneKind::Exec);
assert_eq!(Column::Container.pane(), PaneKind::Log);
assert_eq!(Column::IsoInstaller.pane(), PaneKind::Console);
assert_eq!(Column::IsoLive.pane(), PaneKind::Console);
// Nothing to run: an rpm is installed.
assert_eq!(Column::Rpm.pane(), PaneKind::None);
// (The one row that narrowed its column's pane, `gunnar-service`, is retired
// since GUN-PRODUCTS: the systemd service is `gunnar-cli --features systemd`.)
let server = by_name("gunnar-server").expect("gunnar-server variant");
assert_eq!(server.pane(), PaneKind::Exec);
}
/// The declared variants sit only on legal pairs, and no refused pair is
/// declared.
#[test]
fn the_matrix_agrees_with_itself() {
for c in VARIANTS {
assert!(
c.atom.supports(c.column),
"variant {} sits on a refused pair ({}, {})",
c.name,
c.atom.as_str(),
c.column.as_str()
);
}
let mut refused = 0usize;
for a in ATOMS {
for col in COLUMNS {
if !a.supports(*col) {
refused += 1;
assert!(
variants_at(*a, *col).is_empty(),
"({}, {}) is refused and declared",
a.as_str(),
col.as_str()
);
}
}
}
assert!(
refused > 0,
"the ledger refuses nothing, so the loop above asserted over an \
empty set and could never report red"
);
}
/// An atom with no variants says so out loud.
///
/// Probed: making `availability` return `Buildable` for everything fails the
/// first arm; making it return `Planned` for everything fails the second.
#[test]
fn an_atom_with_no_build_point_is_declared_planned_not_left_blank() {
for a in ATOMS {
let declared = VARIANTS.iter().any(|c| c.atom == *a);
match a.availability() {
Availability::Buildable => assert!(
declared,
"{} claims to be buildable and has no variant",
a.as_str()
),
Availability::Planned(why) => {
assert!(
!declared,
"{} is called planned and has a variant",
a.as_str()
);
assert!(
!why.is_empty(),
"{} is planned for no stated reason",
a.as_str()
);
}
}
}
// gunnar-ui grew its build point (the console's product row, GUN-PRODUCTS).
assert!(
matches!(Atom::GunnarUi.availability(), Availability::Buildable),
"gunnar-ui has its row; it is not planned any more"
);
}
/// The coordinate this crate mints parses back to itself, hash and all.
///
/// The three-part form is the one that matters: `krate:version` says which
/// recipe was intended, and only `krate:version:hash` says which bytes ran.
/// `receipt::audit_receipt` refuses a receipt without the third part, so a
/// coordinate that dropped it would take the whole receipt with it.
#[test]
fn the_recipe_coordinate_round_trips_through_the_parser() {
let c = GunnarBuildThing::coordinate();
assert_eq!(c.krate, KRATE);
assert_eq!(c.version, env!("CARGO_PKG_VERSION"));
assert_eq!(
c.hash.as_deref(),
Some(CONTENT_HASH),
"the recipe does not pin its own bytes, and every receipt naming it is refused"
);
let text = c.to_string();
assert_eq!(
text.split(':').count(),
3,
"the coordinate lost a part on the wire: {text}"
);
let parsed = Coordinate::parse(&text).expect("its own coordinate must parse");
assert_eq!(parsed, c);
}
/// The built set passes the generic validator it will be handed to.
///
/// This is what makes the migration a migration rather than a rewrite: the
/// same rules that judged the toml judge the Rust.
#[test]
fn the_assembled_set_passes_the_validator_the_toml_passed() {
let set = GunnarBuildThing.variants();
assert_eq!(
set.check_defaults(),
Vec::<String>::new(),
"a (suite, role) group has no normal case, or two"
);
// Watched failing: a second default in one group is what that check is
// for, and an empty-problem-list assertion that cannot fill up is blind.
let mut two_defaults = set.clone();
two_defaults
.variants
.iter_mut()
.find(|v| v.name == "gunnar-server")
.expect("the appliance variant")
.is_default = true;
assert!(
!two_defaults.check_defaults().is_empty(),
"check_defaults accepts two defaults in the server group, so the \
assertion above can never report red"
);
let mut validated = 0usize;
for c in VARIANTS {
let v = set
.get(c.name)
.unwrap_or_else(|| panic!("{} is missing from the assembled set", c.name));
if v.package.is_some() {
set.validate(v)
.unwrap_or_else(|e| panic!("{} does not validate: {e}", c.name));
validated += 1;
}
}
assert_eq!(
validated, 15,
"expected the fifteen cargo variants (GUN-PRODUCTS, 2026-10-30: the three products \
gunnar, gunnar-ui and gunnar-cli; the six appliance rows; the front's two \
gunnar-cli-musl rows; the four gunnar-front rows) to be validated, not \
{validated} — a loop that validates nothing passes"
);
// Watched failing: a feature outside `offers` is what `validate` is for.
let mut bad = set
.get("gunnar-server")
.expect("the server variant")
.clone();
bad.features.push("a-feature-nobody-offers".to_string());
assert!(
set.validate(&bad).is_err(),
"the validator accepts a feature the repo does not offer, so the \
five passes above mean nothing"
);
}
/// The receipt's settings come from the variant, so they cannot drift from it.
#[test]
fn a_variants_resolved_settings_name_the_variant_they_came_from() {
let server = by_name("gunnar-server").expect("gunnar-server variant");
let s = server.resolved_settings();
assert_eq!(s.get("atom").map(String::as_str), Some("gunnar-server"));
assert_eq!(s.get("column").map(String::as_str), Some("exe"));
assert_eq!(s.get("cell").map(String::as_str), Some("gunnar-server"));
assert_eq!(
s.get("target").map(String::as_str),
Some("x86_64-unknown-linux-musl"),
"the musl target is what makes this able to be PID 1"
);
assert_eq!(s.get("profile").map(String::as_str), Some("release"));
// The two variants that share (gunnar-server, exe) are told apart by their
// settings, which is the whole reason the pair is not a key.
let metered = by_name("gunnar-server-metered").expect("gunnar-server-metered variant");
assert_ne!(
metered.resolved_settings(),
s,
"two build points on the same (atom, column) resolve identically, so \
a receipt cannot say which one ran"
);
// Three since 2026-08-27: `gunnar-server`, `gunnar-server-thin` and
// `gunnar-service` all sit on (gunnar-server, exe) and are told apart by
// their settings — which is the whole reason the pair is not a key. The
// thin row is checked against the full one right below, so the raised
// literal is not the only thing that moved. Five since 2026-09-04: the
// two metered rows sit on the same pair and differ by `metered`. Seven
// since 2026-09-30: the two `…-narrate` rows (DEV-NARRATE) differ from
// their base by `dev-narrate`. Nine since 2026-10-28 (OX-VARIANTS): the two
// crates.io rows `gunnar-open` and `gunnar-open-client` build package `gunnar`.
// Seven since GUN-PRODUCTS (2026-10-30): the product `gunnar` and the six
// appliance rows (`gunnar-service`, `gunnar-open` and `gunnar-open-client` retired).
assert_eq!(variants_at(Atom::GunnarServer, Column::Exe).len(), 7);
let thin = by_name("gunnar-server-thin").expect("gunnar-server-thin variant");
assert_ne!(
thin.resolved_settings(),
s,
"the tunnel-less appliance resolves identically to the full one, so a \
receipt cannot say which of the two ran"
);
// An unset target resolves to a value rather than being absent.
assert_eq!(
by_name("gunnar")
.expect("gunnar variant")
.resolved_settings()
.get("target")
.map(String::as_str),
Some("host")
);
}
}