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

nordisk/gunnar

crates/gunnar-recipe/src/lib.rs

//! # gunnar-recipe — gunnar's recipe, as code
//!
//! The 2026-08-06 design (`workspace_nordisk/PLAN.md`, *"BUILD THING — MERGAD
//! DESIGN"*) makes a build thing a **published crate**. Not a wasm module riding
//! inside every artifact it built, and not an entry in a register somebody has to
//! search. dwarves either reads a finished artifact's receipt — which names this
//! crate by [`Coordinate`] — or, before any artifact exists, is pointed at the
//! coordinate by hand. There is no third way and no discovery step.
//!
//! ## What this crate answers
//!
//! ```text
//! ATOMS               the five rows: gunnar-server, gunnar, gunnar-cli,
//!                     gunnar-ui (and gunnar-front)
//! COLUMNS             exe, container, iso-installer, iso-live, rpm
//! Atom::assembles     which (row, column) pairs are things that can exist
//! VARIANTS            which of those anybody has actually declared
//! Variant::pane       which panel the scene opens for the finished artifact
//! emit::write_receipt the RECEIPT a real build writes into the artifact
//! ```
//!
//! ## The receipt is written by this crate, from a build script
//!
//! [`emit::write_receipt`] is what `gunnar-cli/build.rs` calls. It resolves WHICH
//! variant is being built out of the features cargo resolved — the five cargo variants
//! have five distinct feature sets, which is what makes them five programs out of
//! one `main.rs` — and hands the answer to `nornir_build_thing::receipt`, which
//! puts it in the binary's `.nordisk.buildthing` section. Nothing declares the
//! variant a second time, because a second declaration is what the receipt exists to
//! stop trusting.
//!
//! ## The two questions this replaces a register with
//!
//! ```text
//! "how was THIS built?"      you hold the artifact  -> read its receipt
//! "what can I build HERE?"   you stand in the tree  -> this crate, in Cargo.toml
//! ```
//!
//! Neither needs a list of every recipe in the world, which is why there is no
//! keyword, no search and no convention file to find.
//!
//! ## Priority
//!
//! `gunnar-server` first — it is the service, and every other atom here is a
//! client of it. Then `gunnar`, the git binary that speaks the 2006 git protocol
//! against it. Columns: `exe`, `container`, `iso-installer`. `rpm` is
//! [last in the universe](atom::Column::Rpm) and nobody builds it.
//!
//! ## THE CODE IS THE SOURCE, and the toml is EMITTED from it
//!
//! `.nornir/build-variants.toml` moved in here and became [`VARIANTS`] plus
//! [`GunnarBuildThing`] — and for three weeks after that it stayed on disk as a
//! **hand-maintained mirror**, with `tests/toml_parity.rs` comparing the two. It
//! is not a mirror any more (2026-08-30): the file is this crate's
//! OUTPUT, written by `cargo run -p gunnar-build-thing -- emit`
//! ([`emit_recipe`]), and read back as authority by nothing.
//!
//! The mirror had to go because it was not merely redundant — it had already
//! lost declarations twice, and both losses were invisible:
//!
//! * An audit found `[build.installer.target]` and `[build.installer.data]` in
//!   the toml and NOT in [`recipe`], with parity GREEN, because the pinned
//!   loader dropped them before the comparison ran;
//! * A later audit measured the whole file refused by the published 0.1.10 reader over
//!   one additive key, and had to write its `[nornir-build-thing] schema` header
//!   by hand — which is what a file with no writer looks like.
//!
//! Both are impossible now. There is one declaration; the file is its
//! projection; the emitter refuses bytes that do not read back EQUAL through the
//! one loader (which carries R1/R2/R3, `nornir_build_thing::schema`), so a group
//! the reader would drop can no longer be emitted OR read. `toml_parity` is a
//! byte-for-byte assertion and fails for any reason the two differ.
//!
//! The reasoning that used to sit between the file's rows is kept verbatim in
//! the reasoning archive; new reasoning goes in the doc comments
//! here, never in the emitted file.
//!
//! `nornir-build-thing 0.1.5` reached crates.io on 2026-08-06, so the sibling
//! `path` dependency that kept this crate detached from gunnar's workspace is
//! gone and it is a normal member again. See `Cargo.toml`.
#![doc(
    html_logo_url = "https://gunnar.rs/download/gunnar.webp",
    html_favicon_url = "https://gunnar.rs/download/favicon.svg"
)]

