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

nordisk/edda

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