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

nordisk/skade

crates/skade-build-thing/src/recipe.rs

//! **The recipe** — skade's `impl BuildThing`, the ONE declaration of what this
//! repository builds. `.nornir/build-variants.toml` is emitted from
//! [`SkadeBuildThing::variants`] (see [`crate::emit`]); nothing here reads it.
//!
//! # What skade declares, honestly
//!
//! skade is the warehouse ENGINE and the **graph root** of the nordisk suite
//! build matrix. It is TRI-MODAL from the consumer's side — a consumer either
//! compiles skade in-process and owns the `RedbCatalog` directly, or reaches a
//! running `skade-katalog serve` over gRPC / Iceberg-REST — but note whose axis
//! that is: it is the CONSUMER's linkage, not skade's. What skade itself ships is
//! two things, and there are two rows:
//!
//! | variant | kind | package · bin | what it is |
//! |---|---|---|---|
//! | `skade` ⭐ | lib | `skade` | the embeddable Iceberg library, with the `s3` object-store backend |
//! | `skade-remote` ⭐ | service | `skade-katalog-serve` · `skade-katalog` | the catalog service that owns the state |
//!
//! # The root invariant, made unbreakable rather than remembered
//!
//! **Nothing is downstream of skade**, so it embeds no sibling and reaches none.
//! The EMPTY `downstream` allowlist is what turns that from a sentence into a
//! check: any `embeds = [...]` on a skade variant is a dependency-graph inversion
//! that `validate_shape` rejects. The service row states `embeds` and `reaches`
//! as explicitly empty rather than omitting them, for the same reason every other
//! recipe in this constellation states `linkage` — an omitted axis is
//! indistinguishable from one somebody forgot.
//!
//! # `sql` is gone, and the hand-written file kept offering it for nine days
//!
//! The DataFusion surface moved OUT of skade on 2026-08-14 (`0369d66`, "SQL moves
//! out of skade so skade can publish again") into the sibling `skade-sql` crate,
//! because a git datafusion dep with no version makes the whole crate
//! unpublishable. From that day `cargo check -p skade --features s3,sql` failed
//! with *"the package `skade` does not contain this feature: sql"* — which means
//! the DEFAULT library variant, the one the build menu offers, named a build
//! point that could not be built. It was removed on 2026-08-23. It is worth
//! recording here because it is precisely what a hand-maintained recipe cannot
//! catch and an emitted one can: a feature list nothing compiles is a list nobody
//! checks.
//!
//! # The six features nothing builds
//!
//! `aws-s3`, `rustfs`, `lineage`, `stream`, `lineage-http` and `testmatrix` were
//! recorded as declared-but-never-built on 2026-07-22 — in prose. They are
//! [`allow_unbuilt`] data now, each with a written reason.
//!
//! Two feature names the workspace declares are deliberately NOT offered here,
//! and that is stated rather than quietly true: `geo-http` (skade's HTTP
//! byte-range GeoParquet reader) and `bench` (the re-export that lets the
//! out-of-crate nornir-bench harness A/B the partition-scan accelerator). Neither
//! is a shape a variant selects — the first is a consumer's viewer decision, the
//! second exists for a bench harness — and the sibling crates' own feature names
//! (`object-store`, `uring`, `rest`) belong to *their* manifests. Widening
//! `offers` to cover them would make this recipe claim an axis it does not drive.
//! See `.nornir/build-thing-design.md`, "what is deliberately not offered".

use std::collections::{BTreeMap, BTreeSet};

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 = "skade-build-thing";

/// The content hash over this crate's sources, computed by `build.rs`.
pub const CONTENT_HASH: &str = env!("SKADE_BUILD_THING_CONTENT_HASH");

/// **The delivery this repo ships in.** suites is a LIST — one row may ship
/// in more than one flavour — but skade 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";

/// skade's build thing.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SkadeBuildThing;

impl SkadeBuildThing {
    /// 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()
}