pub mod atom;
pub mod emit;
/// `code → toml`: the recipe's own emission of `.nornir/build-variants.toml`.
pub mod emit_recipe;
pub mod reachability;
pub mod recipe;
/// Every cargo row and its `cargo install` line (G-BOTH-2).
pub mod twins;
pub mod variants;

pub use atom::{Assembly, Atom, Availability, Column, Suite, ATOMS, COLUMNS, SUITES};
pub use emit::{write_receipt, Attribution, Emitted, RECEIPT_ENV};
pub use emit_recipe::{emitted_toml, write_emitted, EMITTED_HEADER};
pub use reachability::{
    admin_key_argument, app_argv, binds_control_plane_offbox, bound_interface_index,
    check_reachable, declared_interface_count, declares_admin_key, disables_control_plane,
    staged_runtime_path,
};
pub use recipe::{
    admit_limit_param, admit_window_param, iso_kernel_donor, kernel_donor_of, metered_env,
    monetize_pubkey_param, monetize_vital_param, pairing_seed_param, GunnarBuildThing,
    ADMIT_LIMIT_DEFAULT, ADMIT_LIMIT_ENV, ADMIT_LIMIT_PARAM, ADMIT_WINDOW_ENV, ADMIT_WINDOW_PARAM,
    AWS_INSTALLER, AZURE_INSTALLER, AZURE_VOLUME_ID, BILLING_URL_ENV, CONTENT_HASH, FRONT_ENTRY,
    FRONT_INSTALLER, FRONT_METERED_INSTALLER, FRONT_METERED_PAYLOAD_VARIANT,
    FRONT_METERED_VOLUME_ID, FRONT_PAYLOAD_VARIANT, FRONT_VOLUME_ID, GCE_INSTALLER, GCE_VOLUME_ID,
    KRATE, METERED_AWS_INSTALLER, METERED_AWS_VOLUME_ID, METERED_AZURE_INSTALLER,
    METERED_AZURE_VOLUME_ID, METERED_INSTALLER, METERED_VARIANT, METERED_VOLUME_ID,
    MONETIZE_PUBKEY, MONETIZE_PUBKEY_ENV, MONETIZE_PUBKEY_PARAM, MONETIZE_VITAL_DEFAULT,
    MONETIZE_VITAL_ENV, MONETIZE_VITAL_PARAM, OPEN_INSTALLER, OPEN_KORP_ARGS, PAIRING_SEED_PARAM,
    PAIRING_SEED_STAGED_FILE, SEALED_INSTALLER, SEALED_KORP_ARGS,
};
pub use variants::{by_build_point, by_name, by_resolved_features, variants_at, Variant, VARIANTS};

/// `xtask` is written against `nornir_build_thing`'s resolver
/// (`suite_cli::{parse, resolve}`) and its one cargo-driving loop
/// (`build_variant`), and reaching them through this crate is what keeps
/// `xtask/Cargo.toml` at ONE dependency — the repository's own declaration —
/// rather than two. It also makes it impossible for the xtask and the recipe to
/// resolve to different versions of the declaration types. Same line, same
/// reason, as `gunnar-ui-recipe`.
pub use nornir_build_thing;

// **One source, two link targets — and since 2026-09-04, two crates.**
//
// Natively this crate is the `rlib` gunnar's build scripts link. Built for
// `wasm32-unknown-unknown` the same [`GunnarBuildThing`] is the `cdylib` module
// the forge loads and CALLS (dwarves `build_thing::code`, ⚒ and the CLI door
// alike), with no `cfg` fork in the impl, so the pane and the prompt cannot
// disagree about what gunnar can be built into. Template: edda
// `crates/nornir-buildspec-holger`.
//
// What moved is only WHERE the ABI is exported from.
// `nornir_build_thing::export_build_thing!(GunnarBuildThing)` now lives in
// `crates/gunnar-build-thing`, a shim whose entire content is that line plus a
// `pub use` of this crate — because a `crate-type` containing `cdylib` gets no
// `--extra-filename` from cargo, and this crate is reached both as a
// `[build-dependencies]` and as a normal dependency, which under resolver 2 is
// two units writing one filename. MEASURED: `cargo build --workspace` red 6 of
// 8 from cold. See that crate's manifest, and `[lib]` in this one.
//
// The impl stays here. The shim re-exports it, so `GunnarBuildThing` is one
// type with one definition and the two crates cannot drift.

// The contract types, re-exported so a caller needs one dependency rather than
// two to ask this crate a question and act on the answer.
pub use nornir_build_thing::contract::{Coordinate, PaneKind, Receipt, RECEIPT_SECTION};

