crates/draupnir-build-thing/src/recipe.rs
//! **The recipe** — draupnir's `impl BuildThing`, the ONE declaration of what
//! this repository builds. `.nornir/build-variants.toml` is emitted from
//! [`DraupnirBuildThing::variants`] (see [`crate::emit`]); nothing here reads it.
//!
//! # What draupnir declares, honestly
//!
//! **One crate, one `[lib]`, no `[[bin]]`.** The 2026-07-30 suite inventory
//! listed `draupnir` (CLI/server) and `fleet_boot` (daemon) as binaries; measured
//! against the crate, draupnir ships NEITHER. It is the boot/provisioning
//! LIBRARY — the `BootSpec`/`Machine` types, validation, the three backend seams
//! (KVM-via-tunnr · OCI container · Redfish bare-metal) and the fleet fan-out —
//! that consumers such as `jera` and `Skidbladnir` link in-process. Those two
//! stale bin names are recorded in this doc comment rather than invented as
//! phantom `[[bin]]` targets, which is what an aspirational recipe would have
//! done.
//!
//! So the whole build space is **one artifact with a feature axis**, and the
//! three rows below are the three shapes a consumer actually links:
//!
//! | variant | features | what it is |
//! |---|---|---|
//! | `draupnir` ⭐ | — | PURE std: the trait surface, the types, validation and the fleet fan-out compile with **zero dependencies** |
//! | `draupnir-seed` | `seed` | plus the pure-Rust `fatfs` NoCloud cloud-init seed-IMAGE authoring (the file-set render and the seed-DIR output are always compiled) |
//! | `draupnir-tunnr` | `backend-tunnr` | plus the live KVM wire over tunnr's `boot_test` primitive; pulls `seed`, because the KVM adapter authors the seed image it attaches |
//!
//! # `linkage = "not-applicable"` is a declaration
//!
//! Every row says it. A library is linked by whoever consumes it and composes
//! nothing, so the fat/thin axis has no answer for it — and an OMITTED key reads
//! as the `FatService` default, which is a positive claim of self-containment
//! that nobody checked. `NotApplicable` is the value that can be told apart from
//! "undeclared", and `validate` holds it to `embeds`/`reaches` both being empty.
//!
//! # The three backends nothing here builds, and why that is fine
//!
//! `backend-oci`, `backend-youki` and `backend-redfish` are real, shipped
//! features and **no row turns them on**. That is not an oversight and it is not
//! hidden: they are in `allow_unbuilt` with the reason, so the honesty gate reads
//! a written excuse rather than a gap. The reason is the same for all three — a
//! backend is a CONSUMER's choice about which wire it wants compiled in, and
//! draupnir ships no artifact per backend to choose between. `backend-tunnr` has
//! a row only because it is the one backend whose dependency is a PATH dep
//! (`tunnr-vm`, not yet on crates.io), so whether it still compiles is a question
//! about this repository and not about a consumer's manifest.
//!
//! `redfish-server` and `testmatrix` are excused for a different reason each, and
//! both are written down beside the entry.
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::{
ArtifactKind, BuildVariant, BuildVariantSet, Profile, ServiceShape,
};
/// The crate a receipt names as its recipe.
pub const KRATE: &str = "draupnir-build-thing";
/// The content hash over this crate's sources, computed by `build.rs`.
pub const CONTENT_HASH: &str = env!("DRAUPNIR_BUILD_THING_CONTENT_HASH");
/// **The delivery this repo ships in.** suites is a LIST — one row may ship
/// in more than one flavour — but draupnir has exactly one, and writing it down is
/// the whole point: an EMPTY list is not a third flavour, it is a row that
/// cargo xtask build --suite ORIGINAL cannot select. All three rows here were
/// exactly that until 2026-09-20. The only other flavour anywhere today is
/// holger's MVP; every other product declares ORIGINAL alone and TAGS it, so
/// the build verb has ONE shape everywhere.
pub const SUITE: &str = "ORIGINAL";
/// The one package this repository builds.
pub const PACKAGE: &str = "draupnir";
/// draupnir's build thing.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct DraupnirBuildThing;
impl DraupnirBuildThing {
/// 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()
}
/// One library build point: the `draupnir` crate with `features` compiled in.
///
/// `kind = lib`, `role = library`, `linkage = not-applicable` — see the module
/// doc for why the last of those is written rather than omitted.
fn library(name: &str, features: &[&str], is_default: bool) -> BuildVariant {
BuildVariant {
name: s(name),
package: Some(s(PACKAGE)),
kind: ArtifactKind::Lib,
role: Some(Role::Library),
suites: vec![s(SUITE)],
shape: ServiceShape::NotApplicable,
features: features.iter().map(|f| s(f)).collect(),
profile: Profile::Release,
is_default,
..BuildVariant::default()
}
}
/// The three shapes a consumer links.
pub fn variants() -> Vec<BuildVariant> {
vec![
library("draupnir", &[], true),
library("draupnir-seed", &["seed"], false),
library("draupnir-tunnr", &["backend-tunnr"], false),
]
}
/// The feature axis a consumer may turn on.
fn offers() -> Vec<&'static str> {
vec![
"seed",
"backend-tunnr",
"backend-oci",
"backend-youki",
"backend-redfish",
"redfish-server",
]
}
/// **Features nothing here builds, each with a written reason.**
///
/// An unbuilt feature with no entry is a gap; an unbuilt feature with an entry is
/// a decision. These are decisions.
fn allow_unbuilt() -> BTreeMap<String, String> {
[
("backend-oci", "the bollard (podman/Docker REST) container backend — a CONSUMER's choice about which wire it compiles in, and draupnir ships no artifact per backend to choose between. Its deps are all published, so nothing about this repository is untested by leaving it out of the roster."),
("backend-youki", "the daemon-less youki/libcontainer backend, same reason as backend-oci. It is additionally unrunnable rootless on some hosts (apparmor_restrict_unprivileged_userns=1), which is a runtime fact about the box and not a build fact about this crate."),
("backend-redfish", "the pure-Rust ureq/rustls Redfish client, same reason as backend-oci. Its request shaping is fixture-tested with no BMC, so a build point would prove nothing a test does not."),
("redfish-server", "draupnir's OWN BMC — a listening socket. Deliberately not implied by backend-redfish and deliberately not a shipped shape: a client-only build must not grow a server, and nothing in the constellation links draupnir to get one."),
]
.into_iter()
.map(|(k, v)| (s(k), s(v)))
.collect()
}
/// **The pinned build image every cargo variant compiles in** — `[build.toolchain]`.
///
/// The circularity is worth naming, because draupnir is the crate that STARTS
/// the container: the forge builds this recipe's wasm32 module inside
/// `nordisk-cargo-toolchain`, and the engine that runs that container is
/// draupnir's own `backend-oci`. Declaring the pin here is therefore draupnir
/// stating the environment it is itself compiled in — which is exactly what
/// `[build.toolchain]` is for, and not a loop: the image is already minted
/// before the recipe is built.
///
/// Until 2026-09-10 this recipe declared none, and the consequence was NOT that
/// draupnir built on the host: `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 2026-09-10
/// inventory render says `toml` for draupnir 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 draupnir 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/draupnir`), not
// guessed: four deps into `edda` (`nornir-testmatrix` twice, and this
// crate's own `nornir-build-thing` twice), one into `tunnr`
// (`tunnr-vm`) and one into `znippy-zoomies` (`gatling` — the rotary
// engine, per the performance doctrine). If a sibling path dep is added
// or removed, this list moves in the SAME commit.
siblings: vec![s("edda"), s("tunnr"), s("znippy-zoomies")],
})
}
/// **The `[[bin]]` targets no row builds, each with its reason** —
/// `[build.allow_unbuilt_bins]`. ⚒ Build's gate (`buildgate::tally_bins`) counts every
/// bin cargo can see in this tree; MEASURED 2026-10-16 on loki these had neither a
/// row nor a reason (`tests/build_gate.rs` now asks the same question).
fn allow_unbuilt_bins() -> BTreeMap<String, nornir_build_thing::variant::AllowEntry> {
[
("draupnir-build-thing", "this recipe crate's own emit door (`cargo run -p draupnir-build-thing -- emit` regenerates .nornir/build-variants.toml); the forge loads the crate as wasm, the bin is repository tooling, never shipped."),
]
.into_iter()
.map(|(k, v): (&str, &str)| (k.to_string(), nornir_build_thing::variant::AllowEntry::Reason(v.to_string())))
.collect()
}
impl BuildThing for DraupnirBuildThing {
fn manifest(&self) -> Manifest {
Manifest {
abi: ABI_VERSION,
repo: s("draupnir"),
source_crate: s(KRATE),
summary: "draupnir — the nordisk boot/provisioning library: fire up a runtime \
from one BootSpec across three backends (KVM via tunnr · OCI \
container · Redfish bare-metal virtual-media) and drive its power \
lifecycle. A library, not a binary."
.to_string(),
}
}
fn variants(&self) -> BuildVariantSet {
BuildVariantSet {
// ONE delivery, named rather than left implicit, so --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::*;
/// **draupnir ships no binary, and the recipe must not invent one.** The
/// 2026-07-30 inventory said it shipped two; the crate has no `[[bin]]` at
/// all. A row with a `bin` here would be a build point that can only fail.
#[test]
fn no_row_declares_a_binary_because_this_repository_has_none() {
for v in DraupnirBuildThing.variants().variants {
assert!(
v.bin.is_none(),
"variant '{}' declares bin {:?}; draupnir is a library with no [[bin]] target",
v.name,
v.bin
);
assert_eq!(v.kind, ArtifactKind::Lib, "variant '{}'", v.name);
}
}
/// **Every feature a row turns on is one this crate offers, and every offer
/// nothing builds has a written reason.** Between them these two arms make an
/// unexcused gap impossible.
#[test]
fn every_offer_is_either_built_or_excused_by_name() {
let set = DraupnirBuildThing.variants();
let built: Vec<&str> = set
.variants
.iter()
.flat_map(|v| v.features.iter().map(String::as_str))
.collect();
for f in &built {
assert!(set.offers.contains(*f), "variant feature '{f}' is not offered");
}
for offer in &set.offers {
assert!(
built.contains(&offer.as_str()) || set.allow_unbuilt.contains_key(offer),
"feature '{offer}' is offered, no variant builds it, and nothing says why"
);
}
}
/// The library axis is DECLARED inapplicable, never omitted.
#[test]
fn the_linkage_axis_is_declared_inapplicable_rather_than_defaulted() {
for v in DraupnirBuildThing.variants().variants {
assert_eq!(
v.shape,
ServiceShape::NotApplicable,
"variant '{}' would read as a fat self-contained service",
v.name
);
assert!(v.embeds.is_empty() && v.reaches.is_empty());
}
}
}