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_offline_live.rs

//! **The AIRGAP proof — a real kernel, compiled with the network genuinely gone
//! (`#14`, lane AO 2026-08-30).**
//!
//! ## What this test is, and what it refuses to be
//!
//! "We did not observe a fetch" is not evidence of anything: a build that never
//! needed the network on the day it ran looks exactly like a build that cannot
//! need it. So this test does not watch for fetches. It runs the **same build
//! twice under the same denial**, and the only difference between the two runs
//! is whether the staged toolchain archive is on the disk:
//!
//! ```text
//! RED    the archive moved aside  ->  NORDISK-TOOLCHAIN-MISSING, no bzImage
//! GREEN  the archive put back     ->  a bzImage, and a pin saying `staged-archive`
//! ```
//!
//! One of the two arms must fail, or the other one proves nothing. That is the
//! whole design (LAW 2): a green with no red beside it cannot tell a working
//! offline path from a box that happened to have everything already.
//!
//! ## How the network is denied — and what the denial actually covers
//!
//! This test does **not** deny the network itself; a process cannot honestly
//! grade its own airgap. The harness around it does, and the harness must deny
//! it to the CONTAINER ENGINE and not merely to this process — a podman socket
//! is a filesystem path, so a test in a network namespace still talks to a
//! service that may have a perfectly good route to a registry, and a "silent
//! fallback to the network" would then succeed unseen.
//!
//! So the recipe is: a **fresh, empty image store** served by a podman that is
//! **itself inside the denied namespace**.
//!
//! ```bash
//! D=~/scratch/laneAO
//! cat > $D/denied.sh <<'SH'
//! #!/bin/bash
//! exec sudo -n unshare --net -- setpriv --reuid=1000 --regid=1000 --init-groups "$@"
//! SH
//! chmod +x $D/denied.sh
//!
//! # a podman with no route, no addresses, no DNS, and an EMPTY store:
//! setsid nohup $D/denied.sh env HOME=$HOME XDG_RUNTIME_DIR=/run/user/$(id -u) \
//!   podman --root $D/freshroot --runroot $D/freshrun \
//!   system service --time 0 unix://$D/sock/podman.sock &
//!
//! # prove the denial before trusting it (this MUST fail):
//! podman --url unix://$D/sock/podman.sock pull docker.io/library/alpine:latest
//! #   -> dial tcp: lookup registry-1.docker.io: Temporary failure in name resolution
//!
//! # stage the toolchain the courier is meant to have delivered:
//! podman save --format docker-archive -o $CACHE/images/$ID.tar $TOOLCHAIN
//! #   $ID = podman image inspect --format '{{.Id}}' $TOOLCHAIN
//!
//! DOCKER_HOST=unix://$D/sock/podman.sock \
//! KERNEL_LIVE_VERSION=6.12.104 KERNEL_LIVE_SHA256=<the pinned sha256> \
//! KERNEL_OFFLINE_TOOLCHAIN_ID=$ID KERNEL_LIVE_CACHE=$CACHE \
//!   cargo test -p nornir-build-thing --features kernel-build \
//!     --test kernel_offline_live -- --ignored --nocapture
//! ```
//!
//! The kernel tarball must already be in `$CACHE/tarballs/` — it is
//! content-addressed and hash-verified, so a courier fills it exactly the same
//! way, and a build that had to download it under this denial would fail for the
//! wrong reason and tell us nothing about the toolchain.
//!
//! ## The honest limit
//!
//! The denial is a network namespace and a store with nothing in it. It does not
//! simulate a machine with no podman at all (that is answer (a) in
//! `.nornir/kernel-guide.md`, a vendored toolchain tarball, deliberately not
//! built), and it does not prove anything about a build whose declaration sets
//! `toolchain_packages` — that one asks for `apt-get` and says so
//! (`KernelBuild::needs_network`).
#![cfg(feature = "kernel-build")]

use nornir_build_thing::kernel::{
    DraupnirKernelRunner, KernelBuild, KernelPin, UreqFetcher, build_kernel_with,
    kernel_output_dir,
};
use nornir_build_thing::toolchain_archive::{TOOLCHAIN_MISSING, toolchain_archive_entry};