#[cfg(test)]
mod tests {
    use super::*;
    use nornir_build_thing::plugin::BuildThing;
    use nornir_build_thing::variant::ArtifactKind;

    /// Measured, not asserted, on `variants::EVERY_VARIANT_IS_LEGAL`.
    ///
    /// The `const` assertion stops the build but cannot say which variant it
    /// stopped for — a const panic takes a literal. This is the half that names
    /// it, and it is watched failing on variants built here rather than on the real
    /// table: a check that only ever sees a legal table cannot tell a working
    /// ledger from one that returns `true` unconditionally.
    #[test]
    fn the_legality_check_reports_red_on_an_illegal_variant() {
        let template = VARIANTS[0];

        // A git client shipped as a bootable ISO. Refused by the ledger.
        let illegal_assembly = Variant {
            name: "gunnar-cli-as-a-bootable-iso",
            atom: Atom::GunnarCli,
            column: Column::IsoLive,
            kind: ArtifactKind::Iso,
            ..template
        };
        assert!(
            !illegal_assembly.atom.supports(illegal_assembly.column),
            "Atom::assembles calls (gunnar-cli, iso-live) legal, so the const \
             assertion over VARIANTS can never fail"
        );
        assert!(
            matches!(
                illegal_assembly.atom.assembles(illegal_assembly.column),
                Assembly::Refused(why) if !why.is_empty()
            ),
            "the refusal carries no argument, so a caller cannot report why"
        );

        // An `exe` variant producing an ISO. Not a narrowing, a mislabel.
        let mislabelled = Variant {
            name: "an-exe-that-is-an-iso",
            column: Column::Exe,
            kind: ArtifactKind::Iso,
            ..template
        };
        assert!(
            !mislabelled.column.admits(mislabelled.kind),
            "Column::admits lets an `exe` variant produce an ISO, so a mislabelled \
             variant compiles"
        );

        // And the narrowing that IS allowed still is — otherwise the check above
        // would pass by refusing everything, which is the other way to be blind.
        assert!(
            Column::Exe.admits(ArtifactKind::Service),
            "Column::admits refuses the gunnar-service narrowing, so it is not \
             discriminating, it is just saying no"
        );
        assert!(
            variants::every_variant_is_legal(),
            "the real table is illegal"
        );
    }

    /// The atoms the design names, in the order it names them — plus the one it
    /// did not.
    ///
    /// It was five until 2026-09-20. `gunnar-clock` is the SIXTH, and it is not
    /// a widening of the design: the recipe's own `[build.allow_unbuilt]` said
    /// "no variant row fits it until a front-host atom exists", so the atom is
    /// the thing this table had already written down that it was missing. It
    /// goes LAST, because nothing in this repository is a client of it.
    ///
    /// `gunnar-front` is the SEVENTH (2026-09-25): vm1 boots its own medium,
    /// owner's ruling, and that medium's one program is an atom a machine can
    /// be. After the clock, which it links.
    #[test]
    fn the_atom_list_is_the_five_the_design_names_in_priority_order() {
        let names: Vec<&str> = ATOMS.iter().map(|a| a.as_str()).collect();
        assert_eq!(
            names,
            // Four since GUN-PRODUCTS (2026-10-30): the client-only `gunnar` git binary
            // and `gunnar-solo` are the one program `gunnar-cli` with features now.
            vec!["gunnar-server", "gunnar-cli", "gunnar-ui", "gunnar-front"]
        );
        let prios: Vec<u8> = ATOMS.iter().map(|a| a.priority()).collect();
        assert!(
            prios.windows(2).all(|w| w[0] < w[1]),
            "ATOMS is not in priority order: {prios:?}"
        );
        assert_eq!(
            ATOMS[0].priority(),
            0,
            "gunnar-server is not first, and it is the service"
        );
        for a in ATOMS {
            assert_eq!(
                Atom::parse(a.as_str()),
                Some(*a),
                "{a:?} does not round trip"
            );
        }
        assert_eq!(
            Atom::parse("gunnar-serverr"),
            None,
            "a typo resolved to an atom"
        );
    }