/// The three rows.
pub fn variants() -> Vec<BuildVariant> {
    vec![
        BuildVariant {
            name: s("skade"),
            suites: vec![s(SUITE)],
            role: Some(Role::Library),
            is_default: true,
            member: Some(s("skade")),
            package: Some(s("skade")),
            // THE DETACHED-WORKSPACE FIX: skade/, serve/ and skade-flight/ are their
            // own cargo workspaces (so the published skade-katalog manifest never
            // reconciles with the arrow-59 tree). The forge runs ONE cargo at the
            // repo root, where none of these packages exist — `manifest_dir` points
            // it at the workspace that has them. MEASURED (skadefill-compat lane):
            //   cargo build --manifest-path skade/Cargo.toml -p skade --lib --release --features s3
            //     → Finished `release` in 8m29s
            manifest_dir: Some(s("skade")),
            kind: ArtifactKind::Lib,
            shape: ServiceShape::NotApplicable,
            features: vec![s("s3")],
            profile: Profile::Release,
            ..BuildVariant::default()
        },
        BuildVariant {
            name: s("skade-remote"),
            suites: vec![s(SUITE)],
            role: Some(Role::Server),
            is_default: true,
            member: Some(s("skade")),
            package: Some(s("skade-katalog-serve")),
            bin: Some(s("skade-katalog")),
            // `serve`, NOT `skade`: `member` is the SUITE node, the package lives in serve/.
            manifest_dir: Some(s("serve")),
            kind: ArtifactKind::Service,
            shape: ServiceShape::FatService,
            // EMPTY, and said out loud: skade is the graph root, so there is
            // nothing to embed and nothing to reach. `downstream` below makes any
            // future `embeds` a refusal rather than a review comment.
            embeds: BTreeSet::new(),
            reaches: BTreeSet::new(),
            profile: Profile::Release,
            ..BuildVariant::default()
        },
        BuildVariant {
            // The SQL door: Apache Arrow Flight SQL over the warehouse — DataFusion
            // SQL in, streamed Arrow out — for Power BI / ODBC / ADBC / DataFusion
            // clients. This IS "skade remote" as the workspace decided it (skade IS
            // DataFusion; remote = a standalone skade service). Own detached
            // workspace (skade-flight/), like serve/: the datafusion tree never
            // enters the published skade-katalog manifest. Not the server default:
            // skade-remote keeps it. Opens the warehouse itself (one lock-owning
            // RedbCatalog, same as serve) — run it OR serve on one dir, not both.
            name: s("skade-flight"),
            suites: vec![s(SUITE)],
            role: Some(Role::Server),
            is_default: false,
            member: Some(s("skade")),
            package: Some(s("skade-flight")),
            bin: Some(s("skade-flight")),
            manifest_dir: Some(s("skade-flight")),
            kind: ArtifactKind::Service,
            shape: ServiceShape::FatService,
            embeds: BTreeSet::new(),
            reaches: BTreeSet::new(),
            profile: Profile::Release,
            ..BuildVariant::default()
        },
    ]
}

/// The feature space skade offers — the object-store backends plus the
/// lineage/stream/testmatrix seams of the `skade` crate itself.
fn offers() -> Vec<&'static str> {
    vec![
        "s3",
        "aws-s3",
        "rustfs",
        "lineage",
        "stream",
        "lineage-http",
        "testmatrix",
    ]
}

/// **The six features no row builds, each with a written reason.**
pub fn allow_unbuilt() -> BTreeMap<String, String> {
    [
        ("aws-s3", "the aws-sdk-s3 object-store backend, for a consumer that already carries the AWS SDK. WHICH backend is compiled in is the consumer's decision, not skade's — the ⭐ library row picks `s3` (rust-s3, MinIO/S3, configurable endpoint) because that is what this house runs, and a second row would be the same library with a different consumer's answer baked in."),
        ("rustfs", "the embedded local-filesystem ObjectStore — zero extra crates, just std. Same backend-is-the-consumer's-choice reason as `aws-s3`, and it is the one every test uses, so it is compiled constantly even though no delivery names it."),
        ("lineage", "the automatic lineage emit hook on `Table::append`. The lineage TYPES compile regardless; only the emission wiring is gated, so the default build pays nothing — and a shipped library that emitted lineage events to a sink nobody attached would be doing work for no reader."),
        ("stream", "the push CDC consume API (`ChangeStream` + `Table::changes`) over skade-katalog's commit broadcast. Purely additive and off by default for the same reason as `lineage`: the consumer decides whether it wants a changelog stream."),
        ("lineage-http", "the OpenLineage HTTP sink that POSTs each event to a Marquez-style collector. Its own feature so ureq + serde_json stay out of every other build, and enabling it does NOT imply `lineage` — the sink is attached explicitly, so a build point that turned it on would still emit nothing."),
        ("testmatrix", "flips the crate's `functional_status` markers from compiled-out no-ops into real nornir matrix rows. A TEST-HARNESS mode, never a delivery: the write/read data plane must pull no nornir dep."),
    ]
    .into_iter()
    .map(|(k, v)| (s(k), s(v)))
    .collect()
}