/// The declaration both arms build, so the only variable is the archive.
fn declaration(version: String, sha: String, id: &str, cache: &str) -> KernelBuild {
    KernelBuild {
        name: "offline-kernel".into(),
        version,
        tarball_sha256: sha,
        // `nuked` is the cheapest kernel that is still a kernel: this test is
        // about how the TOOLCHAIN got onto the box, and a 40-minute `full`
        // build would measure the compiler instead.
        preset: "nuked".into(),
        toolchain_image: "nordisk-kernel-toolchain".into(),
        toolchain_image_sha256: id.to_string(),
        cache_dir: Some(cache.to_string()),
        // Empty on purpose: a declaration with `toolchain_packages` asks for a
        // package index and `needs_network()` says so. Nothing here does.
        toolchain_packages: Vec::new(),
        cpus: std::env::var("KERNEL_LIVE_CPUS").ok().and_then(|v| v.parse().ok()).unwrap_or(8),
        mem_limit_mb: std::env::var("KERNEL_LIVE_MEM_MB")
            .ok()
            .and_then(|v| v.parse().ok())
            .unwrap_or(8192),
        ..KernelBuild::default()
    }
}

#[test]
#[ignore = "HEAVY + needs a DENIED network: a real kernel compile against a podman that has no \
            route and an empty image store. See this file's header for the harness. Needs \
            KERNEL_LIVE_VERSION, KERNEL_LIVE_SHA256, KERNEL_OFFLINE_TOOLCHAIN_ID, \
            KERNEL_LIVE_CACHE and DOCKER_HOST."]
fn a_kernel_composes_with_no_network_from_a_staged_toolchain_archive() {
    let (Ok(version), Ok(sha), Ok(id), Ok(cache)) = (
        std::env::var("KERNEL_LIVE_VERSION"),
        std::env::var("KERNEL_LIVE_SHA256"),
        std::env::var("KERNEL_OFFLINE_TOOLCHAIN_ID"),
        std::env::var("KERNEL_LIVE_CACHE"),
    ) else {
        eprintln!(
            "SKIP (not a pass): this test needs KERNEL_LIVE_VERSION, KERNEL_LIVE_SHA256, \
             KERNEL_OFFLINE_TOOLCHAIN_ID (the toolchain image's ID — `podman image inspect \
             --format '{{{{.Id}}}}'`, NOT its manifest digest) and KERNEL_LIVE_CACHE. It \
             refuses to invent them, because a test that guesses its own pin is a test that \
             cannot fail."
        );
        return;
    };

    let archive = toolchain_archive_entry(std::path::Path::new(&cache), &id);
    assert!(
        archive.is_file(),
        "the staged toolchain archive must exist before this test can say anything: {}\n\
         produce it with:\n  podman save --format docker-archive -o {} nordisk-kernel-toolchain",
        archive.display(),
        archive.display()
    );
    let aside = archive.with_extension("tar.aside");

    let root = tempfile::tempdir().expect("build root");
    let k = declaration(version, sha, &id, &cache);
    let fetcher = UreqFetcher;
    let runner = DraupnirKernelRunner::new();

    // ── RED ──────────────────────────────────────────────────────────────────
    // The same build, the same denial, the archive moved aside. This must refuse
    // BY NAME. If it succeeds, the box had the toolchain anyway and the GREEN
    // below would have proved nothing; if it fails for another reason, the
    // offline path is not what was measured.
    std::fs::rename(&archive, &aside).expect("move the archive aside");
    let red = build_kernel_with(&fetcher, &runner, &k, root.path());
    // Put it back before asserting, so a failed assertion does not leave the
    // cache broken for the next run.
    std::fs::rename(&aside, &archive).expect("put the archive back");

    let red_err = match red {
        Ok(_) => panic!(
            "RED ARM FAILED TO BE RED: the kernel built with no staged archive and no network. \
             Either the toolchain was already in this engine's store (use a FRESH --root) or \
             the engine has a route to a registry (deny the network to the SERVICE, not just \
             to this process). Nothing about the offline path has been measured."
        ),
        Err(e) => format!("{e:#}"),
    };
    assert!(
        red_err.contains(TOOLCHAIN_MISSING),
        "the red arm must be the toolchain refusal and not something else \
         (a tarball that had to be downloaded, a full disk, a dead socket):\n{red_err}"
    );
    assert!(
        red_err.contains(&archive.display().to_string()),
        "the refusal must name the exact path a courier has to fill:\n{red_err}"
    );
    assert!(
        !kernel_output_dir(root.path(), &k).join("bzImage").exists(),
        "the red arm must not leave a bzImage behind"
    );
    eprintln!("RED  ok: {}", red_err.lines().next().unwrap_or_default());

    // ── GREEN ────────────────────────────────────────────────────────────────
    // Byte-identical call. The only thing that changed is that the file is back.
    let artifacts = build_kernel_with(&fetcher, &runner, &k, root.path())
        .expect("with the archive staged, the same build must complete offline");

    let out = kernel_output_dir(root.path(), &k);
    let bz = out.join("bzImage");
    let bz_len = std::fs::metadata(&bz).expect("a bzImage").len();
    assert!(bz_len > 256 * 1024, "a bzImage of {bz_len} bytes is not a kernel");
    assert!(
        artifacts.iter().any(|a| a.path.ends_with("bzImage")),
        "the produced artifacts must include the bzImage: {artifacts:?}"
    );

    // The RECEIPT is the deliverable, not the exit code: it has to say the
    // toolchain came from the staged archive, or a later reader cannot tell this
    // build from one that pulled.
    let pin_text = std::fs::read_to_string(out.join(format!("{}.kernel-pin.json", k.name)))
        .expect("the kernel pin");
    let pin: KernelPin = serde_json::from_str(&pin_text).expect("parse the kernel pin");
    assert_eq!(
        pin.toolchain_source, "staged-archive",
        "the receipt must record HOW the toolchain got here:\n{pin_text}"
    );
    assert_eq!(pin.toolchain_image_id, id, "the receipt must name the image id it resolved");
    assert_eq!(
        pin.toolchain_image,
        format!("sha256:{id}"),
        "an image that arrived as an archive is addressed by id — the declared \
         name@sha256:<manifest digest> resolves to nothing on a box that did not mint it"
    );
    eprintln!(
        "GREEN ok: bzImage {} bytes, toolchain {} ({})",
        bz_len, pin.toolchain_source, pin.toolchain_image
    );
}