    /// `rpm` is last, and stays last when a sixth column is added.
    ///
    /// Probed: giving `Rpm` the value `4` — the "next number" a reader would
    /// naturally write — passes the first assertion and fails the second, which
    /// is exactly the trap the second one exists for.
    #[test]
    fn rpm_has_the_lowest_priority_in_the_universe() {
        let rpm = Column::Rpm.priority();
        for c in COLUMNS.iter().filter(|c| **c != Column::Rpm) {
            assert!(c.priority() < rpm, "{} outranks rpm", c.as_str());
        }
        assert_eq!(
            rpm,
            u8::MAX,
            "rpm is merely last among today's columns; a sixth column would \
             overtake it"
        );
        assert!(
            !VARIANTS.iter().any(|c| c.column == Column::Rpm),
            "somebody declared an rpm variant; the column exists and nobody builds it"
        );
        for c in COLUMNS {
            assert_eq!(
                Column::parse(c.as_str()),
                Some(*c),
                "{c:?} does not round trip"
            );
        }
    }

    /// Every column maps to the pane the design's switch names, and it maps
    /// through the contract rather than through a copy of the contract.
    #[test]
    fn every_column_opens_the_pane_the_design_names() {
        assert_eq!(Column::Exe.pane(), PaneKind::Exec);
        assert_eq!(Column::Container.pane(), PaneKind::Log);
        assert_eq!(Column::IsoInstaller.pane(), PaneKind::Console);
        assert_eq!(Column::IsoLive.pane(), PaneKind::Console);
        // Nothing to run: an rpm is installed.
        assert_eq!(Column::Rpm.pane(), PaneKind::None);
        // (The one row that narrowed its column's pane, `gunnar-service`, is retired
        // since GUN-PRODUCTS: the systemd service is `gunnar-cli --features systemd`.)
        let server = by_name("gunnar-server").expect("gunnar-server variant");
        assert_eq!(server.pane(), PaneKind::Exec);
    }

    /// The declared variants sit only on legal pairs, and no refused pair is
    /// declared.
    #[test]
    fn the_matrix_agrees_with_itself() {
        for c in VARIANTS {
            assert!(
                c.atom.supports(c.column),
                "variant {} sits on a refused pair ({}, {})",
                c.name,
                c.atom.as_str(),
                c.column.as_str()
            );
        }
        let mut refused = 0usize;
        for a in ATOMS {
            for col in COLUMNS {
                if !a.supports(*col) {
                    refused += 1;
                    assert!(
                        variants_at(*a, *col).is_empty(),
                        "({}, {}) is refused and declared",
                        a.as_str(),
                        col.as_str()
                    );
                }
            }
        }
        assert!(
            refused > 0,
            "the ledger refuses nothing, so the loop above asserted over an \
             empty set and could never report red"
        );
    }

    /// An atom with no variants says so out loud.
    ///
    /// Probed: making `availability` return `Buildable` for everything fails the
    /// first arm; making it return `Planned` for everything fails the second.
    #[test]
    fn an_atom_with_no_build_point_is_declared_planned_not_left_blank() {
        for a in ATOMS {
            let declared = VARIANTS.iter().any(|c| c.atom == *a);
            match a.availability() {
                Availability::Buildable => assert!(
                    declared,
                    "{} claims to be buildable and has no variant",
                    a.as_str()
                ),
                Availability::Planned(why) => {
                    assert!(
                        !declared,
                        "{} is called planned and has a variant",
                        a.as_str()
                    );
                    assert!(
                        !why.is_empty(),
                        "{} is planned for no stated reason",
                        a.as_str()
                    );
                }
            }
        }
        // gunnar-ui grew its build point (the console's product row, GUN-PRODUCTS).
        assert!(
            matches!(Atom::GunnarUi.availability(), Availability::Buildable),
            "gunnar-ui has its row; it is not planned any more"
        );
    }

    /// The coordinate this crate mints parses back to itself, hash and all.
    ///
    /// The three-part form is the one that matters: `krate:version` says which
    /// recipe was intended, and only `krate:version:hash` says which bytes ran.
    /// `receipt::audit_receipt` refuses a receipt without the third part, so a
    /// coordinate that dropped it would take the whole receipt with it.
    #[test]
    fn the_recipe_coordinate_round_trips_through_the_parser() {
        let c = GunnarBuildThing::coordinate();
        assert_eq!(c.krate, KRATE);
        assert_eq!(c.version, env!("CARGO_PKG_VERSION"));
        assert_eq!(
            c.hash.as_deref(),
            Some(CONTENT_HASH),
            "the recipe does not pin its own bytes, and every receipt naming it is refused"
        );
        let text = c.to_string();
        assert_eq!(
            text.split(':').count(),
            3,
            "the coordinate lost a part on the wire: {text}"
        );
        let parsed = Coordinate::parse(&text).expect("its own coordinate must parse");
        assert_eq!(parsed, c);
    }

