crates/gunnar-recipe/src/recipe.rs
//! **The recipe itself** — [`GunnarBuildThing`], gunnar's implementation of
//! `nornir_build_thing::plugin::BuildThing`.
//!
//! Everything the toml's `[build]` header carried lives here: the feature space
//! gunnar `offers`, the `allow_unbuilt` and `allow_unbuilt_bins` excuses, the
//! toolchain image, the container and the ISO. The
//! `[[build.variant]]` list lives in [`crate::variants`].
use std::collections::{BTreeMap, BTreeSet};
use nornir_build_thing::cargo_container::CargoToolchain;
use nornir_build_thing::container::{ContainerBuild, ImageFormat, ImageLayer};
use nornir_build_thing::contract::Coordinate;
use nornir_build_thing::data::{Data, DataFs, DataIo};
use nornir_build_thing::installer::{InstallerBuild, MediumBoot, PayloadKind};
use nornir_build_thing::iso::{IsoBuild, IsoFormat};
use nornir_build_thing::kernel_requires::{clock_requires, hotplug_requires, KconfigReq};
use nornir_build_thing::plugin::{BuildThing, Manifest, ABI_VERSION};
use nornir_build_thing::target::{Bus, Console, Firmware, Nic, Target};
use nornir_build_thing::variant::{AllowEntry, BuildVariantSet};
use crate::variants::VARIANTS;
/// The published crate name. A coordinate's first field, and the string dwarves
/// types when there is no artifact to read a receipt out of.
pub const KRATE: &str = "gunnar-build-thing";
/// **This recipe's content hash** — sha256 over its own `src/**/*.rs`, computed
/// while it compiled. See `build.rs` for what is hashed and why `Cargo.toml` is
/// not.
///
/// `krate@version` says which recipe was *intended*. This says which bytes ran.
/// A version can be rebuilt without moving; the hash cannot.
pub const CONTENT_HASH: &str = env!("GUNNAR_BUILD_THING_CONTENT_HASH");
/// gunnar's build thing.
///
/// Zero-sized: it holds no state because a recipe has none. Everything it
/// answers is `const` data in this crate, which is what makes it safe to call
/// from a UI thread and what makes two callers on two machines get the same
/// answer.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct GunnarBuildThing;
impl GunnarBuildThing {
/// **Where this recipe lives** — the coordinate dwarves points at, and the
/// one a receipt records.
///
/// `hash` is [`CONTENT_HASH`], populated, and that is the whole point:
/// `nornir_build_thing::receipt::audit_receipt` REFUSES a receipt whose
/// `build_thing.hash` is missing, because the version alone says which recipe
/// was intended rather than which one ran.
///
/// This used to return `None`, on the argument that "a coordinate minted by
/// the recipe itself cannot know the content hash of the bytes that will
/// eventually run". That argument confuses two hashes. The receipt records
/// the hash of the RECIPE, not of the artifact, and a recipe knows its own
/// bytes at its own compile time — `build.rs` reads them. The artifact's
/// hash is what the znippy attestation carries, and that one genuinely
/// cannot exist until after the build.
///
/// A coordinate with no hash is still a legal thing to hold: it is what
/// somebody TYPES before any artifact exists, and `Coordinate::parse`
/// accepts the two-part form for exactly that. It is not what a recipe
/// mints about itself.
#[must_use]
pub fn coordinate() -> Coordinate {
Coordinate {
krate: KRATE.to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
hash: Some(CONTENT_HASH.to_string()),
}
}
}
/// The feature space gunnar offers. Any variant feature outside this set is
/// rejected by `BuildVariantSet::validate`.
fn offers() -> BTreeSet<String> {
// `engine-znippy` joined 2026-08-10: the storage engine stopped being an
// undeclared crate (`crates/gunnar-znippy`) and became a feature of the
// already-declared `gunnar-server` package (`locked_on_target.md`, the
// `gunnar-znippy` collapse). A
// feature outside this set is rejected for a variant, so the appliance could
// not name its own engine until it was here.
//
// `engine-gix` is deliberately absent — not a shape, an A/B arm. See
// [`allow_unbuilt`].
//
// `mimalloc` joined 2026-08-11 for the same reason `engine-znippy` did: the
// appliance variant sets `no_default_features`, so a feature it must carry has
// to be nameable here or it silently does not reach the artifact. It is a
// shape and not an arm — every shipping gunnar that serves is built on
// `gunnar_server::ServerAllocator`, and a build without it is 2.2x the
// clone CPU, which is a different product rather than a different
// measurement.
//
// `dhcp-client` and `wg-client` joined 2026-08-22, for the third instance
// of the same reason: they are what `[[build.installer.options]]` decides,
// and an option's feature must be nameable on the appliance row or the
// toggle decides nothing. They are shapes, not arms — the two capabilities
// an appliance customer says yes or no to when the medium is mastered.
// `replication` joined 2026-08-30, and for the FOURTH instance of the same
// reason: the appliance row sets `no_default_features`, and
// replication is not in the crate's `default` list at all, so it can only
// reach the artifact by being named. It is a SHAPE and not an arm — an
// appliance that cannot keep a standby is a different product, which is
// exactly the argument the durability drill makes.
[
"server",
"client",
"control",
"systemd",
"engine-znippy",
"mimalloc",
"dhcp-client",
"wg-client",
"replication",
// `metered` joined 2026-09-04: the service provider's appliance
// — a licensed cargo feature. The same shape argument as `replication`
// — not in the crate's `default`, so it reaches an artifact only by
// being named, and only the two `gunnar-server-metered*` rows name it.
"metered",
// `grow-runtime` joined 2026-09-15: the runtime data-set growth (korp-installer
// 0.1.4 on the registry) — every server variant names it, so the appliance
// grows with the box up. Declared HERE, because the toml is emitted from
// this code and d17802c had edited only the toml.
"grow-runtime",
// `live-app` joined 2026-10-12: the app replaced live (korp-installer `live`, behind the
// installer's log door) — every server variant that pairs (`wg-client`) names it.
"live-app",
// `console` joined 2026-09-25: the front medium's payload (`gunnar-front`)
// names it — the web face (gunnar-ui-server + the built console) on the
// front's own :443. `gunnar-front-metered` names `metered` instead, which
// implies it.
"console",
// `bin` (2026-09-20, `gunnar-clock-musl`'s roll-up) LEFT 2026-10-21, lane
// L10: the gunnar-clock crate moved to edda estate-clock and its row is gone.
// `admit-share` joined 2026-09-28: the usage share on the admission
// gate (`edda_admit::UsageShare`, owner-approved design). Built by
// both metered server rows (and so by every metered installer chain);
// the open rows stay without it.
"admit-share",
// `dev-narrate` joined 2026-09-30 (DEV-NARRATE, owner + coordinator):
// the installer option `dev-narrate` (default OFF) decides it, so it is
// `open` on the four payloads of the estate's media and named by their
// four `…-narrate` rows — the build point an ON dev medium arrives at.
"dev-narrate",
// `public-site` joined 2026-10-25 (TASKS-12, the owner's SLIM RULE):
// gunnar.rs's public face (gunnar-ui's `gunnar-public-site`, mounted by
// the console), named by `gunnar-front-metered` and its narrate twin only.
"public-site",
// `forges` joined 2026-10-25 (AIRGAP, owner rule): forge import (GitHub, a
// GitLab) left the console's default build because it dials those forges. Every
// front row names it, and every front medium switches it on
// (`korp.env.GUNNAR_UI_FORGES=on`), so no live or sold front changes.
"forges",
// `clock-fetch` joined 2026-10-25 (AIRGAP): estate-clock's HTTPS Date sources left
// the default builds; every appliance row (server and front) names it, so a live
// box keeps the sources its medium names.
"clock-fetch",
// `resolver-probe` joined 2026-10-25 (AIRGAP B3): the boot's DNS question for
// github.com left the default builds; every appliance row names it.
"resolver-probe",
// `fleet-pull` joined 2026-10-25 (AIRGAP B5): the pull channel to the vendor's
// monetize, named by the six gunnar-server rows (estate and metered) only.
"fleet-pull",
// `mirror-http` and `sso-egress` joined 2026-10-26 (A32-18): the mirror pull's https
// half and the company-SSO egress (both ureq) left gunnar-server's default build;
// the six gunnar-server rows name both.
"mirror-http",
"sso-egress",
// `acme` joined 2026-10-25 (AIRGAP B1): the front's ACME client left the default
// build; every front row names it and every front medium switches it on.
"acme",
// `egress-door` joined 2026-10-25 (AIRGAP B2): the front's outbound CONNECT door for
// its boxes; every front row names it and every front medium switches it on.
"egress-door",
// `mail` joined 2026-10-25 (AIRGAP): the console's invite mail left its default
// build; every front row names it (the relay's own configuration is the switch).
"mail",
// `sso` joined 2026-10-27 (A34-15): company sign-in left the console's default build
// (gunnar-ui-server `sso`); every front row names it. gunnar-server's own `sso` (the
// protocols) comes with `sso-egress`, which every server row names.
"sso",
// `http` joined 2026-10-28 (OX-VARIANTS): smart HTTP, the `gunnar` package's default
// beside `engine-znippy`, named by the two crates.io rows `gunnar-open` and
// `gunnar-open-client` (`cargo install gunnar`, with and without `client`).
"http",
]
.iter()
.map(|s| (*s).to_string())
.collect()
}
/// Features that exist in the workspace and that **no variant names** — with the
/// reason each one is not a gap.
///
/// An excuse with no reason is not an excuse; `nornir_build_thing::buildgate`
/// refuses an empty one. These are copied word for word from the toml so the
/// migration loses no argument.
fn allow_unbuilt() -> BTreeMap<String, String> {
[
(
"fs",
"gunnar-lfs's on-disk object store, enabled transitively by the server half; a cell cannot name another crate's feature.",
),
(
"engine-gix",
"the second arm of the storage A/B, taken only by a dev/bench build that compiles both engines and selects at runtime; never a delivery shape, and never in the appliance.",
),
(
"conformance",
"a test-only harness feature (each crate dev-depends on itself for it); shipping it would be the defect.",
),
// ── The named extras and the service shape (lane GUN-PRODUCTS, 2026-10-30) ──
(
"online",
"a named extra of the products (gunnar, gunnar-cli): opt-in at `cargo install <product> --features online`, the outbound surfaces no delivered row ships; `cargo xtask ci`'s `extras` step compiles every product with `full`, which includes it.",
),
(
"federation",
"a named extra of the products (gunnar, gunnar-cli): the other forges, opt-in at `cargo install <product> --features federation`; compiled by `cargo xtask ci`'s `extras` step (`full`).",
),
(
"full",
"a named extra of the products: every open extra at once, opt-in at `cargo install <product> --features full`; compiled by `cargo xtask ci`'s `extras` step, delivered by no estate row.",
),
(
"systemd",
"gunnar-cli's systemd service shape (`cargo install gunnar-cli --features systemd`, the retired `gunnar-service` bin): compiled by `cargo xtask ci`'s `systemd-check` and `systemd-tests` steps and by installtest's subject build; no estate row ships it, the appliance has no init system.",
),
// ── gunnar-crypt's and gunnar-core's (the encrypted repositories, 2026-10-06) ──
// Library features of crates the delivered binaries reach through their OWN
// dependency lines (a cell cannot name another crate's feature):
(
"cipher",
"gunnar-crypt's envelope cipher (AES-SIV, HKDF, HMAC): enabled through `keygen`/`wrap` by gunnar-cli's `client` and gunnar-policy, and alone by the browser console in gunnar-ui; a cell cannot name another crate's feature.",
),
(
"keygen",
"gunnar-crypt's epoch-key minting: enabled through `wrap` by gunnar-cli's `client` and gunnar-policy's dev build; a cell cannot name another crate's feature.",
),
(
"user-key",
"gunnar-crypt's person's encryption key (CRYPT-YUBI): enabled through `wrap` by gunnar-cli's `client`, and by the browser console in gunnar-ui with `pq` (no SSH); a cell cannot name another crate's feature.",
),
(
"pq",
"gunnar-crypt's quantum-safe key and ring (CRYPT-PQ-DEFAULT: the default level): enabled through `wrap` by gunnar-cli's `client`, and alone beside `user-key` by the browser console in gunnar-ui (no SSH); a cell cannot name another crate's feature.",
),
(
"wrap",
"gunnar-crypt's age/HPKE key wrap: gunnar-crypt's own default, and enabled by gunnar-cli's `client` (`gunnar-crypt` with wrap + diff); a cell cannot name another crate's feature.",
),
(
"diff",
"gunnar-crypt's plaintext diff of an encrypted file: gunnar-crypt's own default and gunnar-cli's `client`; a cell cannot name another crate's feature.",
),
(
"pq-keys",
"gunnar-crypt's age1pq1 key decoding (bech32): enabled by gunnar-core's dependency line for its key store, and through `wrap`; a cell cannot name another crate's feature.",
),
// gunnar-grpc's company SSO (2026-10-22) needs no excuse since A34-15: `sso` is a
// feature the front rows name (gunnar-front's console sign-in), and gunnar-server's
// `sso` comes with `sso-egress`, which every server row names.
(
"test-hooks",
"gunnar-core's test-only hooks, enabled by gunnar-server's DEV dependency on gunnar-core; shipping it would be the defect.",
),
// ── The 2026-09-04 pass, and it is the FIRST time anything read this
// ── table.
//
// `tests/feature_axis.rs` (new that day) is gunnar's first caller of
// `nornir_build_thing::feature_axis::FeatureAxis::manifest_gate` — the
// shared verdict on "every feature this workspace declares is built by
// a variant or has a written reason". Before it, this list had no
// reader anywhere in the repository, and the gate had exactly one
// caller in the whole constellation (znippy-zoomies). Its first run
// found SIX rows wrong out of six-plus-four, which is what a table
// nobody reads looks like.
//
// `delta` and `simd` were DELETED rather than reworded: both named
// crates that no longer live here. `gunnar-pack` and `gunnar-delta`
// moved to the sibling `edda` checkout, so no `Cargo.toml` in this repo
// declares either feature, and the gate's "EXCUSE FOR A FEATURE THAT IS
// NOT THERE" arm is exactly for that. Their dependency-side spelling
// (`gunnar-pack = { features = ["delta"] }`) is a different thing and
// was never what this table meant.
//
// The four below were the gate's "OFFERED BUT NEVER BUILT" list. None
// is deletable — they gate 37, 5, 8 and 17 `cfg` sites respectively —
// so each got the reason nothing names it. Two of them SHIP and are
// invisible to the `built` column for one structural reason worth
// stating once: a cell names features of its atom's own package, so a
// feature reached as `<other-crate>/<feature>` from a feature that IS
// named can never appear there. That is the same shape as `fs` above.
(
"porcelain",
"gunnar-client's own default (gix + a real worktree), and it SHIPS — reached transitively as `gunnar-client/porcelain` from `gunnar-cli`'s `client` feature, which is itself offered and built. A cell names features of the atom's own package, so the transitive spelling is invisible to the `built` column.",
),
(
"webhooks",
"outbound event delivery, and it does NOT ship in 5.4 (UW-22): `gunnar-cli`'s `server` feature names `gunnar-server/webhooks`, but no variant builds `gunnar-cli` with `server`, and no appliance row (`gunnar-server*`) names it, so no shipped box delivers events. `Webhooks.*` is called only by `gunnar-cli control`, and the console has no screen for it. Shipping it is a product decision: add it to the appliance rows and give the console a screen.",
),
(
"stages",
"gunnar-wire's eighteen instrumentation stages (`dep:gunnar-stage`), enabled only by `bench/`'s own default — a DETACHED workspace, so no delivery variant can name it. Measurement scaffolding, never a shipped shape.",
),
(
"testmatrix",
"gunnar-server's emit channel for nornir-testmatrix rows (`functional_status`); on only under `cargo test`, and with it off the call is an `#[inline]` no-op with the crate absent from the graph entirely. Shipping a test reporter in the appliance would be the defect.",
),
// ── 2026-09-29: `gunnar-common/stderr-capture` (buyflow-observable) ──
(
"stderr-capture",
"gunnar-common's fd-2 pipe whose lines are said through tracing (monetize-server's eprintln! reaches the boot log), and it SHIPS — named as `gunnar-common/stderr-capture` by `gunnar-front`'s manifest, so every front row carries it. Transitive, like `porcelain`.",
),
// ── 2026-10-26: `gunnar-common/jobs` (A32-14) ─────────────────────────
(
"jobs",
"gunnar-common's named jera jobs (a box's duty and the job queue on jera ChainQueue lanes), and it SHIPS — named as `gunnar-common/jobs` by `gunnar-server`'s and `gunnar-front`'s manifests, so every server and front row carries it. Transitive, like `stderr-capture`.",
),
// `peer`, `fetch`, `adjust` (2026-09-14, gunnar-clock) LEFT 2026-10-21, lane L10: the
// crate is edda estate-clock now, and Gunnar's own manifests declare none of them.
// ── 2026-10-10: `gunnar-repair` (5.4 resilience) ─────────────────────
//
// The one repair crate's default build is pure (the wasm guest links it);
// its four features are the I/O layers, and every one SHIPS: `proto` through
// `gunnar-server`'s `replication` and `gunnar-cli`'s `control`, `fleet` (the shared
// pump, M12) through `gunnar-server`'s `fleet-pull`: transitive, like `porcelain`.
(
"fleet",
"gunnar-repair's estate side of monetize's pull channel: monetize-estate-side's SHARED pump with gunnar's executor as its PumpBox (A28 M12), and it SHIPS — named as `gunnar-repair/fleet` by `gunnar-server`'s `fleet-pull`, which every appliance row carries (the community build has `replication` without it and links no monetize crate). Transitive, like `porcelain`.",
),
(
"grpc",
"gunnar-repair's tonic helpers (poison codec, the Bootstrap.Restore call), and it SHIPS — reached through `gunnar-repair/proto` from `gunnar-server`'s `replication` and `gunnar-cli`'s `control`. Transitive, like `porcelain`.",
),
(
"proto",
"gunnar-repair's adapters over gunnar's own generated clients, and it SHIPS — named as `gunnar-repair/proto` by `gunnar-server` (`replication`) and `gunnar-cli` (`control`). Transitive, like `porcelain`.",
),
(
"pump",
"gunnar-repair's async pump and verb orchestration, and it SHIPS — reached through `gunnar-repair/grpc` from `gunnar-server`'s `replication` and `gunnar-cli`'s `control`. Transitive, like `porcelain`.",
),
(
"container-robot",
"gunnar-server's container robot TARGET (`tests/appliance_container_robot.rs`, A28 L14): a test tool, never a delivery shape. It runs as `--features container-robot -- --ignored`; the feature turns on edda's `robot` (`wg-appliance-core/robot`, published in 0.1.7), and no row builds it.",
),
(
"mcp",
"gunnar-front's MCP connector (TASKS-13, owner 2026-10-25): OFF by default and in every release row until the owner turns it on, so no delivery row may build it (`tests/mcp_off_by_default.rs` holds the default tree free of gunnar-mcp); its own test target `mcp_bridge` requires it.",
),
// ── `bin` WAS HERE, and it is GONE (2026-09-20) ──────────────────────
//
// Its text said: "the FRONT host's `gunnar-clock` binary … It ships to
// the front, which is not an atom of this product: no variant row fits
// it until a front-host atom exists. Built today as `cargo build -p
// gunnar-clock --features bin --target x86_64-unknown-linux-musl` — a
// host build, named here as the exception it is."
//
// `Atom::GunnarClock` and the `gunnar-clock-musl` variant exist now, so
// the excuse is spent: `bin` is BUILT, by a row, in the pinned
// container. It is deleted rather than reworded because
// `FeatureAxis::stale` refuses an excuse for a feature a variant builds
// — an allowlist that keeps entries after the debt is paid stops being
// a list of debts.
]
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect()
}
/// **Declared `[[bin]]` targets no variant builds** — `[build.allow_unbuilt_bins]`,
/// the bin-side twin of [`allow_unbuilt`], keyed by bin name and valued with the
/// reason. Four entries, copied byte for byte from the toml.
///
/// This became LOADER VOCABULARY in nornir-build-thing 0.1.9 (2026-08-26):
/// before that the table worked through a second door (`buildgate::read_allowlist`
/// re-read the raw file) and `BuildVariantSet` had no field it could land in, so
/// the parity assertion in `tests/toml_parity.rs` could not hold it. From 0.1.9
/// it is a field, so the mirror declares it and the assertion does.
fn allow_unbuilt_bins() -> BTreeMap<String, AllowEntry> {
[
(
"xtask",
"repository tooling, never shipped; built by every `cargo xtask` run, not by a delivery.",
),
(
"gunnar-build-thing",
"repository tooling, never shipped: the cell runner that materializes CELLS' declared argv (one implementation — owns no build knowledge); tool axis like xtask, not the delivery axis.",
),
(
"gunnar-lfs-serve",
"an ssh-exec interop fixture for tests/interop.rs; the shipped path is gunnar-cli's GitService::LfsTransfer arm, which every atom builds.",
),
(
"gunnar-installtest",
"the detached install-test harness (own [workspace], deps on draupnir + tunnr-vm); run by `cargo xtask install-test`, never shipped.",
),
// ── `gunnar-clock` WAS HERE, and it is GONE (2026-09-20) ─────────────
//
// Its text said: "the FRONT host's clock daemon (`gunnar-clock
// --features bin`), shipped to the front by hand — see the `bin` row of
// `allow_unbuilt` for why no variant builds it yet."
//
// `gunnar-clock-musl` builds it. `buildgate` refuses an
// `[build.allow_unbuilt_bins]` entry that excuses a bin which IS built,
// by name — so this deletion is not a courtesy, it is what makes the
// repository compile its own honesty check.
]
.iter()
.map(|(k, v)| ((*k).to_string(), AllowEntry::Reason((*v).to_string())))
.collect()
}
/// **The pinned build image every cargo variant compiles in** — `[build.toolchain]`,
/// mirrored value for value (the toml beside each key says how each was MEASURED
/// on the image that exists).
///
/// This became LOADER VOCABULARY in nornir-build-thing 0.1.9 (2026-08-26): the
/// registry 0.1.7/0.1.8 loaders had no `cargo_container` module and no
/// `toolchain` field, so the table parsed and was DROPPED silently — a host build
/// reported as a container build. From 0.1.9 it is `BuildVariantSet::toolchain`,
/// so the mirror declares it and `tests/toml_parity.rs` holds the two equal.
fn toolchain() -> Option<CargoToolchain> {
Some(CargoToolchain {
image: "nordisk-cargo-toolchain".to_string(),
// **Re-pinned 2026-09-05 (I8).** The previous digest,
// `f29e517c1ba34b920c…`, was written on 2026-08-30 and no
// image with it has ever existed on the build host: the only local build of
// `nordisk-cargo-toolchain:live` is from 2026-08-27 09:39 and has
// THIS digest, and there is no registry behind the name to pull the
// other from (`short-name did not resolve`, `403 denied`). So the pin
// named an image nobody could produce, and every ISO row — metered or
// not — was refused by it. The pin's whole point (`.nornir/
// build-variants-reasoning.md`: an image cannot be replaced under two
// builds that report the same provenance) is restored by naming the
// image that IS here: rustc 1.97.1 inside it, matching `rust_version`
// below. When the toolchain is rebuilt, this line moves WITH it, in the
// same commit, and never before.
//
// **Moved again 2026-09-05 ~10:30.** A `podman image prune` removed
// `07b4e3a8…` (unused-but-tagged — guarded now) and rebuilt the
// toolchain through the forge door: the kernel image came back
// byte-identical, the cargo one at THIS digest (`podman images
// --digests`, 34 minutes old at the time of the bump). Same rustc,
// same three targets. Every medium of 1.3.0 was composed on
// `07b4e3a8…`; anything composed after this line is on `0b8ba17c…`,
// and a receipt says which.
//
// **Moved for the last time by hand, 2026-09-05 13:3x — the image is
// REPRODUCIBLE now.** edda `ac3852e`: the cargo Containerfile makes the
// kernel file's four moves (base pinned by digest, a snapshot.debian.org
// instant, `ENV SOURCE_DATE_EPOCH=1783900800`, logs scrubbed in-layer)
// and the runner mints through `build_image_reproducible`. MEASURED by
// bevis: two fresh empty stores, `--no-cache --pull=always`, ONE digest
// both times, 11 418 files, 0 diffs, 0 mtime drift. So this line is no
// longer a minting event: every box that mints the recipe gets this
// MANIFEST digest (`72abf41e…` is the image ID — both 64 hex, different
// things; kernel.rs:917 ruled it on 08-23 and bevis re-read it off the
// minted image at 13:4x), `0b8ba17c…` names an image the recipe no longer produces, and
// the next move happens only when the RECIPE changes — in the same
// commit, per the rule above.
// **Moved 2026-09-15 with the RECIPE, per the rule above.** nornir-build-thing
// 0.1.17's cargo-toolchain.Containerfile adds `x86_64-unknown-uefi`, because
// korp-installer 0.1.6 builds korp-boot (EFI/BOOT/BOOTX64.EFI) and both musl
// PID-1s in THIS image instead of host cargo. Minted reproducibly on laptop as
// `nordisk-cargo-toolchain:live-uefi`; this is its manifest digest.
image_sha256: "86d575e874677da3eb9aff3ba6e38ca600781ee9b7948a9df43ef89a6213c762"
.to_string(),
rust_version: "1.97.1".to_string(),
// `wasm32-unknown-unknown` joined 2026-09-05. The recipe crate's
// own wasm build — the plugin the forge loads — was refused on every
// run with "the toolchain image does not carry std for it (it
// declares: gnu, musl)": a refusal by DECLARATION, while the pinned
// image (`07b4e3a8…`, above) carries all three — `rustup target list
// --installed` inside it: wasm32-unknown-unknown, x86_64-unknown-
// linux-gnu, x86_64-unknown-linux-musl. The declaration now says what
// the image has, which is the only thing it is for.
targets: vec![
"x86_64-unknown-linux-gnu".to_string(),
"x86_64-unknown-linux-musl".to_string(),
"wasm32-unknown-unknown".to_string(),
"x86_64-unknown-uefi".to_string(),
],
timeout_secs: 3600,
mem_limit_mb: 8192,
cpus: 8,
network: false,
// Not spelled in the toml; the loader's default, and the value the
// loaded set carries.
remap_paths: true,
// **The sibling checkouts the toolchain container must have mounted beside
// gunnar** for a build inside it to resolve.
//
// It was emptied on 2026-09-09 with the note that the fourteen crates which
// used to force it (eleven in `../edda`, two in `../znippy`, one in
// `../monetize`) had all become crates.io versions, so the container could
// mount nothing but gunnar — the same graph a `git clone` gets — and with a
// standing instruction: *"if a sibling path dependency is ever added back,
// this list must grow with it in the SAME commit."*
//
// It was, and the list did not. MEASURED 2026-09-10, `cargo xtask
// iso-installer --variant gunnar-installer-open`, which never reached cargo:
//
// [build.toolchain]: path dependencies escape this repo into sibling
// repo(s) the declaration does not list … Add `siblings = ["edda",
// "znippy-zoomies"]`. Escaping: edda (3 dep(s), e.g.
// deploy/upcloud/Cargo.toml → nornir-build-thing); znippy-zoomies
// (1 dep(s), e.g. bench-stock/Cargo.toml → znippy-zoomies)
//
// The four are `deploy/upcloud/Cargo.toml`'s `nornir-hash`,
// `nornir-build-thing` and `nornir-git`, and `bench-stock/Cargo.toml`'s
// `znippy-zoomies`. The declaration follows the manifests rather than the
// other way round: this is what the tree ACTUALLY reaches for, and a
// container that is not told fails inside itself, where the error says
// least.
//
// Shrink it back the day those four become registry versions again — and
// measure it, do not assume it: the same verb prints the escape list.
//
// `gunnar-ui` and `monetize` since 2026-09-25: the FRONT medium's payload
// (`crates/gunnar-front`) links gunnar-ui-server and monetize-server by
// path and embeds the built console (`gunnar-ui/crates/gunnar-ui-web/dist`,
// `dist-metered`); monetize is linked BARE (its plugins arrive in the feed) —
// the owner's ruling that the UI and monetize ride vm1's own medium.
//
// `Skidbladnir` since 2026-09-26: nordisk-pairing 0.5.2 moved there from
// edda (`crates/installer/nordisk-pairing`), and gunnar-front,
// gunnar-server and plugin-door-wire reach it by path. MEASURED on loki
// 2026-09-26: the metered compose was refused with "Add `siblings =
// ["Skidbladnir"]`. Escaping: Skidbladnir (4 dep(s))".
//
// `znippy` and `draupnir` since 2026-10-03, TEMPORARY: the release-3 merge
// onto the dependency bump patches storage-git-gix and nornir-build-thing
// to edda's gix-0.88 crates (root `[patch.crates-io]`), and those reach
// `../znippy/git-storage-trait` and `../draupnir` by path. They leave with
// that patch table, when edda publishes the crates.
siblings: vec![
"edda".to_string(),
"znippy-zoomies".to_string(),
"gunnar-ui".to_string(),
"monetize".to_string(),
"Skidbladnir".to_string(),
"znippy".to_string(),
"draupnir".to_string(),
// `tunnr` since 2026-10-12 (live update): gunnar-server links korp-installer's
// `live` by path (`../tunnr/crates/korp-installer`, version kept). MEASURED on
// oden: "Add `siblings = ["tunnr"]`. Escaping: tunnr (1 dep(s))".
"tunnr".to_string(),
],
})
}
/// The appliance image the `container` column builds: gunnar on `scratch`, with
/// no shell and no package manager in it.
fn containers() -> Vec<ContainerBuild> {
vec![ContainerBuild {
name: "gunnar-scratch".to_string(),
base: "scratch".to_string(),
// `scratch` is no base of ours: no recipe builds it (edda main, 2026-10-16).
base_recipe: None,
layers: vec![ImageLayer {
// The image is assembled from the `gunnar-server` variant's artifact
// rather than from a path, which is what keeps the image and the exe
// the same binary instead of two things that agree by convention.
from_variant: Some("gunnar-server".to_string()),
dest: "/gunnar".to_string(),
executable: true,
..ImageLayer::default()
}],
entrypoint: vec!["/gunnar".to_string()],
cmd: vec!["serve".to_string()],
workdir: Some("/data".to_string()),
// `GUNNAR_APPLIANCE = "1"` — the ceremony gate, declared here rather
// than typed by an operator, the same ruling holger's
// `holger-mvp-appliance` variant makes. The toml carries the long form.
// Without it the `se.nordisk.role = gunnar-appliance` label below was
// aspirational: the image minted no identity and printed no canonical
// line.
env: [("GUNNAR_DATA_ROOT", "/data"), ("GUNNAR_APPLIANCE", "1")]
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect(),
expose: vec![2222, 50051],
labels: [("se.nordisk.role", "gunnar-appliance")]
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect(),
tags: vec!["gunnar-scratch:1.1.3".to_string()],
format: ImageFormat::Oci,
// The distributable docker-archive export. NAMEABLE since the 0.1.7 pin
// bump (2026-08-17): the 0.1.5 loader had no such field and dropped the
// toml's key benignly, and the old text here recorded the decision while
// waiting for the bump. The parity guard now compares it for real.
archive: ".container-out/gunnar-image.tar".to_string(),
// Image-build mode, not file mode: this variant is layered from a variant's
// artifact, so it declares no Containerfile and no context directory.
containerfile: String::new(),
context: String::new(),
// 0.1.24's build-time digest pin (`#[serde(skip)]`: never declared,
// never emitted). Unpinned: no consumer pins the appliance image.
expected_sha256: String::new(),
// 0.1.10's generalised consumption edges. The appliance image consumes
// no other build thing: it packs `gunnar-server`, which is a cargo
// variant of THIS repo and therefore a `from_variant`, not an edge.
// Named on purpose — see the `..ContainerBuild::default()` note below.
edges: BTreeMap::new(),
// The toolchain surface's closure (0.1.11, 2026-08-30). DERIVED,
// never hand-declared: it is what a consumer's steps require of an
// IMAGE, landed by the planner before the build. This image is a
// delivery artifact, not a build environment — nothing consumes it — so
// it is `None`, and naming it is this mirror keeping its rule that every
// field added upstream gets a decision here rather than defaulting in.
requires: None,
// No `..ContainerBuild::default()`: every field is named. That is worth
// keeping — a field added upstream then fails to compile here and gets a
// decision, rather than defaulting silently into gunnar's appliance
// image.
}]
}
/// **The kernel the LIVE medium boots** — the mirror of
/// `[build.iso.kernel_donor]` in `.nornir/build-variants.toml`: `kernel ↔
/// tunnr/tunnr-kernel : kernel`, the SAME donor the installer declares.
///
/// A free function and not a field of [`isos`]' `IsoBuild`, for the reason the
/// installer's donor was a raw-file guard under the 0.1.7 pin: this crate pinned
/// `nornir-build-thing` 0.1.9 from the registry, whose `IsoBuild` had no
/// `kernel_donor` (the iso edge is 0.1.10, edda `61e0d53`, published
/// 2026-08-27). It stays a free function because the `[[build.iso]]` mirror is
/// not the only reader — `kernel_donor_of` and `tests/cli_matrix.rs` ask it by
/// variant name, and one value declared once is the point. The
/// value is declared ONCE here; `tests/toml_parity.rs` holds the toml's table
/// to it, and since the 0.1.10 pin it ALSO rides `IsoBuild.kernel_donor`, so the
/// whole-struct parity holds it like any other field.
///
/// Said exactly: the FORGE (dwarves, linking edda main) consumes the edge, and
/// since the bump so does the loader gunnar's own binaries link.
/// `tests/iso_edge_linked.rs` asks that loader and was RED on the 0.1.9 pin on
/// purpose. When the pin
/// advanced (0.1.10, 2026-08-27) the value now ALSO rides `IsoBuild.kernel_donor`
/// in [`isos`], and `tests/iso_edge_linked.rs` turned green by itself.
pub fn iso_kernel_donor() -> nornir_build_thing::installer::KernelDonor {
nornir_build_thing::installer::KernelDonor {
repo: "tunnr".to_string(),
kernel: "tunnr-kernel".to_string(),
}
}
/// **Which declared variant consumes which kernel** — the kernel edge by variant
/// name, from the Rust mirror (the installer's field and the live medium's
/// [`iso_kernel_donor`]), or `None` for a variant that boots no kernel (the
/// exe/container rows). The one place a gate asks "does this variant carry a
/// kernel edge?" — `tests/cli_matrix.rs`' fragment law reads it.
pub fn kernel_donor_of(variant: &str) -> Option<nornir_build_thing::installer::KernelDonor> {
if let Some(i) = installers().into_iter().find(|i| i.name == variant) {
return i.kernel_donor;
}
if isos().iter().any(|i| i.name == variant) {
return Some(iso_kernel_donor());
}
None
}
/// The live appliance ISO the `iso-live` column builds.
/// **What `dhcp` needs of the donor kernel** — the DHCPv4 client is four
/// datagrams on a socket, and this is the socket, named row by row.
///
/// They were measured against `tunnr-kernel` on 2026-08-30 and then moved
/// HERE, into the option row they always belonged to. They rode a
/// temporary `[[build.installer.option_requires]]` table for exactly as long as
/// this repo's own reader was the published `nornir-build-thing 0.1.10`, whose
/// `OptionSpec` is `#[serde(deny_unknown_fields)]` and refused the whole recipe
/// on the key `kernel_requires`. The pin is `nornir-build-thing 0.1.14` from
/// crates.io now (root `Cargo.toml`) — it was a path dep on the sibling edda
/// checkout while 0.1.11 was unpublished, and 2026-09-09 turned that path into a
/// version — so the declaration lives where the feature is and gunnar's own
/// loader can see it.
///
/// Three ABSENCES are measurements, not omissions:
/// * `CONFIG_PACKET` — the client is a UDP socket, never a raw one;
/// * `CONFIG_UNIX` — nothing here opens an AF_UNIX socket (PID 1's is AF_NETLINK);
/// * `CONFIG_VIRTIO_NET` — which NIC the machine has is `target.nic`'s fact, not
/// DHCP's; turning DHCP off does not remove the NIC.
fn dhcp_kernel_requires() -> Vec<KconfigReq> {
vec![
KconfigReq::y(
"CONFIG_NET",
"the DHCPv4 handshake is four datagrams on a socket; a kernel with no network stack has no socket to give (appliance_pid1::dhcp)",
),
KconfigReq::y(
"CONFIG_INET",
"DISCOVER/REQUEST are UDP: the client binds 0.0.0.0:68 and sends to 255.255.255.255:67 (dhcp::client_socket), and the OFFER/ACK come back broadcast because the interface has no address yet",
),
KconfigReq::y(
"CONFIG_NETDEVICES",
"SO_BINDTODEVICE names the interface the limited broadcast leaves by — the one thing that makes 255.255.255.255 routable with no address and no route",
),
KconfigReq::y(
"CONFIG_NET_CORE",
"the netdev core that carries that broadcast out of eth0 before there is a FIB entry to look up",
),
]
}
/// **What `wireguard` needs of the donor kernel** — the USERSPACE data plane's
/// one device, and the two symbols it hangs off.
///
/// ONE writer for both media (the live ISO's row and the installer's twin): the
/// same boringtun opens the same `/dev/net/tun` on either, so a second list
/// would be a second answer. `CONFIG_WIREGUARD` is vetoed everywhere — the
/// requirement is TUN, and with this option off nothing in the medium opens
/// that device and the kernel needs none of it (MEASURED 2026-08-30: bzImage
/// 2 388 992 → 2 364 416 B with `--opt wireguard=off`, and TUN comes back BY
/// CLOSURE rather than by a fragment line when it is on).
fn wireguard_kernel_requires() -> Vec<KconfigReq> {
vec![
KconfigReq::y(
"CONFIG_TUN",
"boringtun is a USERSPACE data plane: wg_appliance_core opens /dev/net/tun and drives the tunnel from the app, which is why CONFIG_WIREGUARD is vetoed everywhere — with this option off nothing in the medium opens that device and the kernel needs no TUN at all",
),
KconfigReq::y(
"CONFIG_NETDEVICES",
"TUN is a netdevice — the driver lives under the NETDEVICES menu (drivers/net/Kconfig)",
),
KconfigReq::y(
"CONFIG_INET",
"CONFIG_TUN `depends on INET` (drivers/net/Kconfig), and the tunnel's outer packets are UDP over IPv4",
),
]
}
// **The paravirt wall clock and PCI hot-plug** every medium asks of the donor kernel
// are edda's `nornir_build_thing::kernel_requires::{clock_requires, hotplug_requires}`
// since lane G10 (2026-10-21): moved there AS IS from this file (clock 50c3e69, hot-plug
// 35290f29 beside the runtime probe b247bf64), one list for every product. holger's
// installer media declared no hot-plug, and a runtime grow on such a kernel answers 511.
// The long argument for each is on the shared function.
fn isos() -> Vec<IsoBuild> {
vec![IsoBuild {
name: "gunnar-appliance-iso".to_string(),
// The kernel edge, on the struct that carries it since the 0.1.10 pin
// (2026-08-27). See `iso_kernel_donor` for why the value is ALSO a free
// function: the mirror had to say it under the 0.1.9 loader, which had
// no field to say it on.
kernel_donor: Some(iso_kernel_donor()),
// The generalised consumption edges beside `kernel_donor`. gunnar
// declares none: its one edge IS the kernel donor above. Named, because
// this mirror names every field on purpose — a field added upstream
// must get a decision here, not default in silently.
edges: BTreeMap::new(),
from_variant: Some("gunnar-server".to_string()),
staging: "assets/appliance-root".to_string(),
volume_id: "GUNNAR_APPLIANCE".to_string(),
format: IsoFormat::Uefi,
boot_params: [
// Order is load-bearing — `.nornir/build-variants.toml` says why:
// tty0 first so a KVM graphics console gets the printk stream,
// ttyS0 LAST so it stays `/dev/console`, the serial line the
// installtest boot proof reads korp-start and gunnar off.
"console=tty0",
"console=ttyS0,115200",
"panic=10",
"GUNNAR_DATA_ROOT=/data",
// DHCP since the PID-1 client landed; the toml carries the full
// why. QEMU user-net grants the same 10.0.2.15 the static line
// used to bake in, so the forwarded-port recipes are unchanged.
"GUNNAR_NET=eth0:dhcp",
"korp.entry=gunnar",
"korp.znippy=/app.znippy",
"--",
"serve",
// ── THIS MEDIUM TAKES THE TOFU DOOR ──────────────────────────
//
// It said `--no-grpc --admin-key @/data/admin.pub` until
// 2026-08-27, and what that shipped was a brick: `--no-grpc`
// closed Bootstrap.Enroll and the seed file was never staged,
// because the `admin_key` parameter beside it was
// `required = false`. MEASURED by booting the asset published on
// release v1.1.3 — `reading the public key from /data/admin.pub:
// No such file or directory`. The live medium's state is RAM and
// dies with the boot, so it takes TOFU and the installer keeps
// the sealed key; `crate::reachability` and the toml both carry
// the long form.
//
// ⚠ `--grpc-bind` is NOT decoration. Its default is
// `127.0.0.1:50051` and gunnar has no shell and no port
// forwarding, so dropping `--no-grpc` alone ships a control plane
// nothing off the box can reach — the same defect wearing the
// opposite argv. Delete this pair and
// `tests/appliance_reachable.rs` goes red over this very
// declaration, by name.
"--grpc-bind",
"0.0.0.0:50051",
// ── THE FLOOR OF A /data THAT IS RAM ─────────────────────────
//
// `--disk-floor`'s default is "5 % or 2 GiB, whichever is larger"
// (gunnar-ssh `headroom.rs`, measured 2026-09-04) — right for the
// installed disk, where 2 GiB is the survivable reserve on a 25
// GiB volume. This medium's `/data` is a tmpfs the size of the
// guest's RAM, and MEASURED 2026-09-05 by the installtest robot on
// a 2 GiB guest: `/data has 947.4 MiB free, under the 2.0 GiB
// floor … no new repository and no push` — a live appliance that
// refuses EVERY push, on a medium whose whole point is to serve
// one boot's worth of git out of RAM. The absolute half of the
// floor cannot be larger than the store; 5 % of the tmpfs plus a
// 64 MiB absolute keeps the operating reserve (the control-store
// rewrite, an append-side compaction) without demanding a disk
// the medium does not have. The installers say nothing here and
// keep the default.
"--disk-floor",
"5%,64MiB",
]
.iter()
.map(|s| (*s).to_string())
.collect(),
// gunnar's mark on the LIVE medium too, not only on the installer's
// (measured 2026-08-18 on the laptop: the live boot wore korp's raven on the
// graphics console). Same file the installer variant bakes; the parity
// guard holds the two variants to one mark.
boot_logo: Some("assets/gunnar.webp".to_string()),
// NO PARAMETERS, and the empty vec is the decision rather than an
// omission. An `admin_key` row stood here with `required: false` and
// `staged_file: "admin.pub"`, and it was wrong twice over: the argv
// referenced it UNCONDITIONALLY (so "not required" was unbuildable in
// practice), and on THIS typ `staged_file` is relative to the initramfs
// root, so the file landed at `/admin.pub` while the argv read
// `/data/admin.pub` — one directory, measured, `EXIT=0`, and a medium
// that could never serve. The toml block carries all three defects in
// full. A per-site appliance sealed to a named key is the INSTALLER's
// job; this one is a live demonstration medium and takes TOFU.
params: Vec::new(),
// ── the LIVE medium's own diet (2026-08-27) ──────────────────────────
//
// The twin of the installer's `wireguard` row below, on the medium that IS
// gunnar rather than the one that installs it. Same name, same feature,
// same forbidden families; what differs is which cell it decides, which is
// the point of an option living on a cell.
//
// NO `params_when_on`: this cell declares no `hub_conf` (its `/data` is RAM
// and dies with the boot), and a conditional parameter naming a row that
// does not exist is refused at validation.
//
// ⚠ It resolves to `gunnar-server-thin` (`variants.rs`), and it has to: an
// OFF build that resolves a feature set no variant declares gets no
// receipt and cannot say which build point it is — gunnar's own gate said
// so out loud on the first live composition of it.
options: vec![nornir_build_thing::options::OptionSpec {
name: "wireguard".to_string(),
label: "WireGuard-klient".to_string(),
default: true,
feature: "wg-client".to_string(),
params_when_on: Vec::new(),
params_when_off: Vec::new(),
param_kinds: std::collections::BTreeMap::new(),
forbid_symbols: vec!["wg_appliance_core".to_string(), "boringtun".to_string()],
bakes: None,
// The SAME rows as the installer's twin of this option, out of the
// one writer below: the live medium runs the same boringtun over the
// same /dev/net/tun, and two hand-written lists would be two answers
// to one question.
kernel_requires: wireguard_kernel_requires(),
requires: Vec::new(),
}],
// The one row-level requirement this medium carries: the paravirt wall
// clock. The LIVE medium runs the same gunnar as the installed disk and
// reads the same boot clock, so a donor without kvmclock boots it on the
// emulated CMOS — two hours fast on UpCloud, measured 2026-09-20. PCI
// hot-plug is NOT here: growing the data set is the installed disk's
// verb, and this medium's `/data` is RAM.
requires: clock_requires(),
..IsoBuild::default()
}]
}
/// The installer ISO — the shape that puts gunnar on a **disk**.
///
/// The mirror of `.nornir/build-variants.toml`'s `[[build.installer]]`, which
/// carries the long argument. In one paragraph: `gunnar-appliance-iso` boots
/// gunnar FROM the ISO onto a `/data` that is RAM, so it can never hold an
/// identity; this variant boots `korp-installer` as PID 1, which formats a real
/// FAT32 filesystem on P3 (`edda/crates/korp-installer/src/esp.rs`,
/// `format_data_partition`) and leaves an installed appliance whose data root
/// OUTLIVES the reboot. That is the precondition the identity ceremony needed
/// and the reason gunnar seeded its WireGuard key instead of minting one.
///
/// The live variant above is untouched and PARKED — beside, never instead of.
///
/// TWO rows since 2026-08-28: the SEALED installer (door A, `--no-grpc
/// --admin-key @/data/admin.pub`, `admin_key` required) and its KEYLESS twin
/// `gunnar-installer-open` (door B, `--grpc-bind 0.0.0.0:50051`, no key on the
/// medium). The twin is DERIVED from the sealed row by [`open_twin`] rather
/// than written out a second time, so the one product decision that differs
/// — the bootstrap door — is the only thing that CAN differ between them here;
/// the toml spells both in full and `tests/toml_parity.rs` holds the two
/// spellings equal field by field.
fn installers() -> Vec<InstallerBuild> {
let sealed = sealed_installer();
let open = open_twin(&sealed);
let gce = scsi_twin(&open);
let aws = nitro_twin(&open);
let azure = hyperv_twin(&open);
// The pairing seed rides the UpCloud pair (open + metered) — derived from
// the open row AFTER the cloud twins, so it is declared on exactly the two
// media the estate pairs, and the metered row inherits it from its open twin.
let open = with_pairing_seed(open);
// DEV-NARRATE rides the same pair (and both fronts, below): the media an
// estate is installed from. The metered row inherits it from its open twin.
let open = with_dev_narrate(open);
let metered = metered_twin(&open, METERED_INSTALLER, METERED_VOLUME_ID, MONETIZE_PUBKEY);
let metered_aws = metered_twin(
&aws,
METERED_AWS_INSTALLER,
METERED_AWS_VOLUME_ID,
MONETIZE_PUBKEY,
);
let metered_azure = metered_twin(
&azure,
METERED_AZURE_INSTALLER,
METERED_AZURE_VOLUME_ID,
MONETIZE_PUBKEY,
);
// vm1's own medium, derived from the open row WITH its pairing seed (every
// medium of an estate reaches the installer the same way).
let front = front_installer(&open);
let front_metered = front_metered_installer(&front);
vec![
sealed,
open,
gce,
aws,
azure,
metered,
metered_aws,
metered_azure,
front,
front_metered,
]
}
/// The name of the SEALED installer — the original, the one whose `admin_key`
/// is `required = true`. Named rather than left a literal so the roster in
/// `tests/appliance_reachable.rs` can be asserted by name; the other three
/// already had consts and this one did not, which is how a four-variant roster
/// came to be checked as the number 3.
pub const SEALED_INSTALLER: &str = "gunnar-installer";
/// The name of the virtio-scsi medium and of its `[[build.installer]]` row.
pub const GCE_INSTALLER: &str = "gunnar-installer-gce";
/// Its volume id — DIFFERENT from `GUNNAR_INSTALL` on purpose: the medium is a
/// different medium (a different kernel is on it), and `installtest`'s
/// `declared_installer` resolves a medium to its cell by the volume id first.
/// Two media that answer to one id are resolved by FILENAME, which is a
/// convention rather than a fact about the bytes.
pub const GCE_VOLUME_ID: &str = "GUNNAR_INSTALL_GCE";
/// **The virtio-scsi twin: the medium for a cloud whose boot disk is SCSI.**
///
/// Derived from the OPEN row for the same reason [`open_twin`] is derived from
/// the sealed one — a third hand-written row would be a third place for the
/// medium to drift. Exactly THREE things differ, and each is a consequence of
/// the machine:
///
/// 1. **`target.buses ∋ virtio-scsi`.** GCE gen1/gen2 Persistent Disk really is
/// virtio-scsi (`nornir_build_thing::target::presets::GCE_VIRTIO`,
/// `boot_disk = VirtioScsi`), so the target disk appears as `/dev/sda` and
/// the kernel needs `SCSI BLK_DEV_SD SCSI_VIRTIO`. With the shared row's
/// `buses = [ide-cdrom, virtio-blk]` the disk is not in `/sys/block` at all
/// and the appliance refuses CORRECTLY on its own console — MEASURED
/// 2026-08-30: `INSTALLER: FAILED hwdetect: no usable target disk — every
/// fixed disk carries the installer medium`.
///
/// `ide-cdrom` is DROPPED, because GCE attaches no cdrom (its `MediumBring`
/// is `raw disk.raw in a tar.gz`), and carrying `ATA ATA_PIIX BLK_DEV_SR`
/// for a device the machine does not have is exactly the bloat this row
/// exists to avoid.
/// 2. **A different kernel donor** — `tunnr-kernel-scsi`. Not a choice: the
/// shared donor's `nuked` preset says `# CONFIG_SCSI is not set` BY NAME,
/// and `KernelBuild::apply_requirements` refuses a requirement over a preset
/// veto instead of merging it. A widened shared row would not build a
/// smaller kernel — it would not build AT ALL.
/// 3. **A different volume id**, so a medium can still say which cell it is.
///
/// **Why not widen the one row.** Every symbol above would then be on the
/// UpCloud appliance's kernel too, and that kernel's whole point is 2.4 MB. The
/// bus is a property of ONE cloud's disk; a shared row would make every
/// customer pay for it.
fn scsi_twin(open: &InstallerBuild) -> InstallerBuild {
let mut gce = open.clone();
gce.name = GCE_INSTALLER.to_string();
gce.volume_id = GCE_VOLUME_ID.to_string();
gce.target = Target {
firmware: Firmware::Both,
consoles: vec![Console::Vga, Console::Serial],
buses: vec![Bus::VirtioBlk, Bus::VirtioScsi],
nic: Nic::Virtio,
..Target::default()
};
gce.kernel_donor = Some(nornir_build_thing::installer::KernelDonor {
repo: "tunnr".to_string(),
kernel: "tunnr-kernel-scsi".to_string(),
});
gce
}
/// The name of the NVMe/ENA medium and of its `[[build.installer]]` row.
pub const AWS_INSTALLER: &str = "gunnar-installer-aws";
/// Its volume id — DIFFERENT again, for [`GCE_VOLUME_ID`]'s exact reason: a
/// Nitro medium carries a different kernel (the NVMe+ENA donor), so it is a
/// different medium, and `installtest`'s `declared_installer` resolves a medium
/// to its cell by the volume id first.
pub const AWS_VOLUME_ID: &str = "GUNNAR_INSTALL_AWS";
/// **The NVMe/ENA twin: the medium for AWS Nitro.**
///
/// Derived from the OPEN row for the same reason [`scsi_twin`] is — a fourth
/// hand-written row would be a fourth place for the medium to drift. Exactly
/// THREE things differ from the open row, and each is a consequence of the
/// machine, the SAME three axes the GCE twin moves:
///
/// 1. **`target.buses = [nvme]`, `target.nic = ena`.** AWS Nitro presents its
/// boot disk as an NVMe controller (`/dev/nvme0n1`) and its NIC as the
/// Elastic Network Adapter — never virtio, never IDE, no cdrom. So the disk
/// the installer finds is NVMe and the closure asks for `CONFIG_NVME_CORE
/// CONFIG_BLK_DEV_NVME CONFIG_ENA_ETHERNET`, all built-in. With the shared
/// row's `[ide-cdrom, virtio-blk]` the Nitro target disk is not in
/// `/sys/block` at all and the appliance would refuse on its own console —
/// the GCE `no usable target disk` failure, one cloud over. `ide-cdrom` is
/// dropped because Nitro attaches no cdrom (its `MediumBring` is an imported
/// disk image), and no virtio bus is carried because Nitro has no virtio
/// device.
/// 2. **A different kernel donor** — `tunnr-kernel-nvme`. Like the SCSI donor
/// it starts from `slim`, not `nuked`: `slim` vetoes neither NVMe nor ENA by
/// name, so `KernelBuild::apply_requirements` MERGES the two requirements
/// instead of refusing them.
/// 3. **A different volume id**, so a medium can still say which cell it is.
///
/// `medium_boot` stays `Hybrid` (inherited): Nitro boots from an IMPORTED disk
/// image (VM Import or import-snapshot + register-image), and a pure El Torito
/// ISO with a zeroed LBA 0 would park exactly as it did on OVH — the hybrid
/// boot sector is what makes an imported-disk cloud bootable at all.
///
/// **Why not widen the one row.** Every symbol above would then be on the
/// UpCloud appliance's kernel and on GCE's, for devices neither machine has.
/// The bus and the NIC are one cloud's facts; a shared row would make every
/// customer pay for them.
fn nitro_twin(open: &InstallerBuild) -> InstallerBuild {
let mut aws = open.clone();
aws.name = AWS_INSTALLER.to_string();
aws.volume_id = AWS_VOLUME_ID.to_string();
// Declared by PRESET since 2026-09-05 (`aws-nitro` derives the same
// both · vga+serial · nvme · ena this row spelled by hand), for the reason
// the Azure twin gives: the preset is what tells the composer which
// container Nitro imports (raw / disk image), and it writes it beside the
// ISO. The OPEN row stays spelled out on purpose: `upcloud` is SeaBIOS-only
// by measurement, while the open medium is `both` and boots under OVMF on
// every KVM cloud and on this box's bench rig.
aws.target = Target {
preset: Some("aws-nitro".to_string()),
..Target::default()
};
aws.kernel_donor = Some(nornir_build_thing::installer::KernelDonor {
repo: "tunnr".to_string(),
kernel: "tunnr-kernel-nvme".to_string(),
});
aws
}
/// The name of the Hyper-V medium and of its `[[build.installer]]` row.
pub const AZURE_INSTALLER: &str = "gunnar-installer-azure";
/// Its volume id — DIFFERENT again, for [`GCE_VOLUME_ID`]'s exact reason: a
/// Hyper-V medium carries a different kernel (the VMBus donor), so it is a
/// different medium, and `installtest`'s `declared_installer` resolves a
/// medium to its cell by the volume id first.
pub const AZURE_VOLUME_ID: &str = "GUNNAR_INSTALL_AZURE";
/// **The Hyper-V twin: the medium for Azure (Generation 2 VMs).**
///
/// Derived from the OPEN row for the same reason [`nitro_twin`] is — a fifth
/// hand-written row would be a fifth place for the medium to drift. Exactly
/// THREE things differ from the open row, the SAME three axes the GCE and
/// Nitro twins move, each a consequence of the machine
/// (`nornir_build_thing::target::presets::AZURE_GEN2`):
///
/// 1. **`target.buses = [hv-storvsc]`, `target.nic = hv-netvsc`.** Azure
/// presents every disk as Hyper-V synthetic SCSI (`hv_storvsc`) and its NIC
/// as the synthetic `hv_netvsc`, both over VMBus — never virtio, never NVMe,
/// no cdrom. So the disk the installer finds is `/dev/sda` on storvsc and
/// the closure asks for `CONFIG_HYPERVISOR_GUEST CONFIG_HYPERV
/// CONFIG_HYPERV_STORAGE CONFIG_HYPERV_NET CONFIG_SCSI CONFIG_BLK_DEV_SD`,
/// all built-in. With the shared row's `[ide-cdrom, virtio-blk]` the target
/// disk is not in `/sys/block` at all — the GCE `no usable target disk`
/// failure, one cloud over. `ide-cdrom` is dropped because Azure attaches
/// no cdrom (its `MediumBring` is an imported VHD), and no virtio bus is
/// carried because a Gen2 VM has no virtio device.
/// 2. **A different kernel donor** — `tunnr-kernel-hyperv`. Like the NVMe donor
/// it starts from `slim`, which vetoes none of the VMBus symbols by name, so
/// `KernelBuild::apply_requirements` MERGES the requirements.
/// 3. **A different volume id**, so a medium can still say which cell it is.
///
/// `firmware` is `Uefi` alone, not `Both`: a Gen2 VM has no CSM, and a Gen1
/// VM boots its OS disk off emulated IDE — a bus this `target` group spells
/// only as a cdrom — so Gen1 is not a shape this medium claims. That is also
/// why `medium_boot` is `Gpt`, not the inherited `Hybrid`: Azure boots from an
/// IMPORTED disk image (a fixed-size VHD in a storage account → managed image
/// or Compute Gallery), so the ISO must be bootable as a DISK — and `hybrid`
/// does that with a legacy boot sector the composer refuses to lay without a
/// BIOS stage (MEASURED 2026-09-05: *"`medium_boot = "hybrid"` needs a real
/// legacy-BIOS stage, but `target.firmware = uefi` lays none"*). `gpt` lays the
/// GPT ESP partition instead, which is what UEFI reads off a disk. The ISO this
/// cell composes is that image's source; `qemu-img convert -O vpc -o
/// subformat=fixed` turns it into the VHD the upload wants.
///
/// **Why not widen the one row.** Every symbol above would then be on the
/// UpCloud appliance's kernel and on GCE's and Nitro's, for a hypervisor none
/// of those machines run under.
fn hyperv_twin(open: &InstallerBuild) -> InstallerBuild {
let mut azure = open.clone();
azure.name = AZURE_INSTALLER.to_string();
azure.volume_id = AZURE_VOLUME_ID.to_string();
// Declared by PRESET, not spelled out: `azure-gen2` derives exactly the
// shape this row carried by hand (uefi · vga+serial · hv-storvsc ·
// hv-netvsc — nornir-build-thing's own test asserts the seven fields
// agree), and the preset is what lets the composer write the cloud's
// import container beside the ISO (edda `62ada54`, 2026-09-05: a cell
// that names an imported-disk machine yields its `.vhd` by declaration;
// seven identical fields cannot say which container a cloud takes — four
// virtio rows split two-and-two between an attached ISO and an imported
// qcow2, so the shape alone is not the fact).
azure.target = Target {
preset: Some("azure-gen2".to_string()),
..Target::default()
};
azure.kernel_donor = Some(nornir_build_thing::installer::KernelDonor {
repo: "tunnr".to_string(),
kernel: "tunnr-kernel-hyperv".to_string(),
});
azure.medium_boot = MediumBoot::Gpt;
azure
}
/// The name of the METERED medium and of its `[[build.installer]]` row.
pub const METERED_INSTALLER: &str = "gunnar-installer-metered";
/// Its volume id — DIFFERENT again, for [`GCE_VOLUME_ID`]'s exact reason. This
/// medium carries a DIFFERENT BINARY (`gunnar-server-metered`, whose symbol
/// table holds the entitlement code the community row has none of), so it is a
/// different medium, and `installtest`'s `declared_installer` resolves a medium
/// to its cell by the volume id first.
pub const METERED_VOLUME_ID: &str = "GUNNAR_INSTALL_METERED";
/// **monetize-server's Ed25519 verifying key**, the 32 public bytes every
/// `Entitlement.Set` on a metered appliance is checked against.
///
/// Baked into the medium rather than staged onto `/data`, because staging is
/// not reachable: `korp-installer` writes staged files only under
/// `korp.reinstall=wipe`, and the ESP is read-only once the box is running. So
/// on the default keep-upgrade the kernel command line is the ONLY channel into
/// a booting gunnar, which is why `--monetize-pubkey` had to learn the literal
/// spelling (`gunnar_common::monetize_key`) before this row could exist at all.
///
/// A VERIFYING key is public by construction — it is what monetize hands out so
/// that facts it signed can be checked. Putting it in a medium and on a cmdline
/// discloses nothing. The SIGNING key never leaves monetize-server, and nothing
/// in gunnar can accept one.
pub const MONETIZE_PUBKEY: &str =
"1c3663248d60900e2996389fba4efa1d7179d6dbd08627d0426f16f94e44c4e6";
/// **Where a lapsed tenant renews.** Baked beside the key for the same reason
/// the key is baked at all: on a keep-upgrade the kernel command line is the
/// ONLY channel into a booting gunnar, and `--billing-url` is REQUIRED whenever
/// `--monetize-pubkey` is given.
///
/// **MEASURED, 2026-09-08, and it cost an outage.** `gunnar-server` used to
/// fall back to a hardcoded `https://gunnar.rs/billing` when told a key and no
/// URL. That default was removed — correctly: the OPEN product must not ship
/// one operator's commercial URL, and a metered appliance with no renew link
/// refuses its tenants into a dead end. But this row was not updated with it,
/// so the next medium carried a key and no URL, and gunnar — which is PID 1 —
/// refused to start:
///
/// ```text
/// gunnar: --monetize-pubkey was given, but --billing-url was not, and this
/// build has no default for it: it would have to invent the page your lapsed
/// tenants are sent to.
/// Kernel panic - not syncing: Attempted to kill init! exitcode=0x00000100
/// ```
///
/// The refusal is right and stays. What was missing is this line. The pairing
/// is asserted by `metered_env_pairs_the_key_with_a_renew_url`, so a medium can
/// never again be composed with one half of it.
///
/// **A placeholder on a running estate** (UW-6): the medium is one for every
/// estate, so the installer's door feeds each site its own page
/// (`refusal_link` = `https://<site>/billing/{tenant}`, gunnar-server
/// `feed::FEED_BILLING_URL`), which replaces this one live.
pub const METERED_BILLING_URL: &str = "https://www.gunnar.rs/billing/{tenant}";
/// The environment variable `gunnar-server` reads the monetize verifying key
/// from (`gunnar_common::cli`, `env = "GUNNAR_MONETIZE_PUBKEY"`).
pub const MONETIZE_PUBKEY_ENV: &str = "GUNNAR_MONETIZE_PUBKEY";
/// The environment variable it reads the renew URL from (`env =
/// "GUNNAR_BILLING_URL"`, `requires = "monetize_pubkey"`).
pub const BILLING_URL_ENV: &str = "GUNNAR_BILLING_URL";
/// The `--param` name a composing door supplies the front's live key under.
pub const MONETIZE_PUBKEY_PARAM: &str = "monetize_pubkey";
/// The environment variable `gunnar-server-metered` reads `--monetize-vital` from.
pub const MONETIZE_VITAL_ENV: &str = "GUNNAR_MONETIZE_VITAL";
/// The `--param` a composing door sets it through (`true`|`false`).
pub const MONETIZE_VITAL_PARAM: &str = "monetize_vital";
/// Its default on every metered medium: owner ruling 2026-09-22, monetize is vital.
pub const MONETIZE_VITAL_DEFAULT: &str = "true";
/// The environment variable `gunnar-server` (with `admit-share`) reads
/// `--admit-limit` from.
pub const ADMIT_LIMIT_ENV: &str = "GUNNAR_ADMIT_LIMIT";
/// The `--param` a composing door sets it through (`100GB`, `none`, bytes).
pub const ADMIT_LIMIT_PARAM: &str = "admit_limit";
/// **Its default on every metered medium: `none` — PassThrough.** A medium
/// composed without the parameter admits exactly as a gate without the usage
/// share; a budget is always an estate's explicit decision
/// (`private-gunnar-ops` `estate.toml` `[admit]`).
pub const ADMIT_LIMIT_DEFAULT: &str = "none";
/// The environment variable `--admit-window-days` is read from.
pub const ADMIT_WINDOW_ENV: &str = "GUNNAR_ADMIT_WINDOW_DAYS";
/// The `--param` it is set through.
pub const ADMIT_WINDOW_PARAM: &str = "admit_window_days";
/// **The metered medium's monetize declaration, as a function of the key —
/// the ONE writer of both halves.**
///
/// ⚠ **Why an `env` pair and not two more words on `korp.args`, which is where
/// this lived until 2026-09-20.** The key WAS a constant spelled into the argv,
/// and a constant is the defect: `monetize-server` mints a fresh Ed25519
/// signing key on every freshly laid front, so the baked key went stale the
/// moment a new estate existed and the only way out was a human editing a
/// tracked declaration before every bring-up. `private-gunnar-ops`' own
/// `monetize-pubkey-mismatch` preflight refused the bring-up over exactly that,
/// correctly.
///
/// Making it an INPUT means a value the composing door supplies has to REPLACE
/// the declared default, and on `nornir-build-thing 0.1.17` there is exactly
/// one field with that property. Measured, in the order it was tried:
///
/// * a `[[build.installer.params]]` row landing on the cmdline **appends**
/// (`InstallerBuild::apply_cli_selection_with` → `boot_params.extend`), and
/// `korp.args` must stay EXACTLY ONE token
/// (`installer::declared_cmd_renders_as_one_korp_args_token`). Two tokens is
/// not an override, it is a medium with two argvs.
/// * appending a duplicate does not reach the installed disk either:
/// `korp_installer::install::carry_site_tokens` carries the **first** token
/// per key (`!carried.iter().any(|c| c.starts_with("{k}="))`), so the
/// leftover default would win on the box even though the installer's own
/// `env_pairs` is last-wins. The two readings disagree; that is a defect in
/// edda and it is reported, but nothing here may depend on it.
/// * `[[build.installer.options]].bakes` DOES template a value into a token and
/// DOES replace by key — but a bake hangs off an `OptionSpec`, and
/// `OptionSpec::validate` refuses one that names no cargo `feature`. There is
/// no honest feature here, and the long note on [`metered_twin`] already
/// ruled out spelling `metered` as a toggle.
/// * `InstallerBuild::env`, however, is rendered by `effective_boot_params`
/// with a PER-KEY idempotence rule — `korp.env.<K>=` is pushed only when no
/// token with that key is present, and landed parameters are already in
/// `boot_params` by then. So a supplied `--param monetize_pubkey=<hex>`
/// lands `korp.env.GUNNAR_MONETIZE_PUBKEY=<hex>` and the declared default is
/// **not emitted at all**: one token, the operator's, on every channel that
/// reads it. That is the override, and it needs nothing new anywhere.
///
/// **Every reason the key is on the kernel command line survives this.**
/// `korp.env.*` IS the command line: `korp-installer` writes staged files only
/// under `korp.reinstall=wipe` and the ESP is read-only once the box runs, so
/// on the default keep-upgrade the cmdline is still the ONLY channel into a
/// booting gunnar. What changed is which token on it carries the value.
///
/// **And the renew URL is still MANDATORY, now by construction rather than by
/// vigilance.** This function is the only thing that writes either half and it
/// writes both, so [`METERED_BILLING_URL`] rides beside ANY key — the supplied
/// one included, because a parameter can only ever REPLACE the key's token
/// (`apply_params_with` refuses an empty value) and can never remove the URL's.
/// The 2026-09-08 outage — a medium carrying `--monetize-pubkey` and no
/// `--billing-url`, gunnar refusing, gunnar being PID 1, `Kernel panic - not
/// syncing: Attempted to kill init!` — is unreachable from here.
///
/// Why the argv is the poorer channel for the same pair, measured against
/// clap: a value on the argv WINS over the same argument's `env`, so an argv
/// that spells the key cannot be overridden by anything the medium is composed
/// with. The pair moving together to `env` is what makes the override possible
/// at all.
#[must_use]
pub fn metered_env(monetize_pubkey: &str) -> std::collections::BTreeMap<String, String> {
[
(MONETIZE_PUBKEY_ENV.to_string(), monetize_pubkey.to_string()),
(BILLING_URL_ENV.to_string(), METERED_BILLING_URL.to_string()),
(
MONETIZE_VITAL_ENV.to_string(),
MONETIZE_VITAL_DEFAULT.to_string(),
),
// The usage share's budget: `none` unless the estate says otherwise,
// replaced per key by `--param admit_limit=…` (see `admit_limit_param`).
(ADMIT_LIMIT_ENV.to_string(), ADMIT_LIMIT_DEFAULT.to_string()),
]
.into_iter()
.collect()
}
/// **The declared setting the front's live key arrives through.**
///
/// A `[[build.installer.params]]` row, which is how this model spells a
/// per-site value: the CLI's `--param monetize_pubkey=<hex>`, the ⚒ pane's
/// input row and the forge's field are all this one declaration, and a door
/// that supplies a name no row declares is refused BY NAME
/// (`params::apply_params_with`). So `private-gunnar-ops` forwards a value it
/// was handed and spells no key of its own.
///
/// `required: false` on purpose: [`MONETIZE_PUBKEY`] stays the DEFAULT for a
/// build that names none (a self-hoster, a KVM proof, the bench), and a named
/// key wins over it. `Text`, which is 0.1.7 vocabulary, and the value is a
/// single token by construction — 64 hex characters.
///
/// Not a secret and `names_a_secret` agrees: a VERIFYING key is what monetize
/// hands out so that facts it signed can be checked, and the SIGNING key never
/// leaves monetize-server.
#[must_use]
pub fn monetize_pubkey_param() -> nornir_build_thing::params::ParamSpec {
nornir_build_thing::params::ParamSpec {
lands: nornir_build_thing::params::Landing::Data,
name: MONETIZE_PUBKEY_PARAM.to_string(),
label: "monetize-server's Ed25519 verifying key, 64 hex characters (the front's \
MONETIZE_SIGNING_PUBKEY)"
.to_string(),
kind: nornir_build_thing::params::ParamKind::Text,
required: false,
boot_param: format!("korp.env.{MONETIZE_PUBKEY_ENV}"),
staged_file: String::new(),
}
}
/// **`monetize_vital`, the owner's ruling of 2026-09-22, as a medium parameter.** `true` (the
/// declared default, [`metered_env`]): a box whose stored entitlement facts do not verify at boot
/// raises `entitlement.key_mismatch` and reports `maintenance` until monetize pushes one that
/// does; `false` only logs. The box starts and serves either way. Replaces the default's token by
/// the same per-key rule as [`monetize_pubkey_param`].
#[must_use]
pub fn monetize_vital_param() -> nornir_build_thing::params::ParamSpec {
nornir_build_thing::params::ParamSpec {
lands: nornir_build_thing::params::Landing::Data,
name: MONETIZE_VITAL_PARAM.to_string(),
label: "is monetize vital: true raises entitlement.key_mismatch and maintenance when stored facts do not verify at boot, false only logs (default true)".to_string(),
kind: nornir_build_thing::params::ParamKind::Text,
required: false,
boot_param: format!("korp.env.{MONETIZE_VITAL_ENV}"),
staged_file: String::new(),
}
}
/// **`admit_limit`: the usage share's budget as a medium parameter** (owner
/// 2026-09-28). `100GB`, `5 GiB`, a byte count, or `none` (PassThrough — the
/// declared default, [`ADMIT_LIMIT_DEFAULT`]). Lands
/// `korp.env.GUNNAR_ADMIT_LIMIT`, replacing the default's token by the same
/// per-key rule as [`monetize_pubkey_param`]. Metered media only: the usage
/// share is in the metered binary alone.
#[must_use]
pub fn admit_limit_param() -> nornir_build_thing::params::ParamSpec {
nornir_build_thing::params::ParamSpec {
lands: nornir_build_thing::params::Landing::Data,
name: ADMIT_LIMIT_PARAM.to_string(),
label: "the usage share's transfer budget per window for a namespace: a size (100GB, 5GiB, bytes) or none = PassThrough (default none)".to_string(),
kind: nornir_build_thing::params::ParamKind::Text,
required: false,
boot_param: format!("korp.env.{ADMIT_LIMIT_ENV}"),
staged_file: String::new(),
}
}
/// **`admit_window_days`: the usage share's rolling window**, in UTC days
/// (the server's default, 30, when absent). Lands
/// `korp.env.GUNNAR_ADMIT_WINDOW_DAYS`; no declared default token, so the
/// server's own default is the only one.
#[must_use]
pub fn admit_window_param() -> nornir_build_thing::params::ParamSpec {
nornir_build_thing::params::ParamSpec {
lands: nornir_build_thing::params::Landing::Data,
name: ADMIT_WINDOW_PARAM.to_string(),
label: "the usage share's rolling window in UTC days (default 30)".to_string(),
kind: nornir_build_thing::params::ParamKind::Text,
required: false,
boot_param: format!("korp.env.{ADMIT_WINDOW_ENV}"),
staged_file: String::new(),
}
}
/// **The METERED twin: the same machine and the same door, billing the plan.**
///
/// Derived from the OPEN row for the reason [`scsi_twin`] and [`nitro_twin`]
/// are — a fifth hand-written row would be a fifth place for the medium to
/// drift. Exactly THREE things differ, and each is a consequence of the
/// BINARY rather than of the machine:
///
/// 1. **`variant = "gunnar-server-metered"`.** The community `gunnar-server`
/// contains no entitlement code at all, so a medium built from it serves
/// every namespace Free no matter what it is told. This was the whole gap:
/// `gunnar-server-metered` had existed since the entitlement work and NO
/// installer row named it, so no ISO could carry it.
/// 2. **The monetize pair on the cmdline**, [`metered_env`] — the verifying
/// key and the renew URL as `korp.env.*` tokens, plus the
/// [`monetize_pubkey_param`] row a composing door replaces the key through.
/// Spelled inline and never as a path: a path would name a file nothing was
/// allowed to create (see [`MONETIZE_PUBKEY`]), which is why every earlier
/// attempt at a metered appliance needed `korp.reinstall=wipe`.
/// 3. **A different volume id**, so a medium can still say which cell it is.
///
/// Everything else is inherited on purpose. Same kernel donor, same target,
/// same options, same parameters: metering is a property of the BINARY, not of
/// the machine, so a metered box is an UpCloud box that bills.
///
/// **Including `target.preset`, which the UpCloud row leaves `None`.** That is
/// the row the composer names when it asks for a machine, and the reason it
/// cannot answer `upcloud` yet is measured on the row it inherits from — see
/// the long `target:` comment in [`sealed_installer`] and
/// `tests/machine_named.rs`, which fails the day the block lifts. Inheriting
/// the silence rather than naming a machine here keeps the rule this function
/// exists for: only `name`, `variant`, `volume_id` and the key on the argv are
/// the twin's, and the MACHINE is always the row's it twins.
///
/// **Three twins since 2026-09-05**, one per cloud row, each derived from the
/// keyless row for THAT machine: [`METERED_INSTALLER`] of the open row
/// (UpCloud), [`METERED_AWS_INSTALLER`] of [`nitro_twin`]'s row and
/// [`METERED_AZURE_INSTALLER`] of [`hyperv_twin`]'s. The function takes the
/// row it twins rather than always the open one, so the AWS twin inherits
/// `aws-nitro` + `tunnr-kernel-nvme` and the Azure twin `azure-gen2` +
/// `tunnr-kernel-hyperv` + `medium_boot = gpt` — the machine's facts — from
/// the row that already spells them, and the composer writes each cloud's
/// import container (`.raw` / `.vhd`) beside the ISO from the inherited
/// preset exactly as it does for the open row. Only `name` and `volume_id`
/// are the caller's; `tests/toml_parity.rs` holds each twin to its open row
/// field by field, so nothing else CAN differ.
///
/// **Why not an option on the open row.** `metered` is a cargo feature, so it
/// selects a different variant and therefore a different artifact; an
/// `[[build.installer.options]]` toggle chooses features WITHIN one variant's
/// build and cannot swap the variant the medium is composed from.
fn metered_twin(
open: &InstallerBuild,
name: &str,
volume_id: &str,
monetize_pubkey: &str,
) -> InstallerBuild {
let mut metered = open.clone();
metered.name = name.to_string();
metered.variant = METERED_VARIANT.to_string();
metered.volume_id = volume_id.to_string();
// The argv is the open row's, byte for byte. What makes this medium the
// metered one is the BINARY it packs and the monetize pair below — and the
// pair lives in `env` rather than on the argv so a composing door can
// replace the key. See `metered_env`.
metered.env = metered_env(monetize_pubkey);
metered.params.push(monetize_pubkey_param());
metered.params.push(monetize_vital_param());
metered.params.push(admit_limit_param());
metered.params.push(admit_window_param());
metered
}
/// The `--param` name the estate's pairing seed arrives under (the pairing
/// contract, 2026-09-24: fixed, do not rename).
pub const PAIRING_SEED_PARAM: &str = "pairing_seed";
/// Where it lands, relative to `/data` on the installed box: the medium's
/// data-stage file `korp/data-stage/pairing/seed.toml`, copied by the first
/// boot to `/data/pairing/seed.toml` (`nordisk_pairing::seed::Seed` TOML).
pub const PAIRING_SEED_STAGED_FILE: &str = "pairing/seed.toml";
/// **The pairing seed — one per medium, nothing per box** (edda
/// `nordisk-pairing::seed`). `--param pairing_seed=@<seed.toml on the composing
/// host>` stages the file for `/data/pairing/seed.toml`; the PID-1 pairing loop
/// reads it there.
///
/// A STAGED FILE and never a `boot_param`: the seed holds the installer CA and
/// the bootstrap client certificate AND ITS KEY, multi-line PEM — the cmdline
/// is whitespace-split and readable to anyone who sees the boot. On the medium
/// the key is only as secret as the medium (owner 2026-09-24: bootstrap-only,
/// it may only join a slot that has not joined and expires with the installer
/// CA); on the installed box korp-installer lands it `0600` and mounts the ESP
/// copy owner-only (edda `agent/pairing-seed-stage`).
///
/// `required: false`: a medium composed without it composes exactly as before
/// and boots unpaired. The name says nothing secret, so `names_a_secret` lets
/// it through — the value's secrecy is the medium's, by design.
#[must_use]
pub fn pairing_seed_param() -> nornir_build_thing::params::ParamSpec {
nornir_build_thing::params::ParamSpec {
lands: nornir_build_thing::params::Landing::Data,
name: PAIRING_SEED_PARAM.to_string(),
label: "Pairing seed (nordisk-pairing seed.toml: estate, front, installer CA, bootstrap cert+key)"
.to_string(),
kind: nornir_build_thing::params::ParamKind::Text,
required: false,
boot_param: String::new(),
staged_file: PAIRING_SEED_STAGED_FILE.to_string(),
}
}
/// `row` plus the [`pairing_seed_param`], appended.
fn with_pairing_seed(mut row: InstallerBuild) -> InstallerBuild {
row.params.push(pairing_seed_param());
row
}
/// The `--opt` name of the DEV-NARRATE option.
pub const DEV_NARRATE_OPTION: &str = "dev-narrate";
/// The cargo feature it decides, on gunnar-server and gunnar-front alike.
pub const DEV_NARRATE_FEATURE: &str = "dev-narrate";
/// The symbol family that must be ABSENT from every OFF (release) payload:
/// Skidbladnir's `nordisk-narrate` crate, sender and all.
pub const DEV_NARRATE_FORBIDDEN: &str = "nordisk_narrate";
/// **DEV-NARRATE** (owner + coordinator 2026-09-30): the box's boot log, sealed
/// to the installer's key (the seed's `installer_pubkey`), over udp/51822 from
/// the moment the NIC has an address — before the installer's WireGuard link.
///
/// **Default OFF**, the one option here that is an opt-IN: a medium says
/// nothing ⇒ the payload carries no sender at all, and the audit reads
/// [`DEV_NARRATE_FORBIDDEN`] back out of the packed binary's symbols to prove
/// it. ON is the kit's dev medium only (`estate install --dev-narrate`, the
/// mock and a test instance; refused for the original estate), and resolves to
/// the payload's own `…-narrate` row. No kernel requirement beyond the
/// network the medium already needs (UDP/IPv4).
#[must_use]
pub fn dev_narrate_option() -> nornir_build_thing::options::OptionSpec {
nornir_build_thing::options::OptionSpec {
name: DEV_NARRATE_OPTION.to_string(),
label: "DEV-NARRATE (sealed early-boot log to the installer, udp/51822) — dev media only"
.to_string(),
default: false,
feature: DEV_NARRATE_FEATURE.to_string(),
params_when_on: Vec::new(),
params_when_off: Vec::new(),
param_kinds: std::collections::BTreeMap::new(),
forbid_symbols: vec![DEV_NARRATE_FORBIDDEN.to_string()],
bakes: None,
kernel_requires: Vec::new(),
requires: Vec::new(),
}
}
/// `row` plus the [`dev_narrate_option`], appended.
fn with_dev_narrate(mut row: InstallerBuild) -> InstallerBuild {
row.options.push(dev_narrate_option());
row
}
// ─────────────────────────────────────────────────────────────────────────────
// THE FRONT MEDIUM — vm1 boots its OWN ISO
// ─────────────────────────────────────────────────────────────────────────────
//
// Owner, 2026-09-25, verbatim: "twin/main gunnar share ISO, but NOT the
// ui/monetize!! BUILD an iso for them using build thing!!!". So an estate has
// TWO media: main and twin boot the gunnar medium (`gunnar-installer-open` /
// `-metered`, payload `gunnar-server`, byte for byte the same for both), and
// vm1 boots THIS one, whose payload is `gunnar-front`. Lane A had put the
// front's role into gunnar-server on the gunnar medium (54fa7bd); it lives in
// `gunnar-front` now and gunnar-server refuses a feed of role `front`.
/// The name of the FRONT medium and of its `[[build.installer]]` row.
pub const FRONT_INSTALLER: &str = "gunnar-front-installer-open";
/// Its volume id — its own, for [`GCE_VOLUME_ID`]'s reason: a different payload
/// is a different medium, and a medium is resolved to its row by volume id.
pub const FRONT_VOLUME_ID: &str = "GUNNAR_FRONT_INSTALL";
/// The cargo variant the front medium lays down.
pub const FRONT_PAYLOAD_VARIANT: &str = "gunnar-front";
/// What korp-start execs on the installed front.
pub const FRONT_ENTRY: &str = "korp.entry=gunnar-front";
/// **The front's forge-import switch** (AIRGAP, owner rule 2026-10-25): gunnar-ui-server
/// dials no forge unless `GUNNAR_UI_FORGES=on`, and every front medium (open and metered)
/// sets it, so a live or sold front keeps forge import as before. A front built without
/// `forges` ignores it.
pub const FRONT_FORGES_SWITCH: &str = "korp.env.GUNNAR_UI_FORGES=on";
/// **The front's ACME switch** (AIRGAP B1, owner 2026-10-25): gunnar-front asks no ACME
/// directory unless it is built with `acme` and `GUNNAR_FRONT_ACME=on`; every front medium
/// sets it, so a live or sold front still gets its certificate.
pub const FRONT_ACME_SWITCH: &str = "korp.env.GUNNAR_FRONT_ACME=on";
/// **The front's egress-door switch** (AIRGAP B2): the boxes behind a front with no public
/// IPv4 leave through it; every front medium sets it.
pub const FRONT_EGRESS_SWITCH: &str = "korp.env.GUNNAR_FRONT_EGRESS=on";
/// **The front medium, derived from the open row** — the same donor kernel,
/// target, boot logo, disk, payload shape and pairing seed, so vm1 reaches the
/// installer exactly as main and twin do. What differs, and why:
///
/// * `variant` = [`FRONT_PAYLOAD_VARIANT`], `korp.entry=gunnar-front`: the
/// payload is the front's program, not the git server;
/// * no `korp.args=` (gunnar-front takes none) and no `GUNNAR_APPLIANCE=1`
/// (the appliance gate is the git server's identity ceremony);
/// * the params: `hub_conf` (the pre-0.3 hub declaration; the front IS the
/// hub now), `clock_key` (the front is the root of time, it pins no peer)
/// and `static_ip`/`gateway` (conditional on the dhcp option, which this row
/// does not carry) are dropped. `clock` stays and means the front's OWN
/// sources; `pairing_seed`, `log_udp`, `reinstall`, `install_then` stay;
/// * no options: DHCP on both NICs and the WireGuard link are not choices on
/// the front (the installer's link is WireGuard always, edda 0.4.0). The
/// options' kernel requirements (NET/INET/NETDEVICES/TUN…) move onto the
/// row, so the kernel closure is the gunnar medium's.
fn front_installer(open: &InstallerBuild) -> InstallerBuild {
use nornir_build_thing::requires::{EdgeRequirement, Requirement};
let mut front = open.clone();
front.name = FRONT_INSTALLER.to_string();
front.variant = FRONT_PAYLOAD_VARIANT.to_string();
front.volume_id = FRONT_VOLUME_ID.to_string();
front.boot_params = open
.boot_params
.iter()
.filter(|p| !p.starts_with("korp.args=") && p.as_str() != "korp.env.GUNNAR_APPLIANCE=1")
.map(|p| {
if p.starts_with("korp.entry=") {
FRONT_ENTRY.to_string()
} else {
p.clone()
}
})
.collect();
let entry = front
.boot_params
.iter()
.position(|p| p == FRONT_ENTRY)
.unwrap_or(front.boot_params.len());
front
.boot_params
.insert(entry, FRONT_FORGES_SWITCH.to_string());
front
.boot_params
.insert(entry + 1, FRONT_ACME_SWITCH.to_string());
front
.boot_params
.insert(entry + 2, FRONT_EGRESS_SWITCH.to_string());
front
.params
.retain(|p| !["hub_conf", "clock_key", "static_ip", "gateway"].contains(&p.name.as_str()));
for p in &mut front.params {
if p.name == "clock" {
p.label = "The front's own clock sources (https://… or ntp://…, comma-separated): the estate's root of time".to_string();
}
}
front.fixtures.clear();
let mut requires = open.requires.clone();
for k in open.options.iter().flat_map(|o| o.kernel_requires.iter()) {
let known = requires
.iter()
.any(|r| matches!(&r.what, Requirement::Kconfig(have) if have.symbol == k.symbol));
if !known {
requires.push(EdgeRequirement {
edge: "kernel".to_string(),
what: Requirement::Kconfig(k.clone()),
});
}
}
front.options.clear();
// The one option the front DOES carry: DEV-NARRATE (default off), so vm1's
// early boot reaches the installer on a dev medium like main's and twin's.
front.options.push(dev_narrate_option());
front.requires = requires;
front
}
/// The name of the METERED front medium and of its `[[build.installer]]` row: vm1 of a
/// SELLING site (`gunnar-twin-one`: gunnar.rs, test2.gunnar.rs). The same medium as
/// [`FRONT_INSTALLER`] with the payload built `metered`: the console's selling half
/// (gunnar-ui-server `metered`), which sells THROUGH the ONE box with the estate bearer
/// the installer feeds. It embeds no monetize (ONE monetize, owner 2026-10-10 13:35Z):
/// no monetize-server, no shop, in any build.
///
/// **The rule** (owner 2026-10-11): OPEN = no money code at all (the self-hoster's build;
/// the open build does not CONTAIN it); METERED = sells, via a ONE box only. A site's
/// front and boxes are open together or metered together.
pub const FRONT_METERED_INSTALLER: &str = "gunnar-front-installer-metered";
/// Its volume id, its own: a different binary is a different medium.
pub const FRONT_METERED_VOLUME_ID: &str = "GUNNAR_FRONT_INSTALL_METERED";
/// The cargo variant the paid front medium lays down.
pub const FRONT_METERED_PAYLOAD_VARIANT: &str = "gunnar-front-metered";
/// **The paid front medium** — [`front_installer`] with the metered payload.
/// Nothing else differs: no monetize key parameter (the front MINTS monetize's
/// key and offers it; the boxes take it from the offer), the same entry, seed,
/// kernel and machine.
fn front_metered_installer(front: &InstallerBuild) -> InstallerBuild {
let mut m = front.clone();
m.name = FRONT_METERED_INSTALLER.to_string();
m.variant = FRONT_METERED_PAYLOAD_VARIANT.to_string();
m.volume_id = FRONT_METERED_VOLUME_ID.to_string();
// RA1-14: the CLI hole's reach is NOT the medium's: every sold estate's front is laid from
// this medium too. gunnar.rs's front is fed `front_cli_hole = public` by the ONE box (its
// site declaration's `cli_hole_public`); every other front keeps `own-network`.
m
}
/// The cargo variant the metered medium composes — the build point that
/// actually links `monetize-embed` and the entitlement gate.
pub const METERED_VARIANT: &str = "gunnar-server-metered";
/// The name of the metered AWS Nitro medium and of its `[[build.installer]]`
/// row — [`metered_twin`] of [`AWS_INSTALLER`].
pub const METERED_AWS_INSTALLER: &str = "gunnar-installer-metered-aws";
/// Its volume id, its own for [`GCE_VOLUME_ID`]'s reason: a different binary
/// on a different kernel is a different medium, and `installtest` resolves a
/// medium to its cell by the volume id first.
pub const METERED_AWS_VOLUME_ID: &str = "GUNNAR_INSTALL_METERED_AWS";
/// The name of the metered Azure Gen2 medium and of its `[[build.installer]]`
/// row — [`metered_twin`] of [`AZURE_INSTALLER`].
pub const METERED_AZURE_INSTALLER: &str = "gunnar-installer-metered-azure";
/// Its volume id — see [`METERED_AWS_VOLUME_ID`].
pub const METERED_AZURE_VOLUME_ID: &str = "GUNNAR_INSTALL_METERED_AZURE";
/// The `korp.args=` token of the sealed installer — door A.
pub const SEALED_KORP_ARGS: &str = "korp.args=serve,--no-grpc,--admin-key,@/data/admin.pub";
/// **The private interface the control plane binds on.**
///
/// `eth1` and not an address, because there is no address to write down: the
/// utility NIC's address is DHCP-learned (UpCloud gave THIS appliance
/// `203.0.113.152`; the next box gets another, and so does this one after a
/// re-lease). A medium is mastered once and installed many times, so the only
/// value it can honestly carry is the one that is the same on every box — the
/// interface. `gunnar_common::bindspec::BindSpec` resolves it at boot.
///
/// `eth1` is the second NIC in PCI order, which on UpCloud is the utility
/// interface (public first, utility second — measured on the live appliance:
/// index 1 public `198.51.100.55`, index 2 utility `203.0.113.152`).
pub const PRIVATE_INTERFACE: &str = "eth1";
/// The `korp.args=` token of the keyless installer — door B.
///
/// `--grpc-bind` is load-bearing twice over. Without it the listener sits on
/// `127.0.0.1:50051`, which on a box with no shell and no port forwarding is
/// nobody's door (`reachability::binds_control_plane_offbox`). With
/// `0.0.0.0:50051` — what it said until 2026-08-31 — the listener is on the
/// PUBLIC interface too, and this variant declares no `--admin-key`, so
/// `Bootstrap.Enroll` stands open to the whole internet until the first
/// successful enrolment. Whoever reaches it first becomes gunnar-admin. That
/// window is the reason to name an interface, and it is enough of one.
///
/// ⚠ **This doc comment used to give a second reason that is FALSE, and the
/// correction matters more than the line it replaces.** It said v1 gRPC
/// authentication is off by decision and that `repo list`, `user list` and
/// `repo create` had answered an uncredentialled caller over the open
/// internet on 2026-08-31. RE-MEASURED against the same live appliance
/// (`198.51.100.55:50051`, `v1.1.4`, bootstrap closed) on 2026-08-31 with a
/// raw gRPC request carrying no metadata at all: `Repos/List`,
/// `Repos/ListNamespaces`, `Access/ListPrincipals` and `Admin/Status` every one
/// answered `grpc-status: 16` (UNAUTHENTICATED) with a zero-byte body, and a
/// freshly minted, unregistered ssh key was refused with "the credential did not
/// verify against any registered key". The gate was holding.
///
/// What produced the original reading: `gunnar control` signs with
/// `~/.ssh/id_ed25519` when `-i` is not given (`gunnar-cli/src/control.rs`,
/// `candidate_identities`), and the probing host's default key is enrolled on
/// that box as gunnar-admin. The probe was authenticated as that enrolled
/// principal and read as anonymous.
/// `AUTHENTICATION_ENABLED` is `true`, `ServerConfig::default().auth` is
/// `AuthMode::Required`, and all nine non-Bootstrap services are mounted
/// `with_interceptor` — see `gunnar-grpc/tests/wire.rs`, which asserts that over
/// a real socket.
///
/// Naming the interface is what makes the third option — neither loopback nor
/// everywhere — expressible at all.
///
/// **By ROLE since the lean estate** (owner 2026-10-23, A19 row 17): `utility:50051`, not
/// `eth1:50051`. Every box boots this one medium, and a box laid behind its front has its
/// utility NIC alone, as `eth0`; gunnar-server makes the role the box's NIC at boot (the last
/// NIC [`CLOUD_GUNNAR_NET`] declares that the box has), and from there it is the interface
/// spelling as ever.
pub const OPEN_KORP_ARGS: &str = "korp.args=serve,--grpc-bind,utility:50051";
/// `korp.env.GUNNAR_NET` for a cloud appliance: BOTH interfaces by DHCP.
///
/// One entry is what every medium said before, and it left the utility NIC
/// dark — measured 2026-08-31, `203.0.113.152` answered no ping from a host on
/// the same /22 because nothing had ever configured it. The first entry owns
/// the default route; `eth1` gets an address and no route, which is right for
/// a network that does not reach the internet.
pub const CLOUD_GUNNAR_NET: &str = "eth0:dhcp,eth1:dhcp";
/// The name of the keyless installer variant and of its `[[build.installer]]` row.
pub const OPEN_INSTALLER: &str = "gunnar-installer-open";
// ─────────────────────────────────────────────────────────────────────────────
// THE ROLE IS NOT THE MEDIUM'S — ONE MEDIUM, THE ROLE OVER THE WIRE
// ─────────────────────────────────────────────────────────────────────────────
//
// Until 2026-09-17 there was a ninth `[[build.installer]]` row, `gunnar-twin`,
// and a ninth pointer variant beside it. It was `open_twin`'s row cloned with
// four lines changed: the volume id, `--role twin` welded into `korp.args`, the
// two `ZNIPPY_*` env tokens and `korp.data.disks=vdb`. Same payload kind, same
// kernel donor, same target disk, same fixtures, same options — and, being a
// clone of the KEYLESS row, `variant = "gunnar-server"`.
//
// **That last word is the defect this deletion exists to close.** gunnar.rs's
// appliance is `gunnar-installer-metered`, whose `variant` is
// `gunnar-server-metered` — the build point that links `monetize-embed` and the
// entitlement gate. Its twin was mastered from `gunnar-twin`, so the twin ran
// `gunnar-server`, and `tests/metered_symbols.rs` GUARANTEES that binary
// contains no entitlement symbol at all. The twin exists to BECOME the primary:
// promoted after a restore, gunnar.rs would have been served by a binary with no
// plan enforcement in it, every suspended tenant live again, and nothing on the
// box able to say why — because nothing on the box would have been wrong. The
// medium was. It could only be found at a takeover, which is the one moment
// nobody is reading a symbol table.
//
// A twin is not a different product. It is the SAME appliance told what it is,
// and the cmdline already carries that kind of fact: the growbench's `SX` rung
// planted `korp.growbench.probe=sx` in the ESP's `korp/cmdline` and korp-boot
// merged it into what the kernel saw, and an install already takes `reinstall`
// and `install.then` as parameters. So the role is one more parameter, and the
// medium that installs a twin is the medium that installs its primary — by
// construction, not by a promise. `tests/twin_is_the_same_medium.rs` holds it.
//
// **Superseded 2026-09-22:** the role is not even a parameter any more — see
// the note below `open_twin`'s ruling. A box is told it is a twin over gRPC
// (`Bootstrap.BecomeTwin`), never on a cmdline.
//
// ## Does the role select a BINARY, or does one binary serve both roles?
//
// **One binary serves both roles, and the role parameter selects nothing at
// link time.** `--role twin` is already a runtime argv switch on the same
// `gunnar-server` (`gunnar_common::cli::ServeRole`): there is no `twin` feature,
// no twin symbol family, nothing a twin links that a primary does not. The axis
// that DOES select a binary is metering, and it stays exactly where it was — on
// the medium's `variant` (`gunnar-server` vs `gunnar-server-metered`), which is
// what `metered_symbols` reads off the artifact.
//
// So "one medium" means one medium PER LINEAGE, not one medium in the world:
// the community row installs a community primary or a community twin, and the
// metered row installs a metered primary or a metered twin. The community build
// gains nothing — it links no `monetize_embed` before this change and none
// after, the open build still never mentions money, and `metered_symbols` is
// untouched and still the proof.
//
// The alternative — one medium carrying BOTH binaries, the role parameter
// picking which to install — is rejected, and it is worth writing down why: it
// would put the money code on the community medium. "Contains none of the
// entitlement families" is a guarantee about what is IN the artifact, and an
// artifact that ships the metered binary beside the community one contains it
// however carefully the installer declines to lay it down. That guarantee is
// load-bearing and is not widened here.
//
// The cost of the choice made instead: an operator mastering a twin must pick
// the same lineage they picked for the primary. That is the same single choice
// they already make, one row rather than two, and picking WRONG is no longer
// expressible as a build — there is no twin row to pick.
// **What a box IS is not the medium's to say** (owner, 2026-09-21/22). From
// 2026-09-17 to 2026-09-22 this file declared a `role` parameter
// (`korp.env.GUNNAR_ROLE=twin`), a `data_disks` parameter (`korp.data.disks=`,
// measured necessary on gunnar.rs's 10 GB + 20 GB twin, `No bootable device`),
// `InstallRole` and `realize` to plant them. All of it is gone:
//
// * the ROLE is conferred over the wire — `cargo xtask elect twin --apply`
// (private-gunnar-ops) records which box is the weakest and calls
// `Bootstrap.BecomeTwin` on it; nothing on any cmdline says "twin";
// * the DATA DISKS are korp-installer's own election (≥ 0.1.15: the system on
// the disk the firmware boots, `/data` on the rest), so no machine's shape is
// baked into — or handed to — the one medium;
// * the twin's memory bound was a cmdline token list and is code since
// znippy-plugin-git 0.1.5 (`Engine::hold_twin_memory_bound`, from
// `ServerStores::mark_twin`) — the measurements are kept there and in
// `gunnar-server/tests/twin_snapshot_memory.rs`.
//
// `tests/twin_is_the_same_medium.rs` pins that no installer row declares
// either parameter and that no row's cmdline carries either token.
/// **The keyless twin of a sealed installer row.** Same payload, same donor,
/// same options and fixtures; the argv takes door B and the `admin_key`
/// parameter is GONE (not `required = false` — an optional key nothing reads is
/// the v1.1.3 defect in a new place). Everything else is a clone, on purpose:
/// a second hand-written row would be a second place for the medium to drift.
fn open_twin(sealed: &InstallerBuild) -> InstallerBuild {
let mut open = sealed.clone();
open.name = OPEN_INSTALLER.to_string();
open.boot_params = sealed
.boot_params
.iter()
.map(|p| {
if p == SEALED_KORP_ARGS {
OPEN_KORP_ARGS.to_string()
} else {
p.clone()
}
})
.collect();
open.params.retain(|p| p.name != "admin_key");
open
}
fn sealed_installer() -> InstallerBuild {
InstallerBuild {
name: "gunnar-installer".to_string(),
// The same atom the live ISO chains, so the installed disk carries the
// binary the regression baseline already boots — one artifact, two
// delivery shapes.
variant: "gunnar-server".to_string(),
// EMPTY, exactly as korp's variant leaves it: the PID-1 that installs is
// `korp-installer`, which lives in `edda` and is `publish = false`.
// gunnar cannot declare a cargo variant for a binary in another
// repository, and a variant that NAMED one would fail
// `validate_installer`. The day korp-installer is reachable, this gets
// its name — in both files at once.
installer_variant: String::new(),
volume_id: "GUNNAR_INSTALL".to_string(),
// gunnar's app `.znippy` rides the installer ISO itself: airgap and
// offline, the shape the appliance is sold in.
payload: PayloadKind::EmbeddedIso,
// No extra payload entries: gunnar's app is the whole archive (edda 0.2.6
// added the table for a product that stages more, Holger's scan sandbox).
payload_entries: std::collections::BTreeMap::new(),
// LEAN — korp PID-1 only, zero container 3pp. gunnar IS the workload;
// there is nothing for a container runtime to run.
container_backend: None,
target_disk_gib: 64,
gpu: false,
boot_params: [
// Order is load-bearing, same as the live variant: tty0 first so a KVM
// graphics console gets the printk stream, ttyS0 LAST so it stays
// /dev/console — the serial line the installer's `INSTALLER:` lines
// and gunnar's identity line are read off.
"console=tty0",
"console=ttyS0,115200",
"panic=10",
// `korp.env.<KEY>` / `korp.args=`, NEVER the bare forms. MEASURED
// 2026-08-15 (`installtest/tests/installer_drive.rs`, first drive
// of this variant): `korp_installer::install::carry_site_tokens`
// forwards a token from the INSTALLER's cmdline to the INSTALLED
// one only when its key starts with `korp.`, so bare
// `GUNNAR_APPLIANCE=1` and a `-- serve --no-grpc` tail died with
// the installer's own boot and the appliance came up ungated,
// exec'ing a bare `gunnar`. The toml variant records the whole story;
// this mirror must say the same bytes or `tests/toml_parity.rs`
// goes red — which is exactly what it is for.
//
// On this shape the data root is P3, a real filesystem the
// installer formatted — not the RAM the live ISO's /data is.
"korp.env.GUNNAR_DATA_ROOT=/data",
// BOTH NICs. See `CLOUD_GUNNAR_NET`: the utility interface an
// installed appliance is reached on privately does not exist until
// something brings it up, and until 2026-08-31 nothing did.
// The same string the `dhcp` option BAKES (`CLOUD_GUNNAR_NET`).
// The bake is what actually reaches the medium; this row is what a
// reader of the declaration sees, and the two disagreeing is how a
// wrong value shipped while the right one was written down.
"korp.env.GUNNAR_NET=eth0:dhcp,eth1:dhcp",
// THE GATE. Everything above is configuration; this line is what
// turns gunnar into an appliance with an identity of its own
// (`gunnar_server::appliance::GATE_ENV`). korp-start decodes it
// into the exec'd app's environment on the installed disk. The
// PRIVATE key is not here and never can be — /proc/cmdline is
// world readable; the ceremony mints it onto P3 at 0600 and
// prints only the public half.
"korp.env.GUNNAR_APPLIANCE=1",
// The app's argv tail, comma-joined; korp-start splits it after
// argv[0]. Without it PID 1 execs a bare `gunnar`, which prints
// its help and exits — "boots but does not serve".
// The bootstrap admin — see `crate::reachability` and the long
// paragraph on this token in `.nornir/build-variants.toml`. Without
// it this installer lays down a git server nobody can ever enrol
// against (measured under KVM 2026-08-25); WITH `--no-grpc` dropped
// instead, Bootstrap.Enroll would be open to any caller with no
// credential on a medium mailed to a customer.
SEALED_KORP_ARGS,
"korp.entry=gunnar",
"korp.znippy=/app.znippy",
]
.iter()
.map(|s| (*s).to_string())
.collect(),
// The 0.1.7 spellings (`env = {…}` / `cmd = [...]`) render exactly the
// `korp.env.*` / `korp.args=` tokens `boot_params` above carries — the
// toml keeps the proven boot_params spelling for now and both files say
// so in the same breath, so these stay empty rather than declaring the
// same tokens twice through two channels.
cmd: Vec::new(),
env: std::collections::BTreeMap::new(),
// The ONE per-site param: the ceremony's hub.conf (public data), landing
// on the medium and — through the composition — on P3 as wg/hub.conf.
// The toml also declares a FIXTURE for it (`[build.installer.fixtures]
// hub_conf = "wg-hub"`, supplied as `--param hub_conf=@fixture`); that
// table is not expressible under this crate's 0.1.7 pin (the field does
// not exist there) and the 0.1.7 loader drops it benignly, so the parity
// compare cannot see it — `installtest/tests/installer_drive.rs` is what
// holds the fixture honest. `applied_params` is the ATTESTATION side
// (what a mastering run filled in), never a declaration.
//
// `static_ip` and `gateway` joined 2026-08-22 as the DHCP option's
// `params_when_off`. The OPTION rows that make them conditional are in
// the toml and NOT here, for the same reason the fixture is not: 0.1.7
// has no `options` field, `InstallerBuild` is not `deny_unknown_fields`,
// so the loader drops the block benignly and a mirror that declared it
// would compare against something the loader never produced. The PARAMS
// are 0.1.7 vocabulary, so these three DO compare — and the order is
// load-bearing, `params` being a Vec.
//
// `kind: Text` on all three: `ParamKind::Ipv4`/`Ipv4Cidr` do not exist
// in 0.1.7 either. The sharp shapes are declared in the option's
// `param_kinds` table, which no reader in this workspace parses.
params: vec![
nornir_build_thing::params::ParamSpec {
lands: nornir_build_thing::params::Landing::Data,
name: "hub_conf".to_string(),
label: "Hub declaration (the ceremony hub.conf, public data)".to_string(),
kind: nornir_build_thing::params::ParamKind::Text,
required: false,
boot_param: String::new(),
staged_file: "hub.conf".to_string(),
},
// THE one parameter this variant cannot be built without, and the only
// required one that is not conditional on an option. A STAGED FILE
// and not a `boot_param`: an OpenSSH public key line contains
// spaces, and `korp.env.*` rides a whitespace-split kernel cmdline
// with no escapes — a boot_param landing would truncate the key at
// "ssh-ed25519", which `InstallerBuild::validate`'s own encoding law
// refuses by name. It is not a secret (a public key is published to
// every host its holder reaches), and `names_a_secret` agrees.
nornir_build_thing::params::ParamSpec {
lands: nornir_build_thing::params::Landing::Data,
name: "admin_key".to_string(),
label: "Bootstrap-admin (kundens OpenSSH-publika nyckel)".to_string(),
kind: nornir_build_thing::params::ParamKind::Text,
required: true,
boot_param: String::new(),
staged_file: "admin.pub".to_string(),
},
nornir_build_thing::params::ParamSpec {
lands: nornir_build_thing::params::Landing::Data,
name: "static_ip".to_string(),
label: "Statisk adress med prefix (t.ex. 10.0.2.15/24)".to_string(),
kind: nornir_build_thing::params::ParamKind::Text,
required: true,
boot_param: "korp.env.GUNNAR_IP".to_string(),
staged_file: String::new(),
},
nornir_build_thing::params::ParamSpec {
lands: nornir_build_thing::params::Landing::Data,
name: "gateway".to_string(),
label: "Standard-gateway (t.ex. 10.0.2.2)".to_string(),
kind: nornir_build_thing::params::ParamKind::Text,
required: true,
boot_param: "korp.env.GUNNAR_GATEWAY".to_string(),
staged_file: String::new(),
},
// The debug channel for a console-less VM (UpCloud 2026-08-29: no
// UART, VGA only, flaky VNC, appliance went dark after the kernel).
// `--param log_udp=<ipv4>:<port>` → `korp.log_udp=` on the cmdline;
// korp-start spools its narration, `pid1::ship_boot_narration` ships
// it + dmesg the moment the lease arrives. Optional and inert when
// absent. `Endpoint` IS 0.1.7 vocabulary, unlike Ipv4/Ipv4Cidr.
nornir_build_thing::params::ParamSpec {
lands: nornir_build_thing::params::Landing::Data,
name: "log_udp".to_string(),
label: "Boot-narration UDP sink for a console-less VM (debug; a.b.c.d:port)".to_string(),
kind: nornir_build_thing::params::ParamKind::Endpoint,
required: false,
boot_param: "korp.log_udp".to_string(),
staged_file: String::new(),
},
// **The clock** (2026-09-14, `gunnar-clock`). Where "now" comes from is
// a SITE fact, so it is a parameter and never baked: an airgapped
// customer's appliance must not dial anybody by default, and
// gunnar.rs names its front — `peer://10.13.8.99:50123`, a signed
// exchange over the utility network. Unset = not configured = the
// report says so. `Text`, not `Endpoint`: the value is a source LIST
// (`peer://…`, `https://…`), parsed and refused by name in
// `estate_clock::config`.
nornir_build_thing::params::ParamSpec {
lands: nornir_build_thing::params::Landing::Data,
name: "clock".to_string(),
label: "Clock sources (peer://ip:port for the front's signed time; https:// fallback, comma-separated)".to_string(),
kind: nornir_build_thing::params::ParamKind::Text,
required: false,
boot_param: "korp.env.GUNNAR_CLOCK".to_string(),
staged_file: String::new(),
},
// The peer's ed25519 PUBLIC key, as the bare base64 blob of its
// OpenSSH line — the `ssh-ed25519 ` prefix has a space and a boot
// param must stay one token. Public data; the private half never
// leaves the front (`/etc/gunnar-clock/time_ed25519`).
nornir_build_thing::params::ParamSpec {
lands: nornir_build_thing::params::Landing::Data,
name: "clock_key".to_string(),
label: "Clock peer's ed25519 public key (base64 blob of the OpenSSH line)".to_string(),
kind: nornir_build_thing::params::ParamKind::Text,
required: false,
boot_param: "korp.env.GUNNAR_CLOCK_KEY".to_string(),
staged_file: String::new(),
},
// The data-survives rule (edda c09dd54, 2026-08-29: data on an
// installed disk must survive): a medium run on a disk it already laid UPGRADES
// it — /data (users, keys, grants, repos, the closed bootstrap) kept
// byte for byte, only the ESP + rootfs rewritten. `--param
// reinstall=wipe` → `korp.reinstall=wipe` is the ONE opt-out: lay
// it fresh. Optional, never baked on its own; the spelling is
// nornir-build-thing's `reinstall_param_spec()` (the registry pin
// predates it, so it is spelled here — `tests/toml_parity.rs` holds
// the row). korp-installer treats the key as plumbing: it never
// rides to the installed disk's cmdline.
nornir_build_thing::params::ParamSpec {
lands: nornir_build_thing::params::Landing::Data,
name: "reinstall".to_string(),
label: "Reinstall policy for a disk that already carries data: keep (upgrade, /data survives) | wipe (lay it fresh)".to_string(),
kind: nornir_build_thing::params::ParamKind::Text,
required: false,
boot_param: "korp.reinstall".to_string(),
staged_file: String::new(),
},
// **What the installer does after INSTALL-OK** (korp-installer
// `korp.install.then`, edda 7b238f2). `poweroff` is for an
// ORCHESTRATED medium: UpCloud cannot detach the installer from a
// running box, so a reboot with the cdrom first re-runs the install
// (MEASURED 2026-09-14, two hours of it). Powered off, the box reads
// `stopped`, the re-image tool detaches both media and boots the disk
// with no settle and no eject — the owner's bar is installer boot →
// banner in under 60 s (2026-09-15), and the fixed settle alone was
// 120 s. A SITE choice, so a parameter: absent = reboot, the medium
// a person boots by hand. Plumbing: it never rides to the installed
// disk's cmdline.
nornir_build_thing::params::ParamSpec {
lands: nornir_build_thing::params::Landing::Data,
name: "install_then".to_string(),
label: "After INSTALL-OK: reboot (a person at the box) | poweroff (a cloud re-image tool detaches the medium)".to_string(),
kind: nornir_build_thing::params::ParamKind::Text,
required: false,
boot_param: "korp.install.then".to_string(),
staged_file: String::new(),
},
// **The live-update switch** (owner 2026-10-12): `--param live_app=off`
// → `korp.live_update=off` — korp-start ignores a staged slot and the box refuses a
// live payload by name. A per-ESTATE choice (pgo `[estate] live_update`), part of
// the BASE: it is never flipped live. Absent = on.
nornir_build_thing::params::ParamSpec {
lands: nornir_build_thing::params::Landing::Data,
name: "live_app".to_string(),
label: "Live updates of the app: on (default) | off (every new app is a base update)".to_string(),
kind: nornir_build_thing::params::ParamKind::Text,
required: false,
boot_param: "korp.live_update".to_string(),
staged_file: String::new(),
},
// **No `role` and no `data_disks` parameter** (owner, 2026-09-21: "gunnar
// twin must be built EXACTLY like the main, it MUST use the SAME iso").
// The medium says nothing about what a box is: every box installs as a
// fresh appliance and a twin is made one over the wire
// (`Bootstrap.BecomeTwin`, after `cargo xtask elect twin`). Where `/data`
// goes is korp-installer's own election (≥ 0.1.15: the system on the disk
// the firmware boots, `/data` on the rest). Both parameters existed
// 2026-09-17 → 2026-09-22; `tests/twin_is_the_same_medium.rs` pins that
// they are gone.
],
applied_params: Vec::new(),
// gunnar's mark, not korp's raven (75a32cc). Under the 0.1.5 pin the
// toml's key was silently dropped by the loader; since the 0.1.7 bump
// it LOADS, so the declared logo is finally a compared fact here.
boot_logo: Some("assets/gunnar.webp".to_string()),
// The kernel DONOR — the mirror of `[build.installer.kernel_donor]`, declared
// here the day the registry pin advanced to 0.1.9 (the first release whose
// `InstallerBuild` carries the field); `tests/toml_parity.rs` holds the two
// spellings equal, and the raw-file guard that stood in for this line while
// the 0.1.7 loader was blind to the table folded into that assertion.
kernel_donor: Some(nornir_build_thing::installer::KernelDonor {
repo: "tunnr".to_string(),
kernel: "tunnr-kernel".to_string(),
}),
// 0.1.10: the generalised edges, the root `preset` bundles and the
// applied one. gunnar declares NONE of them — the kernel edge is the
// `kernel_donor` row above, and a preset bundle has no honest product
// use here yet (tunnr's `preset = "nuked"` is the donor's). Named,
// because this mirror names every field on purpose.
edges: BTreeMap::new(),
presets: Default::default(),
applied_preset: None,
// `[build.installer.fixtures] hub_conf = "wg-hub"` — the same story: a table
// the 0.1.7 loader ignored benignly, visible to 0.1.9, mirrored now.
fixtures: [(
"hub_conf".to_string(),
nornir_build_thing::params::FixtureKind::WgHub,
)]
.into_iter()
.collect(),
// The two `[[build.installer.options]]` rows — the site-level choices the
// 0.1.7 loader could not see (`installtest/tests/installer_options.rs` read
// them through a path dep meanwhile). Visible to 0.1.9, mirrored now, so the
// parity assertion holds them like every other field. Each row is a
// cargo feature the installer's app build carries, the params it needs
// when off, the symbols that must be ABSENT when off, and the boot-param
// bake the installer writes for it.
options: vec![
nornir_build_thing::options::OptionSpec {
name: "dhcp".to_string(),
label: "DHCP (eth0 + eth1)".to_string(),
default: true,
feature: "dhcp-client".to_string(),
params_when_on: Vec::new(),
params_when_off: vec!["static_ip".to_string(), "gateway".to_string()],
param_kinds: [
(
"gateway".to_string(),
nornir_build_thing::params::ParamKind::Ipv4,
),
(
"static_ip".to_string(),
nornir_build_thing::params::ParamKind::Ipv4Cidr,
),
]
.into_iter()
.collect(),
forbid_symbols: vec!["appliance_pid1::dhcp".to_string()],
// **The bake WINS over `boot_params`.** Measured 2026-08-31:
// with `--opt dhcp=on` the composer printed
// `korp.env.GUNNAR_NET=eth0:dhcp` onto the medium even though
// `boot_params` above declared both interfaces — an option
// that bakes a boot param is the writer of that param, and the
// declaration beside it is what the composer replaces. Both
// say the same thing now, which is the only arrangement in
// which reading either one is safe.
//
// **`eth1` rides the ON arm only.** It used to ride both
// (`eth0:{static_ip}:{gateway},eth1:dhcp`), and that OFF bake was
// a medium that stops at boot: unticking DHCP SUBTRACTS
// `dhcp-client`, and `NetConfig::parse_list` in that binary
// refuses `eth1:dhcp` by name (no client to run). Measured red in
// `installtest/tests/installer_options.rs` 2026-09-15. A static
// site names its public address and nothing else; a private NIC
// on such a site wants its own static param, not a lease.
bakes: Some(nornir_build_thing::options::OptionBake {
boot_param: "korp.env.GUNNAR_NET".to_string(),
when_on: CLOUD_GUNNAR_NET.to_string(),
when_off: "eth0:{static_ip}:{gateway}".to_string(),
}),
kernel_requires: dhcp_kernel_requires(),
requires: Vec::new(),
},
nornir_build_thing::options::OptionSpec {
name: "wireguard".to_string(),
label: "WireGuard-klient".to_string(),
default: true,
feature: "wg-client".to_string(),
params_when_on: vec!["hub_conf".to_string()],
params_when_off: Vec::new(),
param_kinds: std::collections::BTreeMap::new(),
forbid_symbols: vec!["wg_appliance_core".to_string(), "boringtun".to_string()],
bakes: None,
kernel_requires: wireguard_kernel_requires(),
requires: Vec::new(),
},
],
// **The MACHINE this medium is built for** (`[build.installer.target]`).
// Spelled at its DEFAULT shape on purpose — `Target::is_default()` stays
// true, so nothing built today moves a byte — and the row exists to make
// the four facts READABLE that were declared nowhere and cost a blind
// attempt against UpCloud each (2026-08-28/29): SeaBIOS only, the ISO
// attached as an IDE cdrom, no UART (VGA is the only console), two NICs.
//
// It reached this file on 2026-08-30 and not before, and the
// reason is the pin, not the design: `InstallerBuild` on the published
// `nornir-build-thing 0.1.10` had no `target` field, so the toml's table
// was dropped benignly here and read only by the composing forge.
// ⚠ THIS GROUP IS EXACTLY `Target::default()`, AND THEREFORE EMITS
// NOTHING. `Target::is_default` is the `skip_serializing_if`, so a
// declaration that happens to equal the default is invisible to every
// READER of the toml — the kernel-requirement solver included.
//
// Measured 2026-08-31: adding `nics = 2` here made the group non-default,
// the group then emitted for the first time, and `--build kernel` was
// refused for BOTH installer rows — `CONFIG_ATA must be y for
// target.buses (ide-cdrom) but the nuked preset says n`. The conflict
// was always in the code; it had simply never been visible to the
// solver. That is NOW-AND-NEXT #4/#7's donor-preset decision and it is
// not this file's to make, so the group stays default and the two-NIC
// requirement is expressed where it is actually derivable — from the
// medium's own `korp.env.GUNNAR_NET`, which names the interfaces
// (`installtest/src/firmware.rs::declared_interface_count`).
//
// ── AND THAT IS WHY `preset` IS `None` HERE (2026-09-08) ────────────
//
// The composer asks for a machine on this row, by name, on every real
// build: *"installer 'gunnar-installer-metered' lays a medium that
// boots off an imported disk (medium_boot = "hybrid") but names no
// machine (target.preset) … Name the preset."* It is a fair complaint —
// `upcloud`, `hetzner-cloud`, `ovh-public-cloud` and
// `scaleway-instance` really do share this exact device set and split
// two-and-two on the import container, so nothing derivable from the
// SHAPE can answer it — and the answer is still not `upcloud`, for a
// reason that is now SMALLER and entirely different.
//
// ⚠ THE KERNEL HALF OF THIS BLOCK IS GONE. It stood for eleven days and
// it was a base defect the whole time, fixed upstream on 2026-09-07
// (edda `84a281a` + `1f86a1f`): `MediumBring::IsoCdrom` no longer
// derives `buses ∋ ide-cdrom`, because how a PLATFORM hands a medium
// over is not which driver the KERNEL must carry — this medium rides
// TWICE, as the IDE cdrom SeaBIOS boots and as the virtio disk Linux
// and korp-installer read the ISO-9660 payload off
// (`deploy/upcloud/src/machine.rs`) — and `target.uarts` (not
// `target.consoles`) now owns `CONFIG_SERIAL_8250`, because a COM port
// is hardware and UpCloud HAS ttyS1/ttyS2 at 0x2f8/0x3e8; only ttyS0 is
// absent. So `CONFIG_ATA` never arrives and the 8250 never flips.
// MEASURED, not assumed — `tests/machine_named.rs` re-measures both:
// the `nuked` donor now ACCEPTS `preset = "upcloud"`, and the merged
// fragment gains exactly `CONFIG_CPU_SUP_AMD=y` and
// `CONFIG_VIRTIO_PCI_LEGACY=y`, both of which the shipped
// `.kernel-out/tunnr-kernel/kernel.config` already carries `=y` as
// `kconfig-derived` rows. That test REPRODUCES the live pin's
// `fragment_sha256` (`7dc24476…`) before it measures the delta, so this
// paragraph is a statement about the artifact and not about the model:
// the bzImage does not move.
//
// ── WHAT IS LEFT IS ONE FIELD, AND IT IS NOT A KERNEL FIELD ─────────
//
// `preset` is the ONE writer of the group, so naming it replaces every
// field above with the machine's own derivation — `firmware` included.
// `upcloud` is `seabios`, `InstallerBuild::installer_iso` turns that
// into `IsoFormat::Bios`, and `korp-installer::isocompose` lays the EFI
// El-Torito section only when `firmware.lays_uefi()`. Naming the
// machine therefore does not narrow a kernel; it DELETES A SECTION OF
// THE MEDIUM. The legacy half survives either way (`both` is a superset
// of `seabios`), so the trade is a pure loss: nothing is bought, and
// what is spent is every UEFI rig this one medium is proven on —
// `bench/runs/mother-2026-09-05T02-02` booted the open twin under OVMF
// 4M three days ago, `installtest … --target qemu-local` is UEFI/OVMF,
// and the SEALED row is the medium that gets MAILED, to UEFI clouds
// included. the reasoning archive already ruled this
// field once, in one sentence: *"`both` keeps the OVMF path for the
// rig."*
//
// That is the same category error as the two the base just fixed, one
// field over: a machine's firmware is what it CAN start, a medium's
// sections are what it CARRIES, and a medium is allowed to carry more
// than any one machine needs. It is reported upward rather than worked
// around here, because a `target` group that could keep `firmware` while
// naming a machine is a base change, not a consumer's `..Default`.
//
// ── WHICH OF THE FOUR IT WOULD BE, AND WHY NOT THE OTHER THREE ─────
//
// The composer's sentence lists four candidates because they share one
// device set, and that is a statement about the SHAPE. The identity is
// not in doubt for a moment: `private-gunnar-ops/site.toml` names the
// appliance this medium installs — `server_uuid =
// "009f96c9-3dca-4f0d-a122-c7a06ecaabeb"`, `verify_addr =
// "95.111.206.91:2222"`, `zone = "se-sto1"` — an UpCloud Cloud Server
// in Stockholm, which is the very box `presets::UPCLOUD`'s own
// `measured` evidence was read off (2026-08-28/29, se-sto1: SeaBIOS
// only, ISO as IDE cdrom, no ttyS0, VNC the only console). The other
// three are not near-misses; two of them cannot take this medium at
// all:
//
// * `ovh-public-cloud` and `scaleway-instance` are
// `MediumBring::ImportQcow2` — no ISO door, so what they want is an
// imported disk and not the `.iso` this row lays. That is the v1.3.0
// defect (media mailed to clouds with no door for one), not a choice;
// * `hetzner-cloud` IS an ISO-attach machine and is still wrong: one
// NIC where this appliance has two, and `measured: None` — gunnar has
// never booted on it. Naming an unbooted machine would put a guess in
// the field whose whole purpose is to stop one.
//
// So the machine is `upcloud`, the question was never which of the
// four, and this row would name it TODAY if naming it were free.
//
// ── IT IS NOT FREE — RE-MEASURED 2026-09-13, NOT INHERITED ──────────
//
// `preset = "upcloud"` put on this row with nothing else changed, on
// `nornir-build-thing 0.1.14` (and 0.1.15/0.1.16 do not touch
// `target.rs`, so the base has not moved since the ruling above):
//
// `InstallerBuild::installer_iso().format` Uefi → Bios
// merged Kconfig `fragment_sha256` 7dc24476… → 25530934…
//
// The first is the EFI El-Torito section leaving the medium, which is
// the ruling above, still true. The second is new to this measurement
// and is a smaller cost honestly stated: the two lines the machine adds
// are already `=y` in the shipped config, so the bzImage rebuilds to
// the same bytes — but the fragment is what the pin keys on, so the
// live appliance's kernel pin goes stale and the kernel is rebuilt to
// produce a file it already has. Neither half buys the medium anything.
//
// So this row stays silent BY DECISION — `None` costs a sentence in a
// log, and the alternative costs an ISO that will not boot where the
// last one did. `aws-nitro` and `azure-gen2` ARE named on their rows
// (below), and the difference is no longer `ide-cdrom`: it is that both
// are UEFI machines, so naming them keeps `lays_uefi()` and their media
// keep the section this one would lose. That is the whole difference,
// and `tests/machine_named.rs` asserts it on the named rows too — a
// machine whose firmware lays no EFI section may not be named here
// until the base separates the two facts.
target: Target { firmware: Firmware::Both, consoles: vec![Console::Vga, Console::Serial], buses: vec![Bus::IdeCdrom, Bus::VirtioBlk], nic: Nic::Virtio, ..Target::default() },
// **What `/data` is laid with** (`[build.installer.data]`).
// `xfs` over FAT32 because a 100 k-user gunnar needs parallel
// writers, files past 4 GiB and a journal instead of `dirsync`; `uring`
// because the deepest queue measured 1.14× per accepted op at 64 clients
// with durability held across three SIGKILL power cuts. Typed
// consequences: `CONFIG_XFS_FS` + CRC32C + `CONFIG_IO_URING` on the
// donor kernel under the features `data.fs` / `data.io`, and
// `korp.data.fs=xfs` / `korp.data.io=uring` baked on the cmdline.
// Same 0.1.10 gap as `target` above, closed by the same pin.
data: Data { fs: DataFs::Xfs, io: DataIo::Uring },
// **THE MEDIUM'S OWN BOOT SHAPE** (`medium_boot`, 2026-08-30).
//
// `hybrid` = LBA 0 of the `.iso` carries a real `55 AA` boot sector that
// chainloads a disk-flavoured copy of the same `korp-boot-bios` stage.
// Without it the medium is a pure El Torito ISO, and a legacy BIOS that
// is handed the identical bytes as a DISK reads LBA 0, finds nothing and
// parks. MEASURED on the 38 680 576 B medium `621808cb…` — `LBA
// 0[510..512] = 00 00`, the whole first sector zero — which is why
// `gunnar-installtest installer --target ovh-public-cloud` produced ZERO
// bytes of serial and burned its whole 420 s budget.
//
// That is not one cloud's quirk. **Every platform that IMPORTS a disk
// image instead of attaching a cdrom** was unreachable by this product:
// OVH and Scaleway (glance/qcow2), GCE (raw disk.raw in a tar.gz), AWS
// (VM Import), Azure (VHD) — the roster's own `MediumBring` word says so
// for 9 of 13 presets. A USB stick is the same shape.
//
// It costs the medium NOTHING: the boot sector and the second stage copy
// both live in the ISO's reserved 32 KiB system area, which was all zeros
// before. `nornir_build_thing::iso_writer`'s
// `a_hybrid_medium_has_a_boot_sector_…_and_costs_no_bytes` asserts the two
// images are the same length and identical from the PVD on.
//
// It is declared on the MEDIUM and not in `target`, and that placement is
// load-bearing: every field of `target` is a kernel-config consequence
// (`Target::kconfig_needs`), and this one changes 512 bytes of an image
// and no kernel symbol at all. Declaring it on `target` would ALSO have
// made `Target::is_default()` false, which lights up the whole group's
// closure — including `ide-cdrom → CONFIG_ATA`, which the donor's `nuked`
// preset vetoes by name.
medium_boot: MediumBoot::Hybrid,
// TWO row-level requirement blocks, neither owned by an option: PCI
// hot-plug, which the runtime data-set growth needs on every medium
// whatever is toggled (see [`hotplug_requires`]), and the paravirt wall
// clock, without which the appliance boots on the emulated CMOS and was
// measured two hours fast on 2026-09-20 (see [`clock_requires`]).
// Everything else it needs of the donor kernel is named on the OPTION
// that needs it (`kernel_requires` above) or on the group that owns the
// fact (`target`, `data`), and the appliance-boot contract is implied by
// `kernel_donor`. Every twin (open, gce, aws, azure, metered ×3, twin) is
// cloned from this row and carries both.
requires: hotplug_requires()
.into_iter()
.chain(clock_requires())
.collect(),
applied_options: Vec::new(),
}
}
impl BuildThing for GunnarBuildThing {
fn manifest(&self) -> Manifest {
Manifest {
abi: ABI_VERSION,
repo: "gunnar".to_string(),
source_crate: KRATE.to_string(),
summary: "gunnar's six atoms — the service, the git binary, the thin \
gRPC client, the (planned) UI, the solo build and the front \
host's clock daemon — across the five delivery columns."
.to_string(),
}
}
/// The declared build space, assembled from [`VARIANTS`].
///
/// This is the whole migration in one function: the same
/// [`BuildVariantSet`] every downstream consumer already understands, built
/// from Rust instead of parsed from a file.
fn variants(&self) -> BuildVariantSet {
BuildVariantSet {
offers: offers(),
allow_unbuilt: allow_unbuilt(),
allow_unbuilt_bins: allow_unbuilt_bins(),
variants: VARIANTS
.iter()
.map(crate::variants::Variant::to_variant)
.collect(),
containers: containers(),
isos: isos(),
installers: installers(),
toolchain: toolchain(),
..BuildVariantSet::default()
}
}
}