/// **The DOOR that produces the archive, driven live.**
///
/// A door nobody can execute reads exactly like a law being obeyed — the whole
/// reason `toolchain_images_live.rs` exists — and an offline path whose archive
/// can only be made with a shell command in a document is that same defect one
/// step further out. So the export leg is exercised through
/// [`stage_toolchain_archive`] against a real engine, and the assertion is the
/// ARTIFACT: the file it wrote is read back with **no daemon** and must name the
/// image the engine says it exported.
///
/// Needs an engine that HAS `KERNEL_OFFLINE_TOOLCHAIN` (default
/// `nordisk-kernel-toolchain`) — the ONLINE side of the courier, so this one is
/// run on the box that mints the image, not on the airgapped one.
#[test]
#[ignore = "HEAVY: exports a ~1.2 GB image archive. Needs an engine holding \
            KERNEL_OFFLINE_TOOLCHAIN. Run: cargo test -p nornir-build-thing --features \
            kernel-build --test kernel_offline_live -- --ignored --nocapture"]
fn the_staging_door_writes_an_archive_that_names_the_image_it_holds() {
    use nornir_build_thing::toolchain_archive::{
        ToolchainImageStore, stage_toolchain_archive, toolchain_archive_entry,
    };

    // FULLY QUALIFIED on purpose. podman normalises a short name on the way out
    // (`nordisk-kernel-toolchain` -> `docker.io/library/nordisk-kernel-toolchain:latest`)
    // and draupnir's export refuses to file an archive under a tag the archive
    // does not carry. Measured here, first run, 2026-08-30 — so the door is
    // exercised with the spelling an operator must actually use.
    let image = std::env::var("KERNEL_OFFLINE_TOOLCHAIN")
        .unwrap_or_else(|_| "docker.io/library/nordisk-kernel-toolchain:latest".into());
    let runner = DraupnirKernelRunner::new();
    match runner.image_present(&image) {
        Ok(true) => {}
        Ok(false) => {
            eprintln!("SKIP (not a pass): this engine does not hold `{image}` — nothing to stage.");
            return;
        }
        Err(e) => {
            eprintln!("SKIP (not a pass): no reachable container engine: {e:#}");
            return;
        }
    }

    let cache = tempfile::tempdir().expect("cache");
    let written = stage_toolchain_archive(&runner, &image, cache.path()).expect("stage the archive");

    // The file is named by its CONTENT, and the content is what the engine says
    // it is — read back through the daemon-free reader an airgapped box uses,
    // never through the exporter's own return value alone.
    let held = runner.inspect_archive(&written).expect("read the archive back with no daemon");
    assert_eq!(
        written,
        toolchain_archive_entry(cache.path(), &held.config_digest),
        "the archive must be filed under the id it actually holds"
    );
    assert!(held.bytes > 1024 * 1024, "an archive of {} bytes is not an image", held.bytes);
    let bare = image.rsplit('/').next().unwrap_or(&image).split(':').next().unwrap_or(&image);
    assert!(
        held.tags.iter().any(|t| t.contains(bare)),
        "the archive must carry the image's own tag, or a courier cannot tell what it \
         delivered: {:?}",
        held.tags
    );
    eprintln!(
        "staged {} ({} bytes, id {}, tags {:?})",
        written.display(),
        held.bytes,
        held.config_digest,
        held.tags
    );
}