Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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()
            );
        }
    }
}