xtask/src/build.rs
//! `cargo xtask build` — **build what the recipe declares, in the image it
//! declares, and nothing else.**
//!
//! # Why this exists
//!
//! gunnar is correctly built and correctly deployed. What it was not doing is
//! asking [`gunnar_recipe`] *what* to build: the 19 `[[build.variant]]` rows in
//! `.nornir/build-variants.toml` are emitted from the recipe and read by every
//! tool that discovers this repository (the dwarves forge, the build-thing pane,
//! installtest, the bench harness) — while this xtask built the same artifacts
//! from argv it spelled itself, in 25 699 lines that named no row.
//!
//! Two spellings of one build is the drift that has no symptom until the day
//! they disagree, and then the disagreement is silent: the forge builds the row,
//! the release builds the xtask's idea of it, and nothing compares them.
//!
//! This module OWNS NO BUILD KNOWLEDGE. It carries no package name, no feature
//! string, no triple, no list of rows — and, since 2026-09-17, no image name and
//! no digest either. Every one of those is a field of something the recipe
//! declares: a `BuildVariant` for the argv, `BuildVariantSet::toolchain` for the
//! environment. The argv is materialized by the ONE writer that materializes it
//! for the forge as well, and the environment is entered through the ONE loop
//! that enters it for the kernel and package kinds.
//!
//! # The defect this module carried for one day, and the law that names it
//!
//! Between `71dbc1f` (2026-09-17) and this commit the row resolution was right
//! and the EXECUTION LEG was wrong: it called `nornir_build_thing::build_variant`,
//! which is `Command::new("cargo")` in the host's environment
//! (`provenance.rs:473`). Everything the compile needs then comes from whichever
//! box happens to type the command — the rustc version, the installed target
//! set, the C compiler, `~/.cargo/config.toml`'s linker choice.
//!
//! MEASURED on loki 2026-09-17, `cargo run -q -p xtask -- build --variant
//! gunnar-server` on the host leg: it resolved the row correctly, compiled for
//! several minutes, and died in `aws-lc-sys`' build script because loki has no
//! `x86_64-linux-musl-gcc` — and every gunnar server variant targets
//! `x86_64-unknown-linux-musl`. The box decided the verdict. The law it broke is
//! written in the image's own first lines
//! (`nornir-build-thing/src/containerfiles/cargo-toolchain.Containerfile:3`):
//!
//! > The host contributes a container engine socket and a workdir. NOTHING
//! > else… A build that only works on one box is a bug, not a build.
//!
//! So the cargo rows go through
//! [`nornir_build_thing::cargo_container::build_variant_in_container`] over
//! [`DraupnirCargoRunner`] — the same draupnir `backend-oci` engine the kernel
//! kind, the package kind and korp-installer's compose already drive, reused
//! rather than re-rolled. The host's contribution is now exactly three things:
//! the engine socket, the repo (plus the siblings the declaration names) and a
//! target directory.
//!
//! # Step 1 is the image, and it is minted, never assumed
//!
//! `[build.toolchain] image_sha256` is a MANIFEST digest, and a digest names an
//! image a box may simply not have: there is no registry behind
//! `nordisk-cargo-toolchain`, so a first run on a fresh box would otherwise
//! refuse with `pull image …@sha256:…` — a failure about a network, for a build
//! that needs none. The kernel road mints in its step 1; this one does too
//! when the pin is absent (and only then), through
//! [`nbt::cargo_container::ensure_declared_toolchain`] — never into `:live` —
//! over [`DraupnirCargoRunner`], which writes the
//! crate's OWN embedded Containerfile into the context (a repo never supplies
//! its own build environment) and mints it REPRODUCIBLY — pinned base digest, a
//! snapshot.debian.org instant, a fixed `SOURCE_DATE_EPOCH`, logs scrubbed
//! in-layer. Every box that mints it lands the same manifest digest, so this
//! step is idempotent: after the first, podman answers from its layer cache.
//!
//! MEASURED on loki 2026-09-17, a box that had never held the image: the mint
//! landed `sha256:3d5115baa07d03ca51eb3e44ebb86b4e64e679bd88f38e156ef9da12c35d2143`
//! — byte-for-byte the digest the recipe has pinned since 2026-09-15, so the
//! recipe is the pin and the box is not. First mint 47.9 s; the re-mint on the
//! next run of this verb, 0.2 s.
//!
//! # The proof
//!
//! MEASURED on loki 2026-09-17, the same box and the same command that had just
//! failed on the host leg:
//!
//! ```text
//! $ cargo run -q -p xtask -- build --variant gunnar-server
//! toolchain image nordisk-cargo-toolchain:live minted in 47.9 s
//! building in nordisk-cargo-toolchain@sha256:3d5115ba… (rust 1.97.1, 8192 MiB,
//! 8 cpus, network none, siblings: edda, znippy-zoomies)
//! === gunnar-server ===
//! cargo build -p gunnar-server --bin gunnar-server --features server,…
//! --no-default-features --profile release --target x86_64-unknown-linux-musl
//! built in 181.5 s
//! artifact target/x86_64-unknown-linux-musl/release/gunnar-server (34823144 bytes)
//! sha256:4fb39396ab0842de6e436480e24956a4c0e7105c74ee5334977454a424fbbf6b
//! ```
//!
//! `file` calls it a static-pie x86-64 ELF and `sha256sum` on the host agrees
//! with the attested digest, so what is recorded is what is on disk. loki still
//! has no `x86_64-linux-musl-gcc` — `which` finds neither it nor `musl-gcc` —
//! which is the entire point: the host was not asked. A second run of the verb
//! rebuilt the same variant in 2.9 s to the SAME sha256.
//!
//! # The two roads, and why only one of them is here
//!
//! A CARGO row (`package` is set — the four `gunnar-server*` rows, the three
//! `gunnar`/`gunnar-cli`/`gunnar-solo` rows and `gunnar-service`) is a cargo
//! build, and it goes through the shared CONTAINER loop, which content-hashes
//! what cargo reports and captures a provenance record whose toolchain is read
//! off the container's own `rustc --version` rather than the host's.
//!
//! A MEDIA row (`kind = container | iso | installer` — `gunnar-image`,
//! `gunnar-appliance-iso`, the eight `gunnar-installer*` rows and `gunnar-twin`)
//! is not a cargo build at all: it is podman, korp and a kernel donor. Those
//! eleven rows are declared by the recipe and are still BUILT by this xtask's
//! own media modules (`appliance.rs`, `images.rs`, `isoinstaller.rs`,
//! `image_formats.rs` — about 4 300 lines between them). holger keeps that half
//! behind a `build-space` crate; gunnar has no `build-space` yet, so the seam is
//! NAMED here rather than faked: `--list` shows every row including the media
//! ones, and asking this verb to build one REFUSES by name and says which door
//! does build it. A verb that silently skipped eleven of nineteen rows would be
//! worse than one that has not grown to them yet.
use std::path::Path;
use std::time::Instant;
use gunnar_recipe::nornir_build_thing as nbt;
use nbt::cargo_container::{
build_variant_in_container, ensure_declared_toolchain, toolchain_with_override, CargoToolchain,
DraupnirCargoRunner, HostPaths, ToolchainImageStore, ToolchainMint,
TOOLCHAIN_IMAGE_OVERRIDE_ENV,
};
use nbt::suite_cli;
use nbt::variant::{ArtifactKind, BuildVariant, BuildVariantSet};
/// `cargo xtask build [--variant <name>] [--list]`.
///
/// The flags are the constellation's shared ones: `suite_cli::parse` is the same
/// parser dwarves, nornir, holger, korp and facett's xtasks use.
pub fn run(root: &Path, argv: &[String]) -> Result<Vec<String>, String> {
let args = suite_cli::parse(argv).map_err(|e| format!("{e:#}"))?;
let set = gunnar_recipe::emit_recipe::declared();
let (_suite, chosen) = suite_cli::resolve(&set, &args).map_err(|e| format!("{e:#}"))?;
if args.list {
for v in &chosen {
println!("{:<32} {}", v.name, describe(v));
}
return Ok(chosen.iter().map(|v| v.name.clone()).collect());
}
// The environment, asked for BEFORE the first row is touched: a box that
// cannot enter it must hear so once, by name, and not eight rows in.
let tc = declared_toolchain(&set)?;
let paths = HostPaths::from_env(root);
std::fs::create_dir_all(&paths.target_dir)
.map_err(|e| format!("create the target dir {}: {e}", paths.target_dir.display()))?;
let runner = DraupnirCargoRunner::new();
mint_the_declared_image(&runner, &tc, &paths.target_dir)?;
eprintln!(
"\nbuilding in {} (rust {}, {} MiB, {} cpus, network {}, siblings: {})",
tc.image_ref(),
tc.rust_version,
tc.mem_limit_mb,
tc.cpus,
if tc.network { "on" } else { "none" },
if tc.siblings.is_empty() {
"none".to_string()
} else {
tc.siblings.join(", ")
}
);
let mut built = Vec::new();
for v in &chosen {
eprintln!("\n=== {} ===\n {}", v.name, describe(v));
if is_media(v) {
return Err(media_refusal(v));
}
// The shared CONTAINER loop: `set.require`, `set.validate`,
// `tc.check_target`, then ONE `cargo build --message-format=json
// --locked --offline` over the row's own `cargo_args()` inside the
// pinned image, content-hashing the executables it reports.
let t0 = Instant::now();
let out = build_variant_in_container(
&set,
&v.name,
root,
&paths.target_dir,
paths.registry_bind(),
&tc,
&runner,
)
.map_err(|e| format!("{e:#}"))?;
// `success` is cargo's exit code and nothing more, so it is read here
// rather than trusted: a red that returned `Ok` must not be counted.
if !out.success {
return Err(format!(
"variant '{}' did not build in {} — the compiler's own words are above",
v.name,
tc.image_ref()
));
}
eprintln!(" built in {:.1} s", t0.elapsed().as_secs_f64());
// The provenance record's OWN hashes, not a second hashing pass here:
// what is printed is what was attested.
for a in &out.attestation.record.artifacts {
eprintln!(
" artifact {} ({} bytes)\n sha256:{}",
a.path, a.bytes, a.sha256
);
}
built.push(v.name.clone());
}
Ok(built)
}
/// **The image this repo compiles in, as the RECIPE declares it** — never a name
/// spelled here.
///
/// [`toolchain_with_override`] is the shared reader: it takes the declaration and
/// applies [`TOOLCHAIN_IMAGE_OVERRIDE_ENV`] when it is set, announcing that on
/// stderr, so proving a new image before the pin moves does not need an edit to
/// this file.
fn declared_toolchain(set: &BuildVariantSet) -> Result<CargoToolchain, String> {
let tc = toolchain_with_override(set.toolchain.clone()).map_err(|e| format!("{e:#}"))?;
tc.ok_or_else(undeclared_refusal)
}
/// A repo with no `[build.toolchain]` is REFUSED rather than quietly built on the
/// host. The host road is what this verb was changed to stop doing, and a silent
/// fallback to it would put the one-box bug back for whoever happens to have the
/// right compilers installed.
fn undeclared_refusal() -> String {
format!(
"the recipe declares no `[build.toolchain]`, so there is no image to build in, and \
`cargo xtask build` does NOT fall back to the host: a build whose verdict depends on \
which box typed it is not a build.\n \
Declare the table in `crates/gunnar-recipe/src/recipe.rs` (mirrored into \
`.nornir/build-variants.toml`) with `image`, a MANIFEST-digest `image_sha256`, \
`rust_version`, `targets` and the bounds — or set {TOOLCHAIN_IMAGE_OVERRIDE_ENV} to a \
digest-pinned reference for a one-off run."
)
}
/// **Step 1: the declared image exists on this box, or is made to.**
///
/// Reproducible and therefore idempotent — see the module doc. The context holds
/// exactly one file and the crate writes it, so the repo cannot substitute its
/// own build environment; it lives under the target dir because that is already
/// the directory this verb is allowed to write in.
///
/// Over the engine seam ([`ToolchainImageStore`]) so the names it may write are
/// provable without a socket.
///
/// The pin present: nothing is minted and no name is written. Absent: the
/// recipe is minted under its own content-addressed tag
/// (`nordisk-cargo-toolchain:recipe-<hash>`) and the pin must resolve after,
/// else a named refusal. It never writes `:live`: on 2026-10-05 a `:live`
/// re-mint moved the tag onto a newer recipe's image, the pinned `3d5115ba…`
/// lost its only name, and Build Thing's pulls of the pin failed 403.
fn mint_the_declared_image(
store: &dyn ToolchainImageStore,
tc: &CargoToolchain,
target_dir: &Path,
) -> Result<(), String> {
let ctx = target_dir.join("nordisk-cargo-toolchain-context");
let t0 = Instant::now();
let minted = ensure_declared_toolchain(store, &ctx, tc).map_err(|e| {
format!(
"step 1, the declared toolchain image {}: {e:#}\n \
It needs a container engine socket — the ONE thing the host is asked for. \
Nothing pulls the pin; every box mints it from the committed recipe.",
tc.image_ref()
)
})?;
match minted {
ToolchainMint::AlreadyPresent => {
eprintln!(
"toolchain image {} already in the engine, nothing minted",
tc.image_ref()
)
}
ToolchainMint::Minted { tag } => eprintln!(
"toolchain image {tag} minted in {:.1} s",
t0.elapsed().as_secs_f64()
),
}
Ok(())
}
/// A row this verb does not build: podman, korp and a kernel donor, not cargo.
fn is_media(v: &BuildVariant) -> bool {
!matches!(v.kind, ArtifactKind::Exe | ArtifactKind::Service)
}
/// Which door DOES build this row. Named, so the refusal is a direction and not
/// a dead end.
fn media_refusal(v: &BuildVariant) -> String {
let door = match v.kind {
ArtifactKind::Container => "cargo xtask image (xtask/src/images.rs)",
ArtifactKind::Iso => "cargo xtask iso-installer (xtask/src/isoinstaller.rs)",
_ => "cargo xtask appliance (xtask/src/appliance.rs)",
};
format!(
"variant '{}' is a {} row, and `cargo xtask build` builds cargo rows only.\n \
It IS declared by the recipe — `cargo xtask build --list` shows it — but the \
medium is built by:\n {}\n \
Moving that half behind the declaration is the rest of this change: holger keeps \
it in a `build-space` crate, and gunnar has none yet.",
v.name,
kind_word(v.kind),
door
)
}
fn kind_word(k: ArtifactKind) -> &'static str {
match k {
ArtifactKind::Container => "container",
ArtifactKind::Iso => "iso",
ArtifactKind::Installer => "installer",
ArtifactKind::Exe => "exe",
ArtifactKind::Service => "service",
_ => "non-cargo",
}
}
/// What this row builds, in one line — the row's own answer, never a guess.
fn describe(v: &BuildVariant) -> String {
if is_media(v) {
format!(
"{} — built by a medium door, not by cargo",
kind_word(v.kind)
)
} else {
v.command_line()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `--list` names EVERY declared row, media included. A build list that
/// showed only the eight cargo rows would quietly hide eleven of nineteen.
#[test]
fn the_list_shows_every_row_the_recipe_declares() {
let set = gunnar_recipe::emit_recipe::declared();
let listed = run(Path::new("."), &["--list".to_string()]).expect("--list always prints");
assert_eq!(
listed.len(),
set.variants.len(),
"--list showed {} of {} declared rows",
listed.len(),
set.variants.len()
);
}
/// This module owns no build knowledge: every line it prints for a cargo row
/// is the ROW's own `command_line()`.
#[test]
fn a_cargo_row_is_described_by_its_own_command_line() {
let set = gunnar_recipe::emit_recipe::declared();
let v = set
.variants
.iter()
.find(|v| !is_media(v))
.expect("the recipe declares at least one cargo row");
assert_eq!(describe(v), v.command_line());
}
/// A medium row is REFUSED by name and the refusal says which door builds
/// it — never skipped, never silently treated as a cargo build.
#[test]
fn a_medium_row_is_refused_by_name_and_says_what_does_build_it() {
let set = gunnar_recipe::emit_recipe::declared();
let v = set
.variants
.iter()
.find(|v| is_media(v))
.expect("the recipe declares media rows");
let e = media_refusal(v);
assert!(e.contains(&v.name), "{e}");
assert!(e.contains("cargo xtask"), "the refusal names no door: {e}");
}
/// **The environment is the recipe's, not this file's.** The image, the
/// digest, the rustc version and the bounds all come back through
/// `BuildVariantSet::toolchain`, and the reference this verb runs is a
/// MANIFEST DIGEST — a tag would be a name somebody can move under two
/// builds that report the same provenance.
#[test]
fn the_recipe_declares_the_image_this_verb_builds_in() {
let set = gunnar_recipe::emit_recipe::declared();
let tc = declared_toolchain(&set).expect("gunnar declares [build.toolchain]");
tc.validate().expect("the declared toolchain must validate");
assert!(
tc.image_ref().contains("@sha256:"),
"the toolchain reference is not digest-pinned: {}",
tc.image_ref()
);
assert!(
!tc.rust_version.is_empty(),
"the declaration names no rust_version"
);
assert!(
tc.mem_limit_mb > 0 && tc.cpus > 0,
"a build container is a guest, not a host process"
);
}
/// **Every cargo row can actually be compiled in the declared image.** This
/// is the test that would have caught the defect this module was changed
/// for: `gunnar-server` targets `x86_64-unknown-linux-musl`, loki has no
/// `x86_64-linux-musl-gcc`, and the answer is that the host is not asked.
/// `check_target` is the declaration's own comparison, so a row whose triple
/// the image carries no std for is a named refusal here rather than an
/// `E0463` an hour into a compile.
#[test]
fn every_cargo_row_targets_a_triple_the_declared_image_carries() {
let set = gunnar_recipe::emit_recipe::declared();
let tc = declared_toolchain(&set).expect("gunnar declares [build.toolchain]");
let mut checked = 0usize;
for v in set.variants.iter().filter(|v| !is_media(v)) {
tc.check_target(&v.name, v.target.as_deref())
.unwrap_or_else(|e| {
panic!(
"row '{}' cannot be built in the declared image: {e:#}",
v.name
)
});
checked += 1;
}
assert!(checked >= 8, "only {checked} cargo rows were checked");
assert!(
set.variants
.iter()
.any(|v| v.target.as_deref() == Some("x86_64-unknown-linux-musl")),
"the row that proved the defect (a musl target) is gone from the declaration"
);
}
/// A recipe with no `[build.toolchain]` REFUSES by name and spells the fix.
/// It does NOT fall back to host cargo — that fallback is the whole defect.
#[test]
fn no_declared_toolchain_is_a_refusal_and_never_a_host_build() {
let mut set = gunnar_recipe::emit_recipe::declared();
set.toolchain = None;
let e = declared_toolchain(&set).expect_err("an undeclared environment must refuse");
assert!(
e.contains("[build.toolchain]"),
"the refusal names no table: {e}"
);
assert!(
e.contains("host"),
"the refusal does not say what it is refusing to do: {e}"
);
assert!(
e.contains(TOOLCHAIN_IMAGE_OVERRIDE_ENV),
"the refusal names no one-off door: {e}"
);
}
/// Step 1 against a fake engine store that models podman's naming: a mint
/// writes its tag, and `name@sha256:<manifest>` resolves only while a tag
/// under `name` still names that image.
mod step_one {
use super::super::mint_the_declared_image;
use super::CargoToolchain;
use gunnar_recipe::nornir_build_thing::cargo_container::ToolchainImageStore;
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::Path;
const NAME: &str = "nordisk-cargo-toolchain";
const PINNED_ID: &str = "id-ab4b8dc004e8";
const PINNED_DIGEST: &str =
"3d5115baa07d03ca51eb3e44ebb86b4e64e679bd88f38e156ef9da12c35d2143";
const RECIPE_ID: &str = "id-88c4902987c2";
const RECIPE_DIGEST: &str =
"668468e775ad80f32d700cbc9709d41cac726f40d00649461a0932f30d1b2067";
#[derive(Default)]
struct FakeStore {
tags: RefCell<BTreeMap<String, String>>,
images: RefCell<BTreeMap<String, String>>,
mints: RefCell<Vec<String>>,
}
impl ToolchainImageStore for FakeStore {
fn image_present(&self, image_ref: &str) -> anyhow::Result<bool> {
if let Some((repo, digest)) = image_ref.split_once("@sha256:") {
let images = self.images.borrow();
return Ok(self.tags.borrow().iter().any(|(t, id)| {
t.rsplit_once(':').map(|(r, _)| r) == Some(repo)
&& images.get(id).is_some_and(|d| d == digest)
}));
}
Ok(self.tags.borrow().contains_key(image_ref))
}
fn image_id_of(&self, image_ref: &str) -> anyhow::Result<Option<String>> {
Ok(self.tags.borrow().get(image_ref).cloned())
}
fn mint_recipe(&self, _ctx: &Path, tag: &str) -> anyhow::Result<()> {
self.images
.borrow_mut()
.insert(RECIPE_ID.into(), RECIPE_DIGEST.into());
self.tags.borrow_mut().insert(tag.into(), RECIPE_ID.into());
self.mints.borrow_mut().push(tag.into());
Ok(())
}
}
/// gunnar pins `3d5115ba…`, `:live` names it, and the edda this xtask
/// links mints `668468e7…` (clang + llvm). Step 1 must build in the pin
/// and leave every name where it is — on 2026-10-05 a `:live` re-mint
/// un-named the pin and Build Thing's pulls of it failed 403.
#[test]
fn step_one_builds_in_a_present_pin_and_moves_no_name() {
let live = format!("{NAME}:live");
let store = FakeStore::default();
store
.tags
.borrow_mut()
.insert(live.clone(), PINNED_ID.into());
store
.images
.borrow_mut()
.insert(PINNED_ID.into(), PINNED_DIGEST.into());
let tc = CargoToolchain {
image: NAME.into(),
image_sha256: PINNED_DIGEST.into(),
..Default::default()
};
let out = mint_the_declared_image(&store, &tc, Path::new("/target"));
assert!(
store.image_present(&tc.image_ref()).unwrap(),
"step 1 un-named the pin `{}` (tags now {:?})",
tc.image_ref(),
store.tags.borrow()
);
assert_eq!(
store.tags.borrow().get(&live).map(String::as_str),
Some(PINNED_ID)
);
out.expect("the pin is in the engine: step 1 must let the build proceed in it");
assert!(
store.mints.borrow().is_empty(),
"minted: {:?}",
store.mints.borrow()
);
}
}
}