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