crates/nornir-build-thing/tests/kernel_build_live.rs
//! **HEAVY, LIVE kernel compile — the real thing, `#[ignore]`d.**
//!
//! Compiles a REAL vanilla Linux kernel from a pinned kernel.org tarball inside
//! a podman/Docker toolchain container, through the SAME production path the
//! forge uses: [`UreqFetcher`] (pure-Rust download + host-side sha256 verify) →
//! [`DraupnirKernelRunner`] (draupnir `backend-oci`, bind-mounted workdir,
//! bounded run) → collect `bzImage` + `.config` → [`KernelPin`].
//!
//! ## Why `#[ignore]` + feature-gated
//!
//! This downloads ~150 MB from cdn.kernel.org and compiles a kernel (30+ min).
//! It joins the `--ignored` heavy pass exactly like the sibling `iso_boot` /
//! `boot_matrix` heavies — never a default `cargo test`. The feature gate keeps
//! the default build free of the bollard/tokio stack.
//!
//! ## How to run
//!
//! The tarball pin is NOT baked into this file — a committed example hash would
//! rot the day a mirror re-rolls, and a test that fetches whatever the network
//! answers is exactly what the kind refuses by design. Pin it at the door:
//!
//! ```bash
//! # 1. pick a version + its sha256 from kernel.org's signed sums:
//! # https://cdn.kernel.org/pub/linux/kernel/v6.x/sha256sums.asc
//! export KERNEL_LIVE_VERSION=6.12.4
//! export KERNEL_LIVE_SHA256=<the 64-hex sha256 of linux-6.12.4.tar.xz>
//! # 2. a reachable podman/Docker socket (systemctl --user enable --now podman.socket)
//! # 3. the heavy pass:
//! cargo test -p nornir-build-thing --features kernel-build \
//! --test kernel_build_live -- --ignored --nocapture
//! ```
//!
//! ## The axes, all env-optional
//!
//! `KERNEL_LIVE_PRESET` · `KERNEL_LIVE_PROFILE` · `KERNEL_LIVE_BASE` ·
//! `KERNEL_LIVE_TOOLCHAIN`(+`_SHA256`) · `KERNEL_LIVE_TOOLCHAIN_KIND` ·
//! `KERNEL_LIVE_MINIMAL` · `KERNEL_LIVE_LTO` · `KERNEL_LIVE_CPUS` ·
//! `KERNEL_LIVE_MEM_MB` · `KERNEL_LIVE_NAME` · `KERNEL_LIVE_KEEP`
//! (+ `KERNEL_LIVE_KEEP_LAST`, default 3 — kept workdirs are ~2 GB each and
//! filling a filesystem stops far more than this test), and for the
//! MINIFY pass `KERNEL_LIVE_SOLVER` (+ `_TIMEOUT`, `_VERSION`) and
//! `KERNEL_LIVE_CACHE` (where the shared tarball + kclause-formula cache lives).
//!
//! Missing env / missing socket ⇒ the test SKIPs with a loud note (never a
//! faked green); a wrong pin ⇒ the refusal-by-name this kind exists for.
#![cfg(feature = "kernel-build")]
use nornir_build_thing::kernel::{
build_kernel_with, kconfig_symbol, kclause_cache_entry, kernel_output_dir,
DraupnirKernelRunner, KernelBuild, KernelPin, UreqFetcher, TINYCONFIG_DEPS_FRAGMENT,
};
#[test]
#[ignore = "HEAVY: real kernel.org download (~150MB) + real kernel compile (30+ min) in a \
container. Needs KERNEL_LIVE_VERSION + KERNEL_LIVE_SHA256 env and a podman/Docker \
socket. Run: cargo test -p nornir-build-thing --features kernel-build --test \
kernel_build_live -- --ignored --nocapture"]
fn a_real_vanilla_kernel_compiles_in_a_container() {
let (Ok(version), Ok(sha)) = (
std::env::var("KERNEL_LIVE_VERSION"),
std::env::var("KERNEL_LIVE_SHA256"),
) else {
eprintln!(
"SKIP (not a pass): set KERNEL_LIVE_VERSION and KERNEL_LIVE_SHA256 — take them \
from https://cdn.kernel.org/pub/linux/kernel/v6.x/sha256sums.asc — this test \
refuses to fetch an unpinned kernel, which is the point of the kind."
);
return;
};
// `KERNEL_LIVE_KEEP=<dir>` keeps the workdir (tarball, tree, `.kernel-out/`)
// instead of a tempdir that is gone the instant the test returns — so the
// bzImage the assertions below measured can be looked at afterwards (LAW 2:
// applied output, not a green line). Unset ⇒ the tempdir, exactly as before.
let keep: Option<std::path::PathBuf> = std::env::var_os("KERNEL_LIVE_KEEP").map(Into::into);
// RETENTION. Kept workdirs are 1.9-2.5 GB each and nothing ever swept them:
// on 2026-08-23 eight of them filled the scratch filesystem's quota and
// every tool on the box stopped working until a human ran `rm -rf`. The
// sweep runs BEFORE the build, so the space is free when it is needed
// rather than after. `KERNEL_LIVE_KEEP_LAST=0` is refused by the sweep.
if let Some(dir) = &keep {
if let Some(parent) = dir.parent() {
let n: usize = std::env::var("KERNEL_LIVE_KEEP_LAST")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3);
match nornir_build_thing::kernel::sweep_kept_builds(parent, n) {
Ok(removed) if !removed.is_empty() => eprintln!(
"kernel_build_live: swept {} old kept build(s), keeping the newest {n}",
removed.len()
),
Ok(_) => {}
Err(e) => eprintln!("kernel_build_live: sweep refused: {e}"),
}
}
}
let tmp = if keep.is_none() { Some(tempfile::tempdir().expect("workdir")) } else { None };
let root_path: std::path::PathBuf = match &keep {
Some(dir) => {
std::fs::create_dir_all(dir).expect("KERNEL_LIVE_KEEP dir");
dir.clone()
}
None => tmp.as_ref().expect("tempdir").path().to_path_buf(),
};
// The AXES the e2e matrix drives, all env-optional so the default run is
// byte-identical to what this test always did: profile (vanilla|hardened),
// base (defconfig|tinyconfig), and the toolchain image (the bare-distro
// default, or the built+pinned nordisk one, which builds AIRGAPPED).
let profile = std::env::var("KERNEL_LIVE_PROFILE").unwrap_or_else(|_| "vanilla".into());
let preset = std::env::var("KERNEL_LIVE_PRESET").unwrap_or_else(|_| "normal".into());
// The base override is deliberate here: a preset already implies one, and
// the matrix wants to be able to say "these contents, that starting point"
// explicitly on the receipt rather than inferring it.
let base = std::env::var("KERNEL_LIVE_BASE").ok();
let name = std::env::var("KERNEL_LIVE_NAME").unwrap_or_else(|_| "live-vanilla".into());
let mut k = KernelBuild {
name,
version,
tarball_sha256: sha,
profile,
preset,
base,
..KernelBuild::default()
};
if let Ok(img) = std::env::var("KERNEL_LIVE_TOOLCHAIN") {
// A self-contained image: no packages, therefore no network. If it is
// the nordisk one, the digest pin is REQUIRED (validate refuses it
// unpinned) — which is the whole point of building our own.
k.toolchain_image = img;
k.toolchain_packages = Vec::new();
if let Ok(d) = std::env::var("KERNEL_LIVE_TOOLCHAIN_SHA256") {
k.toolchain_image_sha256 = d;
}
}
if std::env::var("KERNEL_LIVE_PROFILE_RUN").is_ok() {
k.profile_run = true;
}
if std::env::var("KERNEL_LIVE_MINIMAL").is_ok() {
k.minimal = true;
}
if let Ok(t) = std::env::var("KERNEL_LIVE_TOOLCHAIN_KIND") {
k.toolchain = t;
}
if std::env::var("KERNEL_LIVE_LTO").is_ok() {
k.lto = true;
}
// MINIFY (design §7 step 6): the SAT pass over the Kconfig DAG, in the SAME
// bounded container. Needs an image that carries kmax — the nordisk
// toolchain one does, and `validate()` refuses the bare-distro default by
// name rather than dying on `klocalizer: not found` after a 148 MB fetch.
if let Ok(sv) = std::env::var("KERNEL_LIVE_SOLVER") {
k.solver = sv;
}
if let Ok(t) = std::env::var("KERNEL_LIVE_SOLVER_TIMEOUT") {
k.solver_timeout_secs = t.parse().expect("KERNEL_LIVE_SOLVER_TIMEOUT is seconds");
}
if let Ok(v) = std::env::var("KERNEL_LIVE_SOLVER_VERSION") {
k.solver_version = v;
}
// `KERNEL_LIVE_CACHE=<dir>` moves the SHARED cache (tarball + kclause
// formula) off the default `~/.cache/nordisk/kernel` — the knob a run on a
// box with a small home needs.
if let Ok(c) = std::env::var("KERNEL_LIVE_CACHE") {
k.cache_dir = Some(c);
}
if let Ok(c) = std::env::var("KERNEL_LIVE_CPUS") {
k.cpus = c.parse().expect("KERNEL_LIVE_CPUS is a whole number of cores");
}
if let Ok(m) = std::env::var("KERNEL_LIVE_MEM_MB") {
k.mem_limit_mb = m.parse().expect("KERNEL_LIVE_MEM_MB is MiB");
}
k.validate().expect("the declaration the live run was asked for");
eprintln!(
"kernel_build_live: {} · preset={} · profile={} · base={} · profile_run={} · minimal={} · toolchain={} · lto={} · solver={:?} · image={} · -j{} · {} MiB · network={}",
k.name,
k.preset,
k.profile,
k.base(),
k.profile_run,
k.minimal,
k.toolchain,
k.lto,
k.solver,
k.toolchain_image_ref(),
k.cpus,
k.mem_limit_mb,
k.needs_network()
);
let started = std::time::Instant::now();
let fetcher = UreqFetcher;
let runner = DraupnirKernelRunner::new();
let artifacts =
build_kernel_with(&fetcher, &runner, &k, &root_path).expect("the live kernel build");
let wall = started.elapsed();
// With MINIFY on there is a FOURTH artifact: the solved dependency closure,
// the fragment an operator commits (`deps-<version>.config`).
assert_eq!(
artifacts.len(),
if k.solves() { 4 } else { 3 },
"bzImage + kernel.config + pin{}",
if k.solves() { " + the solved closure" } else { "" }
);
let out = kernel_output_dir(&root_path, &k);
let bz = std::fs::metadata(out.join("bzImage")).expect("bzImage on disk");
assert!(bz.len() > 1_000_000, "a real bzImage is megabytes, got {} bytes", bz.len());
let config =
std::fs::read_to_string(out.join("kernel.config")).expect("kernel.config on disk");
// ★ The boot contract is PRESET-AWARE, and the two arms are counterweights.
//
// MEASURED 2026-08-23, the first time anybody built `full`: 13 693 952 B in
// 246 s, PID 1 alive, console + virtio-blk present at probe — and
// `CONFIG_TUN` NOT builtin, so the probe reported `tun ABSENT`. That is
// correct: `full` is plain `defconfig` with NO constellation contract, and
// on x86 defconfig TUN is a MODULE (`MODULES=y` there) while VIRTIO_NET,
// VIRTIO_BLK and VFAT_FS happen to be `=y` anyway.
//
// Before this, the assertion below ran on every preset and this test was RED
// on a kernel that had built and booted perfectly — the TEST was wrong, not
// the build. Asserting the ABSENCE on `full` is what proves `base.config` is
// doing work rather than restating what defconfig already gives.
if k.preset == "full" {
assert!(
!config.contains("CONFIG_TUN=y"),
"`full` is defconfig with NO contract: if TUN is builtin here, either \
defconfig changed or a contract fragment leaked into the preset that \
exists to have none"
);
} else {
// The contract carriers. `tinyconfig` reaching this line at all means the
// canary let it through, so the contract must be there too — a tinyconfig
// build that silently dropped virtio would be the exact silent degrade the
// canary exists to prevent, and it must not pass as a green.
for needed in
["CONFIG_VIRTIO_BLK=y", "CONFIG_VIRTIO_NET=y", "CONFIG_TUN=y", "CONFIG_VFAT_FS=y"]
{
assert!(config.contains(needed), "built .config must carry {needed}");
}
}
let pin: KernelPin = serde_json::from_str(
&std::fs::read_to_string(out.join(format!("{}.kernel-pin.json", k.name))).expect("pin"),
)
.expect("pin parses");
assert_eq!(pin.kernel_version, k.version);
assert_eq!(pin.tarball_sha256, k.tarball_sha256);
assert_eq!(pin.solver, k.solver, "the receipt records whether this kernel was minified");
eprintln!(
"kernel_build_live: bzImage {} bytes in {:.1} s at {} (kept: {})",
bz.len(),
wall.as_secs_f64(),
out.display(),
keep.is_some()
);
// ── the MINIFY receipt ──────────────────────────────────────────────────
//
// The closure is the DELIVERABLE of the SAT pass, so it is measured, not
// assumed — and it is measured against the list a person closed by hand from
// twelve canary refusals (`src/kconfig/deps-tinyconfig.config`, 2026-08-22).
// Agreement and disagreement are both findings; the run prints whichever it
// gets rather than asserting the answer it hoped for.
if k.solves() {
let closure = std::fs::read_to_string(out.join(format!("deps-{}.config", k.version)))
.expect("the solved closure on disk");
let solved: std::collections::BTreeSet<&str> =
closure.lines().filter_map(kconfig_symbol).collect();
assert!(!solved.is_empty(), "a closure with no symbols closed nothing:\n{closure}");
// The fragment this build actually merged — the solver only closes what
// the fragment NAMES, so what the fragment already named is not a
// finding about the solver, and reporting it as one would be a lie in
// either direction.
let merged = std::fs::read_to_string(out.join("work").join("fragment.config"))
.expect("the merged fragment the build applied");
let declared: std::collections::BTreeSet<&str> =
merged.lines().filter_map(kconfig_symbol).collect();
let by_hand: std::collections::BTreeSet<&str> =
TINYCONFIG_DEPS_FRAGMENT.lines().filter_map(kconfig_symbol).collect();
let already: Vec<&str> = by_hand.intersection(&declared).copied().collect();
let found: Vec<&str> =
by_hand.iter().filter(|s| !declared.contains(*s) && solved.contains(*s)).copied().collect();
let missed: Vec<&str> = by_hand
.iter()
.filter(|s| !declared.contains(*s) && !solved.contains(*s))
.copied()
.collect();
eprintln!(
"kernel_build_live: MINIFY {} — solved closure {} symbols\n \
vs src/kconfig/deps-tinyconfig.config ({} lines closed BY HAND from twelve canary refusals):\n \
already in this build's fragment (not the solver's to find): {}\n \
FOUND by the solver on its own: {:?}\n \
MISSED by the solver: {:?}",
k.solver,
solved.len(),
by_hand.len(),
already.len(),
found,
missed
);
let entry =
kclause_cache_entry(&k.cache_dir(&root_path), &k.tarball_sha256, &k.solver_version);
assert!(entry.is_dir(), "the formula is cached for the next build: {}", entry.display());
eprintln!(
"kernel_build_live: MINIFY formula cached at {} ({} bytes)",
entry.display(),
dir_bytes(&entry)
);
}
}
/// Every byte under `dir`, recursively — the formula lives two directories down
/// (klocalizer keys it by a build-system id), so a flat read would report the
/// size of nothing.
fn dir_bytes(dir: &std::path::Path) -> u64 {
let Ok(rd) = std::fs::read_dir(dir) else { return 0 };
rd.flatten()
.map(|e| match e.file_type() {
Ok(t) if t.is_dir() => dir_bytes(&e.path()),
_ => e.metadata().map(|m| m.len()).unwrap_or(0),
})
.sum()
}