    /// The built set passes the generic validator it will be handed to.
    ///
    /// This is what makes the migration a migration rather than a rewrite: the
    /// same rules that judged the toml judge the Rust.
    #[test]
    fn the_assembled_set_passes_the_validator_the_toml_passed() {
        let set = GunnarBuildThing.variants();
        assert_eq!(
            set.check_defaults(),
            Vec::<String>::new(),
            "a (suite, role) group has no normal case, or two"
        );
        // Watched failing: a second default in one group is what that check is
        // for, and an empty-problem-list assertion that cannot fill up is blind.
        let mut two_defaults = set.clone();
        two_defaults
            .variants
            .iter_mut()
            .find(|v| v.name == "gunnar-server")
            .expect("the appliance variant")
            .is_default = true;
        assert!(
            !two_defaults.check_defaults().is_empty(),
            "check_defaults accepts two defaults in the server group, so the \
             assertion above can never report red"
        );

        let mut validated = 0usize;
        for c in VARIANTS {
            let v = set
                .get(c.name)
                .unwrap_or_else(|| panic!("{} is missing from the assembled set", c.name));
            if v.package.is_some() {
                set.validate(v)
                    .unwrap_or_else(|e| panic!("{} does not validate: {e}", c.name));
                validated += 1;
            }
        }
        assert_eq!(
            validated, 15,
            "expected the fifteen cargo variants (GUN-PRODUCTS, 2026-10-30: the three products \
             gunnar, gunnar-ui and gunnar-cli; the six appliance rows; the front's two \
             gunnar-cli-musl rows; the four gunnar-front rows) to be validated, not \
             {validated} — a loop that validates nothing passes"
        );

        // Watched failing: a feature outside `offers` is what `validate` is for.
        let mut bad = set
            .get("gunnar-server")
            .expect("the server variant")
            .clone();
        bad.features.push("a-feature-nobody-offers".to_string());
        assert!(
            set.validate(&bad).is_err(),
            "the validator accepts a feature the repo does not offer, so the \
             five passes above mean nothing"
        );
    }

    /// The receipt's settings come from the variant, so they cannot drift from it.
    #[test]
    fn a_variants_resolved_settings_name_the_variant_they_came_from() {
        let server = by_name("gunnar-server").expect("gunnar-server variant");
        let s = server.resolved_settings();
        assert_eq!(s.get("atom").map(String::as_str), Some("gunnar-server"));
        assert_eq!(s.get("column").map(String::as_str), Some("exe"));
        assert_eq!(s.get("cell").map(String::as_str), Some("gunnar-server"));
        assert_eq!(
            s.get("target").map(String::as_str),
            Some("x86_64-unknown-linux-musl"),
            "the musl target is what makes this able to be PID 1"
        );
        assert_eq!(s.get("profile").map(String::as_str), Some("release"));

        // The two variants that share (gunnar-server, exe) are told apart by their
        // settings, which is the whole reason the pair is not a key.
        let metered = by_name("gunnar-server-metered").expect("gunnar-server-metered variant");
        assert_ne!(
            metered.resolved_settings(),
            s,
            "two build points on the same (atom, column) resolve identically, so \
             a receipt cannot say which one ran"
        );
        // Three since 2026-08-27: `gunnar-server`, `gunnar-server-thin` and
        // `gunnar-service` all sit on (gunnar-server, exe) and are told apart by
        // their settings — which is the whole reason the pair is not a key. The
        // thin row is checked against the full one right below, so the raised
        // literal is not the only thing that moved. Five since 2026-09-04: the
        // two metered rows sit on the same pair and differ by `metered`. Seven
        // since 2026-09-30: the two `…-narrate` rows (DEV-NARRATE) differ from
        // their base by `dev-narrate`. Nine since 2026-10-28 (OX-VARIANTS): the two
        // crates.io rows `gunnar-open` and `gunnar-open-client` build package `gunnar`.
        // Seven since GUN-PRODUCTS (2026-10-30): the product `gunnar` and the six
        // appliance rows (`gunnar-service`, `gunnar-open` and `gunnar-open-client` retired).
        assert_eq!(variants_at(Atom::GunnarServer, Column::Exe).len(), 7);
        let thin = by_name("gunnar-server-thin").expect("gunnar-server-thin variant");
        assert_ne!(
            thin.resolved_settings(),
            s,
            "the tunnel-less appliance resolves identically to the full one, so a \
             receipt cannot say which of the two ran"
        );

        // An unset target resolves to a value rather than being absent.
        assert_eq!(
            by_name("gunnar")
                .expect("gunnar variant")
                .resolved_settings()
                .get("target")
                .map(String::as_str),
            Some("host")
        );
    }
}