/// **The root invariant as data**: nothing is downstream of skade, so the
/// allowlist is empty and any `embeds` is a refusal.
fn downstream() -> BTreeMap<String, BTreeSet<String>> {
    [(s("skade"), BTreeSet::new())].into_iter().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
/// skade 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 whole
/// code-is-source ruling was out of force here for want of one line; the
/// 2026-09-10 inventory render says `toml` where it should say `kod`, for skade
/// 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`: base pinned by digest, a snapshot.debian.org
/// instant, `SOURCE_DATE_EPOCH`), 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 skade 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/skade`),
        // not guessed: the checkouts skade's own `path = "../.."` dependencies
        // reach into. `bench/` is most of it — `jera`, `nornir-hash`, `nornir`,
        // `draupnir` and `znippy-zoomies` — and this crate's own
        // `nornir-build-thing` is the sixth edda dep. A container holding only
        // skade cannot resolve any 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.
        siblings: vec![s("draupnir"), s("edda"), s("nornir"), 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> {
    [
        ("skade-build-thing", "this recipe crate's own emit door (`cargo run -p skade-build-thing -- emit` regenerates .nornir/build-variants.toml); the forge loads the crate as wasm, the bin is repository tooling, never shipped."),
        ("catalog-bench", "bench/ (skade-katalog-bench, a DETACHED workspace kept out of the published crate's tree and lock): the catalog benchmark runner (embedded vs Nessie vs Polaris) — a bench tool, never shipped."),
        ("bench-containers", "bench/ (detached workspace): brings the bench's competitor backends (RustFS, Nessie, Polaris) up and down for catalog-bench — bench 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 SkadeBuildThing {
    fn manifest(&self) -> Manifest {
        Manifest {
            abi: ABI_VERSION,
            repo: s("skade"),
            source_crate: s(KRATE),
            summary: "skade — the warehouse ENGINE and the graph ROOT of the nordisk suite: \
                      a fast, embeddable Apache Iceberg library and the `skade-katalog` \
                      catalog service that owns the state. Nothing is downstream of it."
                .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(),
            downstream: downstream(),
            variants: variants(),
            toolchain: toolchain(),
            ..BuildVariantSet::default()
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use nornir_build_thing::feature_axis::FeatureAxis;

    /// **The graph root embeds nothing, and a variant that tries is refused.**
    /// Stated as a check rather than a sentence, because a dependency-graph
    /// inversion is not something anybody notices by reading.
    #[test]
    fn nothing_is_downstream_of_skade_and_an_embed_is_refused() {
        let set = SkadeBuildThing.variants();
        assert_eq!(
            set.downstream.get("skade").map(BTreeSet::len),
            Some(0),
            "the allowlist must be EMPTY and present — an absent allowlist and an \
             empty one are different claims"
        );
        for v in &set.variants {
            assert!(v.embeds.is_empty() && v.reaches.is_empty(), "'{}'", v.name);
        }
        let inverted = BuildVariant {
            name: s("skade-inverted"),
            package: Some(s("skade-katalog-serve")),
            member: Some(s("skade")),
            kind: ArtifactKind::Service,
            embeds: ["korp"].into_iter().map(s).collect(),
            ..BuildVariant::default()
        };
        assert!(
            set.validate(&inverted).is_err(),
            "a skade variant embedding a sibling inverts the suite dep graph"
        );
    }

    /// **`sql` may not come back by accident.** It moved to the sibling
    /// `skade-sql` crate on 2026-08-14 and the recipe kept offering it for nine
    /// days, during which the ⭐ library row named a build point cargo refused.
    #[test]
    fn the_recipe_does_not_offer_a_feature_this_crate_no_longer_has() {
        let set = SkadeBuildThing.variants();
        assert!(
            !set.offers.contains("sql"),
            "`sql` lives in the sibling skade-sql crate; offering it here makes the \
             default row a build point cargo refuses"
        );
        for v in &set.variants {
            assert!(!v.features.iter().any(|f| f == "sql"), "'{}'", v.name);
        }
    }

    /// The feature axis does not rot: seven offered, one built, six excused by
    /// name — and a stale excuse fails too.
    #[test]
    fn the_feature_axis_gate_is_clean() {
        let set = SkadeBuildThing.variants();
        if let Err(rot) = FeatureAxis::of("skade", &set).gate() {
            panic!("the feature axis is rotting: {rot:#?}");
        }
    }
}