crates/Skidbladnir-build-thing/src/recipe.rs
//! **The recipe** — Skidbladnir's `impl BuildThing`, the ONE declaration of what
//! this repository builds. `.nornir/build-variants.toml` is emitted from
//! [`SkidbladnirBuildThing::variants`] (see [`crate::emit`]); nothing here reads
//! it.
//!
//! # What Skidbladnir declares, honestly
//!
//! Skíðblaðnir is the airgap / systemd+OCI-ops CLI the whole suite is installed
//! BY: it wraps each member as an installable unit under a dedicated system user.
//! It is a driver and an installer, not a composed suite service — it reaches
//! BMC/Vault/registries over their own wires when its backends are on and embeds
//! no suite sibling, so the shipped rows are plain `exe` targets and the fat/thin
//! axis does not apply to them.
//!
//! **Four shipped executables:**
//!
//! | variant | package · bin | features |
//! |---|---|---|
//! | `skidbladnir` ⭐ | `skidbladnir` · `skidbladnir` | `pki` — the lean, offline-safe default (pure-Rust ed25519); every external backend is a trait seam, off |
//! | `skidbladnir-znippy` | `skidbladnir` · `skidbladnir` | `pki`, `znippy` — the REAL znippy archive engine linked for seal/read/verify/unpack |
//! | `skidbladnir-probe` | `skidbladnir-probe` · `skidbladnir-probe` | the airgap install/verify probe |
//! | `skidbladnir-testsvc` | `skidbladnir-probe` · `skidbladnir-testsvc` | the minimal installable test service |
//!
//! # The seven `lib` rows are not deliveries, and the reason is a bug
//!
//! They exist so a feature that GATES A TEST is compiled by something automatic
//! instead of by a human remembering to type `--features`. MEASURED 2026-08-25
//! (`tests/gated_runs_are_declared_guard.rs`): with only the four `exe` rows
//! declared, **13 features gated tests that no variant enabled** — roughly 350
//! library `#[test]` fns plus four whole `tests/*.rs` targets that nothing in
//! this repo ever compiled.
//!
//! The cost of that had already been paid. `vmsuite::judge_active` graded
//! systemd's transitional `deactivating` as **stopped** — a `systemctl stop` →
//! `Sleep(1500)` → collect case passed on "it stopped" while a real service
//! drained for 6.4 s — and the 23 pure judge tests that would have caught it sit
//! behind `matrix`. Fixed in `4be0017`; it survived because nothing ran the file.
//!
//! The split is by **dependency stack**, not by taste: a row mixing the reqwest
//! backends with the ureq harbour clients would go red without saying which stack
//! broke. Each row is one closure that can fail on its own.
//!
//! What these rows prove and do not prove: a brokkr `variant` step is a cargo
//! BUILD, so the forge COMPILES these surfaces on every push, which kills
//! bit-rot. It does not EXECUTE the tests. Making the pure judges run on a plain
//! `cargo test` needs them out from behind `matrix` — an open follow-up since
//! 2026-08-03 (`.nornir/testmatrix-arms.json`, arm 3).
//!
//! # What the code says that the hand-written file could not
//!
//! The retired toml offered **17** features. The manifest declares **26**. The
//! nine it never mentioned were invisible to the manifest-direction rot gate, and
//! so was the probe crate's THIRD binary, `skidbladnir-scenario`. Both are
//! written down now — every unbuilt feature in [`allow_unbuilt`] with a reason,
//! the third bin in [`allow_unbuilt_bins`] — so the gate has something to ask
//! about rather than nothing.
use std::collections::BTreeMap;
use nornir_build_thing::cargo_container::CargoToolchain;
use nornir_build_thing::contract::Coordinate;
use nornir_build_thing::plugin::{BuildThing, Manifest, Role, ABI_VERSION};
use nornir_build_thing::variant::{
AllowEntry, ArtifactKind, BuildVariant, BuildVariantSet, Profile, ServiceShape,
};
/// The crate a receipt names as its recipe.
pub const KRATE: &str = "Skidbladnir-build-thing";
/// The content hash over this crate's sources, computed by `build.rs`.
pub const CONTENT_HASH: &str = env!("SKIDBLADNIR_BUILD_THING_CONTENT_HASH");
/// **The delivery this repo ships in.** `suites` is a LIST — a row may ship in
/// more than one flavour — but Skidbladnir has exactly one, and the point of
/// writing it down is that an EMPTY list is not a third flavour: it is a row
/// `cargo xtask build --suite ORIGINAL` cannot select, and until 2026-09-20 all
/// eleven rows here were exactly that (measured: 27 untagged rows across the
/// constellation, eleven of them this repo's — the single largest block). The
/// only flavour besides ORIGINAL that exists anywhere today is holger's MVP;
/// every other product declares ORIGINAL alone and TAGS it anyway, so the build
/// verb has one shape everywhere and holger needs no flag the others reject.
pub const SUITE: &str = "ORIGINAL";
/// Skidbladnir's build thing.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SkidbladnirBuildThing;
impl SkidbladnirBuildThing {
/// The coordinate a receipt names this recipe by: `krate:version:hash`.
#[must_use]
pub fn coordinate() -> Coordinate {
Coordinate {
krate: KRATE.to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
hash: Some(CONTENT_HASH.to_string()),
}
}
}
fn s(x: &str) -> String {
x.to_string()
}
/// A shipped executable.
fn exe(name: &str, package: &str, bin: &str, features: &[&str], is_default: bool) -> BuildVariant {
BuildVariant {
name: s(name),
package: Some(s(package)),
bin: Some(s(bin)),
kind: ArtifactKind::Exe,
role: Some(Role::Tool),
suites: vec![s(SUITE)],
features: features.iter().map(|f| s(f)).collect(),
profile: Profile::Release,
is_default,
..BuildVariant::default()
}
}
/// A row built from `probe/`, a DETACHED workspace (its own `[workspace]`, std only,
/// so it cross-compiles static musl). `cargo build -p skidbladnir-probe` in the root
/// workspace finds no such package, so the row names `manifest_dir = "probe"`
/// (ODEN-CI round 1, 2026-10-25: `rows:ORIGINAL` died there).
fn probe_exe(name: &str, bin: &str) -> BuildVariant {
BuildVariant {
manifest_dir: Some(s("probe")),
..exe(name, "skidbladnir-probe", bin, &[], false)
}
}
/// A **compile-only** library row: a feature that gates tests, compiled by
/// something automatic. `profile = dev` because nothing here is shipped, and
/// `linkage = not-applicable` because a library is linked by whoever consumes it.
fn gated_test_surface(name: &str, features: &[&str]) -> BuildVariant {
BuildVariant {
name: s(name),
package: Some(s("skidbladnir")),
kind: ArtifactKind::Lib,
role: Some(Role::Tool),
suites: vec![s(SUITE)],
shape: ServiceShape::NotApplicable,
features: features.iter().map(|f| s(f)).collect(),
profile: Profile::Dev,
..BuildVariant::default()
}
}
/// The rows, in declaration order: the four shipped, then the seven compile-only.
pub fn variants() -> Vec<BuildVariant> {
vec![
exe("skidbladnir", "skidbladnir", "skidbladnir", &["pki"], true),
exe("skidbladnir-znippy", "skidbladnir", "skidbladnir", &["pki", "znippy"], false),
probe_exe("skidbladnir-probe", "skidbladnir-probe"),
probe_exe("skidbladnir-testsvc", "skidbladnir-testsvc"),
// `matrix` (= kvm → machine): the KVM install matrix, the vm-systemd suite
// and the korp-provision suite — 59 library tests, 3 test targets and the
// `vm_systemd_suite` bench. Pulls draupnir's seed + backend-tunnr and libc.
gated_test_surface("skidbladnir-matrix", &["pki", "matrix"]),
// `promote`: the Njord-facing release/promotion API — 99 library tests plus
// the 17 in tests/promote_contract.rs. Pure serde_yaml, the cheapest row.
gated_test_surface("skidbladnir-promote", &["pki", "promote"]),
// `service-drive`: the drivable systemd seam — 7 library tests plus
// tests/service_kind_live.rs. Pulls the LEAN draupnir surface only.
gated_test_surface("skidbladnir-service-drive", &["pki", "service-drive"]),
// The reqwest-backed CONNECTED side: vault (PKI issue), openbao (structure
// turn-on) and download (resumable fetch, which also gates harvest.rs). One
// stack, one row — 27 library tests.
gated_test_surface("skidbladnir-connected", &["pki", "vault", "openbao", "download"]),
// The ureq-backed upstream-registry federation and both harbour clients —
// 44 library tests. A separate row from `connected` because it is a
// different TLS client stack entirely.
gated_test_surface(
"skidbladnir-federation",
&["pki", "federation", "upstream-aws", "upstream-azure"],
),
// The cluster targets: kubernetes (kube/k8s-openapi apply) and talos (the
// apid gRPC client) — 9 library tests over the tokio/tonic stack.
gated_test_surface("skidbladnir-cluster", &["pki", "kubernetes", "talos"]),
// `sockets`: socket activation / heartbeat — 61 library tests, the single
// largest dark surface the 2026-08-25 guard found.
gated_test_surface("skidbladnir-sockets", &["pki", "sockets"]),
]
}
/// Every feature the manifest declares, `default` excluded.
fn offers() -> Vec<&'static str> {
vec![
"pki",
"vault",
"redfish",
"openbao",
"download",
"terraform",
"msi-oci",
"pack-oci",
"oci",
"service-drive",
"machine",
"kvm",
"container",
"jera",
"matrix",
"znippy",
"kubernetes",
"talos",
"testmatrix",
"federation",
"upstream-azure",
"upstream-aws",
"sockets",
"promote",
"promote-k8s",
"watch-refresh",
]
}
/// **The twelve features no row builds, each with a written reason.**
///
/// Nine of them were not in the retired toml's `offers` at all, so nothing could
/// even ask about them. Three groups recur: a feature REACHED by one a row builds
/// (`machine`, `kvm`), a HEAVY container backend behind draupnir's bollard/tokio
/// stack, and a test-harness mode.
pub fn allow_unbuilt() -> BTreeMap<String, String> {
[
("machine", "the server-side machine lifecycle module (MachineController + MachineSpec/Handle + the content-addressed ImageCache). REACHED by `kvm`, which `matrix` implies and a row builds; no row names it alone because the trait without a controller implementing it compiles nothing a test exercises."),
("kvm", "the qemu/KVM MachineController. REACHED by `matrix`, which the `skidbladnir-matrix` row builds; naming it alone would be a second row over the same closure."),
("container", "the podman/docker MachineController (fast, stateless tests). Not reached by `matrix`, and no row builds it: it is an ALTERNATIVE controller for the same trait, and the KVM one is what the install matrix actually drives. The day a container arm of the matrix exists it gets a row."),
("jera", "route the machine module's container/VM spawns through jera instead of raw Command. Deliberately an ADDITIVE first step — a classifier, a mapper and an honest `jera_unsupported()` gap-list beside the working direct path, not a replacement — so there is no artifact to build it into yet."),
("msi-oci", "build MSIs (WiX v4 under Wine) through draupnir's one OCI engine. Pulls draupnir's bollard/tokio; the lean core must not carry it, and with the feature off the build renders the WXS for real and then bails HONESTLY, which is the behaviour every shipped row wants."),
("pack-oci", "the container dep-capture build in pack.rs through the same draupnir OCI engine. Same reason as `msi-oci`: heavy backend, honest bail when off."),
("oci", "the convenience umbrella over `msi-oci` + `pack-oci`. An umbrella cannot be less unbuilt than what it unions."),
("redfish", "BMC provisioning over Redfish REST (power/boot/virtual-media). A CONNECTED backend nothing in this repo's own test surfaces drives — the Redfish end of the constellation is draupnir's, and pointing a row at a BMC nobody has would be a build point that proves nothing."),
("terraform", "drive the `terraform` binary. A zero-dependency feature (`terraform = []`) that shells out to an external tool; there is nothing for a build point to link, and the zero-shell law means it is on the way out rather than the way in."),
("promote-k8s", "`promote` plus `kubernetes` plus the gzip/base64 payload path. Both halves already have rows of their own; a third row over their union would compile nothing the two do not, and it is the union that is rare in practice."),
("watch-refresh", "a gix-backed watch/refresh path. Not built by any row and not exercised by any declared test surface — the one entry here that is a genuine gap rather than a decision, and it is named so it stops being invisible."),
("testmatrix", "turns the `functional_status` emit markers into real nornir matrix rows. A TEST-HARNESS mode, never a delivery: it is turned on by the test command, not by a build point."),
]
.into_iter()
.map(|(k, v)| (s(k), s(v)))
.collect()
}
/// **The `[[bin]]` no row builds**, with its reason.
fn allow_unbuilt_bins() -> BTreeMap<String, AllowEntry> {
[
// 2026-10-16 build-thing sweep (loki): the gate counted these and no row or reason covered them.
("Skidbladnir-build-thing", "this recipe crate's own emit door (`cargo run -p Skidbladnir-build-thing -- emit` regenerates .nornir/build-variants.toml); the forge loads the crate as wasm, the bin is repository tooling, never shipped."),
("xtask", "the `cargo xtask` door (`cargo xtask ci`, `cargo xtask build`): repository tooling that runs the CI plan and the declared rows; a member nothing depends on, never shipped."),
("pairing-probe", "nordisk-pairing's real-socket/mTLS/WireGuard probe for the draupnir fleet test (tests/fleet_draupnir.rs), run in the test's containers — a test subject, never shipped."),
(
"skidbladnir-scenario",
"the probe crate's THIRD executable — a scenario driver for the install/verify probe, run by hand and by the probe's own harness, never installed. It was not mentioned in the hand-written recipe at all (found 2026-08-30, lane AG); naming it here is what makes its absence from the roster a decision instead of an oversight.",
)]
.into_iter()
.map(|(k, v)| (s(k), AllowEntry::Reason(s(v))))
.collect()
}
/// **The pinned build image every cargo variant compiles in** — `[build.toolchain]`.
///
/// Until 2026-09-10 this recipe declared none, and the consequence was NOT that
/// Skidbladnir built on the host. `dwarves buildthing` builds this very crate's
/// wasm32 module INSIDE this container
/// (`dwarves::build_thing::code::run_cargo`), so an absent table meant
/// `CargoToolchain::validate` refused — *"no `image_sha256` — a named tag is a
/// convenience, NOT a pin"* — the code road closed, and the forge fell back to
/// reading the emitted `.nornir/build-variants.toml` as if it were the source.
/// The code-is-source ruling was out of force here for want of one line; the
/// 2026-09-10 inventory render says `toml` for Skidbladnir and six siblings.
///
/// The digest is the **MANIFEST** digest (64 lowercase hex, no `sha256:`), read
/// off the engine rather than typed from memory:
/// `cargo run -p nornir-build-thing --features image-build --example
/// image_digest -- nordisk-cargo-toolchain:live` — the bollard door added the
/// same day, because before it the only way to learn a digest was the
/// `podman image inspect` the no-shelling law forbids. The image recipe is
/// reproducible (edda `ac3852e`), so every box that mints it lands on this
/// digest. It moves only when the image RECIPE moves, in the same commit.
/// MOVED 2026-09-20 (loki). The value below was
/// `2cf9b012c9d2ffa42b447e5b0d127184e5a3f998dd2574d771477e9c8e366659` — a
/// second mint of this same recipe which exists on exactly one box, t14s, and
/// sat there UNTAGGED (`<none>:<none>`, 2026-07-13). On loki it does not exist
/// at all, so `CargoToolchain::validate` refused every container build of this
/// repo BY DIGEST — correctly, and that is the whole point of a pin: it told
/// the truth about an environment that was not there. The digest now names the
/// mint that is tagged `nordisk-cargo-toolchain:live` and `:live-uefi` on both
/// boxes, and which composed the live gunnar appliance ISO; `gunnar` and
/// `holger` have pinned it since 2026-09-15. Read back on loki through the
/// engine door, not from podman hearsay:
/// `cargo run -p nornir-build-thing --features image-build --example
/// image_digest -- nordisk-cargo-toolchain:live` ->
/// `3d5115baa07d03ca51eb3e44ebb86b4e64e679bd88f38e156ef9da12c35d2143`.
/// Same `rust_version` 1.97.1, and the target list below is a subset of what
/// that image carries (it adds `x86_64-unknown-uefi`), so the pin moves and
/// nothing else does.
fn toolchain() -> Option<CargoToolchain> {
Some(CargoToolchain {
image: s("nordisk-cargo-toolchain"),
// RE-PINNED 2026-10-01 (lane R, dwarf), `3d5115ba…` → `668468e7…`: edda 6b5f905 added
// clang + llvm to cargo-toolchain.Containerfile (same Debian snapshot, same
// SOURCE_DATE_EPOCH) and no longer mints `3d5115ba…`; this is the new manifest digest.
image_sha256: s("668468e775ad80f32d700cbc9709d41cac726f40d00649461a0932f30d1b2067"),
rust_version: s("1.97.1"),
// What the pinned image CARRIES std for (`rustup target list
// --installed` inside it), not what Skidbladnir delivers. wasm32 is not
// optional here: it is the target this crate's own plugin module is
// built for, and a declaration that omitted it would refuse the code
// road by name before the container ever ran.
targets: vec![
s("x86_64-unknown-linux-gnu"),
s("x86_64-unknown-linux-musl"),
s("wasm32-unknown-unknown"),
],
timeout_secs: 3600,
mem_limit_mb: 8192,
cpus: 8,
network: false,
remap_paths: true,
// MEASURED 2026-09-10
// (`--example escaping_siblings /home/rickard/git/Skidbladnir`), not
// guessed: nine deps into `edda` (`app-heartbeat`, `jera`,
// `nornir-build-thing`, `nornir-escape`, …), two into `znippy`
// (`znippy-common`, `znippy-compress`), one into `znippy-zoomies`
// (`gatling`) and one into `draupnir`. A container holding only
// Skidbladnir resolves none of them, and the error it fails with names a
// manifest path, not this list. If a sibling path dep is added or
// removed, this list moves in the SAME commit.
//
// `tunnr` is SECOND ORDER and appears in no Skidbladnir manifest. A
// sibling is a CHECKOUT the container mounts, not a file it copies, so
// cargo resolves it in its own right: the `draupnir` above arrives with
// its own root manifest, which path-depends on `tunnr-vm`. MEASURED on
// the code road, 2026-09-10 (`dwarves buildthing info Skidbladnir`) --
// the pin let the container run, and this is what cargo said inside it:
//
// error: failed to get `tunnr-vm` as a dependency of package
// `draupnir v0.1.11 (/work/draupnir)`
// Caused by: failed to read `/work/tunnr/Cargo.toml`
//
// The `escaping_siblings` example cannot see this: it scans THIS repo's
// manifests, which is a first-order answer to a transitive question. If
// a sibling path dep is added or removed -- here OR in draupnir's root
// manifest -- this list moves in the SAME commit.
// 2026-10-16 (build-thing sweep): `openzl-sys-rs` is reached one hop past znippy
// (znippy-common's DEFAULT `openzl` feature → `../../openzl-sys-rs`); cargo loads it
// (measured on loki: hiding the checkout fails `cargo metadata --locked`), so the
// container must carry it. `tests/siblings.rs` walks the transitive closure.
siblings: vec![
s("draupnir"),
s("edda"),
s("openzl-sys-rs"),
s("tunnr"),
s("znippy"),
s("znippy-zoomies"),
],
})
}
impl BuildThing for SkidbladnirBuildThing {
fn manifest(&self) -> Manifest {
Manifest {
abi: ABI_VERSION,
repo: s("Skidbladnir"),
source_crate: s(KRATE),
summary: "Skíðblaðnir — the airgap / systemd+OCI-ops CLI the whole nordisk \
suite is installed BY: it wraps each member as an installable unit \
under a dedicated system user. Four shipped executables, and seven \
compile-only rows that keep the test-gating features from rotting."
.to_string(),
}
}
fn variants(&self) -> BuildVariantSet {
BuildVariantSet {
// ONE delivery, named rather than left implicit: with a single
// suite check_suites has nothing to compare, but --suite ORIGINAL
// resolves here instead of falling through to UNSUITED.
base_suite: Some(s(SUITE)),
offers: offers().into_iter().map(s).collect(),
allow_unbuilt: allow_unbuilt(),
allow_unbuilt_bins: allow_unbuilt_bins(),
variants: variants(),
toolchain: toolchain(),
..BuildVariantSet::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use nornir_build_thing::feature_axis::FeatureAxis;
/// The feature axis does not rot: twenty-six offered, fourteen built,
/// twelve excused by name — and a stale excuse fails too.
#[test]
fn the_feature_axis_gate_is_clean() {
let set = SkidbladnirBuildThing.variants();
if let Err(rot) = FeatureAxis::of("Skidbladnir", &set).gate() {
panic!("the feature axis is rotting: {rot:#?}");
}
}
/// **The compile-only rows must stay compile-only.** A `lib` row that grew a
/// `bin` would quietly become a delivery — and a delivery built at
/// `profile = dev` with a test-gating feature set is not something anyone
/// should be able to install.
#[test]
fn no_test_surface_row_ships_a_binary() {
for v in SkidbladnirBuildThing.variants().variants {
if v.kind == ArtifactKind::Lib {
assert!(v.bin.is_none(), "'{}' is a compile-only row with a bin", v.name);
assert_eq!(v.profile, Profile::Dev, "'{}' is not shipped", v.name);
assert_eq!(v.shape, ServiceShape::NotApplicable, "'{}'", v.name);
} else {
assert!(v.bin.is_some(), "'{}' ships nothing", v.name);
assert_eq!(v.profile, Profile::Release, "'{}' is shipped", v.name);
}
}
}
/// Every excuse names a feature this repo offers, and no excuse is wordless.
#[test]
fn every_excuse_names_a_feature_this_repo_offers() {
let set = SkidbladnirBuildThing.variants();
for (f, why) in &set.allow_unbuilt {
assert!(set.offers.contains(f), "'{f}' is excused and is not offered");
assert!(!why.trim().is_empty(), "'{f}' is excused with no words");
}
}
}