nornir-build-thing
The reusable, dependency-light core of nornir's build-thing (task #36): declarative cargo build variants + a content-addressed build-provenance attestation (SLSA-style build-info). Extracted as a leaf crate — mirroring nornir-testmatrix — so a leaf repo's xtask can build a declared variant and capture provenance without nornir's iceberg / arrow / eframe stack.
What a repo declares
A product ships several distinct executables from one source tree. The driving example is holger's three split exes — embedded, minimal, full — each a distinct (package × bin × features × profile × target) point, declared as data in a [build] / [[build.variant]] TOML table (in nornir.toml or a standalone .nornir/build-variants.toml):
[build]
offers = ["embedded", "minimal", "full", "served", "gui"]
exclusive = [["embedded", "minimal", "full"]] # a build is exactly one fatness
[[build.variant]]
name = "full"
package = "holger-full"
bin = "holger-full"
features = ["full", "served", "gui"]
profile = "release"
The headline call
use nornir_build_thing::{BuildVariantSet, build_variant};
let set = BuildVariantSet::discover(repo_root)?.unwrap_or_default();
let outcome = build_variant(&set, "full", repo_root)?; // validate → cargo build → capture
println!("{}", outcome.attestation.digest_hex());
build_variant validates the chosen variant against the set's rules, renders the exact cargo build argv, runs it (with --message-format=json so the produced executables are content-hashed), and returns a BuildOutcome carrying the artifacts plus an unsealed provenance Attestation.
Declared parameters and fixtures
[[build.installer.params]] / [[build.iso.params]] declare a cell's install-time inputs (a site name on the cmdline, a hub.conf staged onto the medium); the CLI and the dwarves pane supply them as --param name=value / name=@path. A cell may also declare a fixture the forge mints itself — [build.installer.fixtures] hub_conf = "wg-hub" — filled with --param hub_conf=@fixture; the record says the value came from that fixture. Minting is injected through params::FixtureSource / InstallerComposer::fixtures() (korp-installer's wgfixture is the one implementation), so this crate pulls no WireGuard. See params.rs.
Provenance
A BuildProvenance record captures exactly what a build produced:
- the chosen variant descriptor, verbatim;
- the resolved inputs — cargo argv, features, profile, target, VCS rev + branch + dirty flag + working-tree digest;
- the toolchain (
rustc/cargo/ host triple); - the host (cores + memory);
- the outcome + the SHA-256 content hash of every artifact.
record.attest() folds the record to canonical bytes and a SHA-256 digest; a ProvenanceSealer binds a detached seal to that digest. The real, cryptographic sealer is znippy's detached-CMS signer — wired by nornir behind its build-provenance feature (nornir::build::seal); this crate stays dep-light and never fabricates a signature.
Modules
| module | what |
variant |
the descriptor, validator, and cargo materializer |
provenance |
BuildProvenance, Attestation, the ProvenanceSealer seam, build_variant → BuildOutcome |
surface |
the data-driven UI surface (BuildSurface) a viz tab / CLI --json / MCP tool renders |
sink |
the BuildProvenanceSink warehouse seam + dep-light NullSink / JsonFileSink |
elm |
the build-thing UI component (facett Elm pure core: Model / Msg / update) |
kernel_trim |
the measured kernel trim: kernel what-if <row> --drop CONFIG_X / candidates (static half, over the composed row kernel Kconfig graph), kernel coverage (the DEV gcov probe kernel through the row's suite), kernel veto / vetoes (one veto per run, each with a receipt) — see below |
syscall_join |
the join of measured syscalls with kernel options: kernel needs <row>, and the measured-call veto inside what-if / candidates (see below) |
measure |
the measure step: measure syscalls <row> [--check] runs a row's programs under the EXTERNAL heimdall tool and writes seccomp profiles beside the row (see below) |
No heavy deps: VCS state is read via the git CLI and host facts via std + /proc, so there is no gix dependency.
The measured kernel trim: coverage and vetoes
kernel_trim answers what a row's kernel COULD drop (the static half: what-if, candidates, the inverse check). Two more verbs turn a candidate into a row veto, on evidence, one at a time:
kernel coverage <row> --run <suite>(kernel_trim::coverage). The row's media are built with the DEV probe kernel--opt kernel.coverage=gcov(GCOV_KERNEL+GCOV_PROFILE_ALL+DEBUG_FS, KASLR off, merged after every other layer, its own content id). A build of it is refused by name for a release medium (NORDISK_MEDIA_KIND=release) or a real root key (KORP_ROOT_SIGNING_KEYnotdev). The row's suite (aSuiteRunner; the live one,ScriptSuite, is the row repo's own KVM script) boots it, runs the checks, and saves the kernel image (_text.._end) out of each VM after each stage with the QEMU monitor.walk_gcovfollows the kernel's owngcov_infolist in the saved image (the layout ofkernel/gcov/gcc_4_7.cfor the GCC invmlinux's.comment; refused when the image'slinux_banneris not the vmlinux's). Nothing runs in the guest. Each instrumented object maps to theCONFIG_*symbols that gate it (the static half's Makefile gate map, directory gates and composite parents): a symbol is executed, never executed (instrumented objects, none ran) or unmeasured (no object of its own:#ifdefcode in files that stay). The report, with the stages that ran each symbol and the checks NOT run, is.nornir/kernel-coverage/<row>/<sha12 of the row's fragment>.json.kernel candidatesthen shows each candidate's coverage and names the veto-ready ones.kernel veto <row> CONFIG_X [--run <suite>](kernel_trim::veto). ONE symbol per run. Refused by name when it is on the row's protect list (.nornir/kernel-vetoes/<row>/protect.list,CONFIG_*globs with a reason; the probe's own symbols are always protected), when no green coverage report was measured on the row's current fragment, when the symbol was not measured never-executed, when anything executed or protected would leave with it, or when the what-if is not clean. Then the vetoed kernel is composed (the inverse check must hold: that is the bzImage delta), the line is added to the row's veto list (VetoStore; a recipe lands it withveto_requirementsas a=nrequirement on the row's kernel edge), and the suite reruns. Green keeps the veto; red reverts the list. Either way a receipt is written to.nornir/kernel-vetoes/<row>/receipts/<date>-<CONFIG_X>.json: fragment sha before and after, bzImage before/after/delta, the stages, the checks not run, the coverage report it was chosen on, the date and the tool versions.kernel vetoes <row>lists the receipts and the current list.
The measure step and the licence boundary
measure syscalls <row> [--check] (module measure, Heimdall tooling plan phase 1) runs every program of a row under Heimdall's syscall probe over the runs in .nornir/profiles/<row>/measure.toml, and writes per program <arch>-<libc>.{probe.json,json,oci.json,rs} beside the row plus the evidence measure.json. --check fails on drift, naming the call and the test that made it.
This crate is MIT OR Apache-2.0; Heimdall is AGPL-3.0-only. This crate never links a heimdall crate. It runs the heimdall binary (cargo install nordisk-heimdall-cli --version 0.2.0 --locked) as a separate process and reads the JSON files it writes as data. The row pins the tool by version and sha256; a missing tool, another version or another build is refused in one line that names the install command. The generated .rs names heimdall_seccomp types: only code that already links Heimdall includes it; everything else reads the .json / .oci.json.
The join: measured syscalls and kernel options
Module syscall_join (Heimdall tooling plan step 3) joins what the measure step recorded with the composed row kernel's OWN source tree. Nothing is hand-written:
- The map (
SyscallMap, schemanornir.syscall-map/1): every x86_64 call ofarch/x86/entry/syscalls/syscall_64.tbl(common+64), the file that defines its entry point (SYSCALL_DEFINEn;COMPAT_SYSCALL_DEFINEnis kept and marked, it is not an x86_64 call), theCONFIG_*gates of that object (the Makefiles'obj-$(CONFIG_X)lines, ancestor directories and composite parents, read withkernel_trim::makefile_gates), the#ifdef CONFIG_Xblocks around the definition, and whetherkernel/sys_ni.chas aCOND_SYSCALLstub (then a call whose option is off answers ENOSYS). It is cached per kernel version under the trim's work dir (.kernel-trim/syscall-map/<version>-x86_64.json). kernel needs <row> [--json](schemanornir.kernel-needs/1): every measured call of every program (.nornir/profiles/<row>/measure.jsonand each program's.probe.json, read as data) -> the options it needs, by program and call (tunnr-cni bpf -> CONFIG_BPF, CONFIG_BPF_SYSCALL (test …)) and by option (an option the row has OFF is shouted). The inverse hint: syscall-gating options the row enables that no measured call needs ("no measured caller"; feeds the trim candidates, never auto-vetoed).- The veto inside
kernel what-ifandkernel candidates: a drop whose leaving symbols take away a measured call's kernel code is REFUSED by name (CONFIG_BPF_SYSCALL: tunnr-cni calls bpf (test …)) and--measurenever builds it; a call made only by error-path runs (near-miss) warns instead. Candidates that would be refused leave the ranking and are listed by name; the others carrynear-missorno measured callernotes.--jsongivesnornir.kernel-what-if/1/nornir.kernel-candidates/1.
Limits, stated: Makefile ifdef blocks and #if … || … / #elif branches give no gate, and code paths inside a call (a bpf command that needs CONFIG_CGROUP_BPF) are not split out: the join answers "is the call there at all". Programs that run only inside the booted appliance are not measured yet and are named (vm_later).