src/demo_container.rs
//! AIRGAP9 (demo) — the **korp-demo + facett-demo tunnr container**, plus the
//! **data components** it ships (the Europe basemap), all pure Rust.
//!
//! A demo bundle carries two kinds of payload, content-addressed in ONE
//! [`archive`](crate::archive) bundle:
//!
//! - **binaries** — the demo appliances (korp-demo, facett-demo), placed under
//! `linux-x86_64/<name>` in the bundle; and
//! - **data components** — large read-only data files (e.g. the Europe
//! `europe.parquet` basemap) placed under `data/<name>/<file>`, each with a
//! sidecar placement manifest at `data-manifests/<name>.json` that records
//! where the file must land on the target and which env var can redirect it.
//!
//! ```text
//! pack_with_data archive (tar+zstd) tunnr PID 1
//! ┌───────────────────┐ ┌──────────────────────────┐ ┌──────────────────────┐
//! │ Staging │ → │ TarZstd::pack → bundle │ → │ install_data_on_boot │
//! │ linux-x86_64/… │ │ (content-addressed: │ │ reads data-manifests│
//! │ data/korp-map/… │ │ binaries + data + their │ │ places each file at │
//! │ data-manifests/… │ │ placement manifests) │ │ its read path │
//! └───────────────────┘ └──────────────────────────┘ └──────────────────────┘
//! ```
//!
//! [`DemoContainer::pack`](crate::demo_container::DemoContainer::pack) builds a binaries-only bundle; [`DemoContainer::pack_with_data`](crate::demo_container::DemoContainer::pack_with_data)
//! additionally packs one or more data components. [`DemoContainer::install_data_on_boot`](crate::demo_container::DemoContainer::install_data_on_boot)
//! is the **tunnr-PID-1 data-placement entrypoint**: it reads each placement
//! manifest back out of the bundle and extracts the packed file, byte-identical,
//! to its resolved on-target read path (an env override wins, so tests/operators
//! can redirect the placement without touching `/var/lib`).
//!
//! ## Honest boot seam
//! Actually *booting* a KVM guest needs `qemu-system-x86_64` + OVMF + a guest
//! image present, which this headless build does not carry, so
//! [`DemoContainer::boot`](crate::demo_container::DemoContainer::boot) is an **explicit seam**: it builds the real,
//! tunnr-identical qemu argv ([`unfold::kvm::KvmTarget`](crate::unfold)) and
//! reports the data placements the container would install on boot — it does not
//! fake a spawned VM. The full Rust wiring (pack → placement) is built and tested
//! here; only the guest spawn is deferred to a host with qemu + image.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::archive::{Archive, TarZstd};
use crate::layout::Layout;
/// A packed **data component**: a data file that ships INSIDE the demo bundle
/// (content-addressed) and is placed on the target by tunnr's PID-1
/// install-on-boot. Serialized as the `data-manifests/<name>.json` sidecar the
/// pack step writes and [`DemoContainer::install_data_on_boot`](crate::demo_container::DemoContainer::install_data_on_boot) reads back.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DataPlacement {
/// The component key (the `nornir` data-component name), e.g. `"korp-map"`.
pub name: String,
/// Where the file lives INSIDE the bundle, e.g. `"data/korp-map/europe.parquet"`.
pub packed_path: String,
/// The default on-target read path, e.g. `"/var/lib/korp-demo/map/europe.parquet"`.
pub target_path: String,
/// An env var that, when set at install-on-boot, OVERRIDES `target_path`
/// (resolution #1) — lets tests/operators redirect the placement.
pub env_override: Option<String>,
}
/// The built-in catalog of demo data components: a component name → its canonical
/// `(packed_filename, default_target_path, env_override)`. Kept here (not in the
/// product crates) for the same reason as [`crate::product::registry`]: the demo
/// products are *described*, not linked. Returns `None` for an unknown component
/// so [`DemoContainer::pack_with_data`](crate::demo_container::DemoContainer::pack_with_data) fails loudly rather than packing a file
/// nothing knows how to place.
fn known_data_component(name: &str) -> Option<(&'static str, &'static str, &'static str)> {
match name {
// The korp Map view's Europe basemap: packed as europe.parquet regardless
// of the source fixture's own name, placed where korp-demo reads it, and
// redirectable via KORP_MAP_PARQUET (korp-demo's own resolution #1).
"korp-map" => Some((
"europe.parquet",
"/var/lib/korp-demo/map/europe.parquet",
"KORP_MAP_PARQUET",
)),
_ => None,
}
}
/// A tunnr container that ships the demo products + their data components and
/// places the data on boot. Holds the packed bundle path + the on-target layout.
#[derive(Debug, Clone)]
pub struct DemoContainer {
/// VM/container name (also the qemu `-name`).
pub name: String,
/// The packed bundle (tar+zstd) carrying the binaries + data + placement manifests.
pub bundle: PathBuf,
/// On-target install layout (data dir + version-dated software dir).
pub layout: Layout,
/// The disk image the KVM guest boots (the seam: built/Buildroot rootfs).
pub disk: PathBuf,
}
impl DemoContainer {
/// A container definition with sensible defaults (the airgap layout the
/// installer writes into on the target).
pub fn new(
name: impl Into<String>,
bundle: impl Into<PathBuf>,
disk: impl Into<PathBuf>,
) -> Self {
DemoContainer {
name: name.into(),
bundle: bundle.into(),
layout: Layout {
data_dir: "/var/lib".into(),
log_dir: "/var/log".into(),
software_dir: "/opt/lake".into(),
version: "demo".into(),
},
disk: disk.into(),
}
}
/// **Pack** a binaries-only demo bundle (build side, pure Rust): assemble a
/// staging tree with each demo product's real binary, then [`TarZstd::pack`]
/// it into `out` (tar+zstd, content-addressed). `binaries` maps a product name
/// → the built binary on the build host. Returns the bundle path.
pub fn pack(
staging_root: &Path,
version: &str,
binaries: &[(&str, &Path)],
out: &Path,
) -> Result<PathBuf> {
Self::pack_with_data(staging_root, version, binaries, &[], out)
}
/// **Pack with data** (build side, pure Rust): the demo binaries PLUS one or
/// more data components in a single content-addressed bundle. Each data
/// component is placed at `data/<name>/<canonical-file>` and gets a sidecar
/// placement manifest at `data-manifests/<name>.json` recording its on-target
/// read path (so install-on-boot can place it without re-deriving anything).
///
/// `data` maps a component name (must be a `known_data_component`, e.g.
/// `"korp-map"`) → the source file on the build host; the source's own name
/// is irrelevant (a `europe-synth.parquet` fixture is packed as the canonical
/// `europe.parquet`).
pub fn pack_with_data(
staging_root: &Path,
version: &str,
binaries: &[(&str, &Path)],
data: &[(&str, &Path)],
out: &Path,
) -> Result<PathBuf> {
// The staging tree is a plain directory; TarZstd digests the WHOLE tree,
// so both the data file and its placement manifest land in the bundle's
// content-addressed manifest.
std::fs::create_dir_all(staging_root)
.with_context(|| format!("create staging root {}", staging_root.display()))?;
// Binaries: opaque payload for the data-pack contract (the placement test
// asserts the DATA, not the binaries), placed under linux-x86_64/<file>.
for (_name, src) in binaries {
let rel = format!("linux-x86_64/{}", file_name(src));
copy_into(staging_root, &rel, src)?;
}
// Data components: packed at their canonical path + a placement manifest.
for (name, src) in data {
let (file, target, env) = known_data_component(name)
.with_context(|| format!("unknown demo data component `{name}`"))?;
let packed_path = format!("data/{name}/{file}");
copy_into(staging_root, &packed_path, src)?;
let placement = DataPlacement {
name: (*name).to_string(),
packed_path: packed_path.clone(),
target_path: target.to_string(),
env_override: Some(env.to_string()),
};
let manifest_rel = format!("data-manifests/{name}.json");
let dest = staging_root.join(&manifest_rel);
std::fs::create_dir_all(dest.parent().unwrap()).ok();
std::fs::write(&dest, serde_json::to_vec_pretty(&placement)?)
.with_context(|| format!("write placement manifest {}", dest.display()))?;
}
TarZstd::new()
.pack(staging_root, version, out)
.context("pack demo bundle")?;
Ok(out.to_path_buf())
}
/// Read every data-component placement manifest back out of the bundle
/// (`data-manifests/*.json`), sorted by component name. Empty for a
/// binaries-only bundle.
pub fn data_placements(&self) -> Result<Vec<DataPlacement>> {
self.data_placements_via(&TarZstd::new())
}
/// [`data_placements`](Self::data_placements) against an explicit archive
/// engine (the seam install-on-boot reads through).
fn data_placements_via(&self, archive: &dyn Archive) -> Result<Vec<DataPlacement>> {
let mut out = Vec::new();
for entry in archive.list(&self.bundle)? {
let is_manifest = entry
.strip_prefix("data-manifests/")
.is_some_and(|rest| rest.ends_with(".json"));
if !is_manifest {
continue;
}
let bytes = archive.read_entry(&self.bundle, &entry)?;
let placement: DataPlacement = serde_json::from_slice(&bytes)
.with_context(|| format!("parse data placement {entry}"))?;
out.push(placement);
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
/// **The tunnr-PID-1 data-placement entrypoint** (target side, pure Rust):
/// read each placement manifest from the bundle and extract the packed file,
/// byte-identical, to its resolved on-target read path — the env override
/// (resolution #1) wins over the default `target_path`. Returns the names of
/// the components placed, in manifest order. A binaries-only bundle places
/// nothing (empty vec), so a build that omits the map degrades gracefully.
pub fn install_data_on_boot(&self, archive: &dyn Archive) -> Result<Vec<String>> {
let mut placed = Vec::new();
for p in self.data_placements_via(archive)? {
let target = p
.env_override
.as_ref()
.and_then(|var| std::env::var(var).ok())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(&p.target_path));
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create data dir {}", parent.display()))?;
}
archive
.extract_one(&self.bundle, &p.packed_path, &target)
.with_context(|| format!("place data component {}", p.name))?;
placed.push(p.name);
}
Ok(placed)
}
/// The KVM launch argv — the SAME q35/accel=kvm:tcg launch tunnr uses
/// ([`unfold::kvm::KvmTarget`](crate::unfold)). Pure → tested; the spawn is
/// the seam ([`boot`](Self::boot)).
pub fn qemu_argv(&self) -> Vec<String> {
crate::unfold::kvm::KvmTarget::new(
self.name.clone(),
self.disk.to_string_lossy().into_owned(),
)
.qemu_argv()
}
/// **HONEST BOOT SEAM** — does not fake a boot. Builds the real qemu argv and
/// reports the data placements the container would install on boot (name →
/// on-target read path), plus whether `/dev/kvm` is present. A host with
/// qemu + the guest image actually boots via
/// [`unfold::kvm::KvmTarget`](crate::unfold); this build asserts the wiring.
pub fn boot(&self) -> BootSeam {
let placements = self.data_placements().unwrap_or_default();
BootSeam {
name: self.name.clone(),
qemu_argv: self.qemu_argv(),
kvm_available: crate::unfold::kvm::KvmTarget::kvm_available(),
disk: self.disk.clone(),
data_placements: placements
.into_iter()
.map(|p| (p.name, p.target_path))
.collect(),
}
}
}
/// What [`DemoContainer::boot`](crate::demo_container::DemoContainer::boot) would run, surfaced instead of a faked boot. The
/// inject-assert tests assert on this (the argv + data placements are real
/// artifacts, not a spawned VM). `data_placements` drives tunnr's PID-1 init
/// marker `placed data component <name> -> <path>`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BootSeam {
pub name: String,
pub qemu_argv: Vec<String>,
pub kvm_available: bool,
pub disk: PathBuf,
/// (component name → on-target read path) for every packed data component.
pub data_placements: Vec<(String, String)>,
}
/// Copy `src` into `staging_root/rel`, creating parent dirs. The staging-relative
/// `rel` is exactly the bundle entry path (content-addressed on pack).
fn copy_into(staging_root: &Path, rel: &str, src: &Path) -> Result<()> {
let dest = staging_root.join(rel);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
std::fs::copy(src, &dest)
.with_context(|| format!("copy {} → {}", src.display(), dest.display()))?;
Ok(())
}
/// The final path component of a path-ish string.
fn file_name(path: &Path) -> String {
path.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::archive::{load_manifest, sha256_file};
/// Pack a demo bundle WITH the Europe basemap data component and assert the
/// content-addressed manifest carries the parquet under its canonical path
/// with the SOURCE file's real SHA-256 + length (the pack-side of tunnr's
/// data-pack contract). Mirrors tunnr's `data_pack` inject-assert.
#[test]
fn pack_with_data_content_addresses_the_basemap() {
let scratch = tempfile::tempdir().unwrap();
// A fixture whose own name is NOT the canonical one — asserts the pack
// canonicalizes to europe.parquet.
let parquet = scratch.path().join("europe-synth.parquet");
std::fs::write(&parquet, b"PAR1synthetic-osm-nodes-europe-subsetPAR1").unwrap();
let korp_bin = scratch.path().join("korp-appliance");
let facett_bin = scratch.path().join("facett-demo");
std::fs::write(&korp_bin, b"#!korp\n").unwrap();
std::fs::write(&facett_bin, b"#!facett\n").unwrap();
let bundle = scratch.path().join("demo-with-map.bundle");
DemoContainer::pack_with_data(
&scratch.path().join("staging"),
"demo",
&[
("korp-demo", korp_bin.as_path()),
("facett-demo", facett_bin.as_path()),
],
&[("korp-map", parquet.as_path())],
&bundle,
)
.unwrap();
assert!(bundle.exists(), "bundle packed");
let manifest = load_manifest(&bundle).unwrap();
let pq = manifest
.entries
.iter()
.find(|e| e.path == "data/korp-map/europe.parquet")
.expect("europe.parquet packed at its canonical path");
assert_eq!(
pq.sha256,
sha256_file(&parquet).unwrap(),
"content-addressed"
);
assert_eq!(pq.len, std::fs::metadata(&parquet).unwrap().len());
assert!(manifest.verify_root(), "bundle root hash self-consistent");
// The bundle lists the parquet + its placement manifest (cheap header read).
let entries = TarZstd::new().list(&bundle).unwrap();
assert!(entries.iter().any(|e| e == "data/korp-map/europe.parquet"));
assert!(entries.iter().any(|e| e == "data-manifests/korp-map.json"));
}
/// The boot seam reports the map placement (name → default read path) and
/// install-on-boot places the parquet byte-identical at the ENV-redirected
/// path (resolution #1) — the full pack → PID-1 placement flow, pure Rust.
#[test]
fn boot_seam_reports_and_install_places_the_basemap() {
let scratch = tempfile::tempdir().unwrap();
let parquet = scratch.path().join("europe.parquet");
std::fs::write(&parquet, b"PAR1europe-nodesPAR1").unwrap();
let korp_bin = scratch.path().join("korp-appliance");
std::fs::write(&korp_bin, b"#!korp\n").unwrap();
let bundle = scratch.path().join("demo.bundle");
DemoContainer::pack_with_data(
&scratch.path().join("staging"),
"demo",
&[("korp-demo", korp_bin.as_path())],
&[("korp-map", parquet.as_path())],
&bundle,
)
.unwrap();
let container = DemoContainer::new("korp-facett-demo", &bundle, "/var/lib/demo/disk.qcow2");
let seam = container.boot();
assert_eq!(
seam.data_placements,
vec![(
"korp-map".to_string(),
"/var/lib/korp-demo/map/europe.parquet".to_string()
)],
"boot seam reports the Europe map placement"
);
// The honest boot seam still builds the real, tunnr-identical qemu argv.
let argv = seam.qemu_argv.join(" ");
assert!(
argv.contains("-machine q35,accel=kvm:tcg"),
"tunnr machine: {argv}"
);
assert!(argv.contains("-name korp-facett-demo"));
// install-on-boot places the parquet at the env-redirected scratch path.
let placed_at = scratch.path().join("placed/europe.parquet");
// SAFETY: single-threaded test; the var is removed immediately after.
unsafe { std::env::set_var("KORP_MAP_PARQUET", &placed_at) };
let placed = container.install_data_on_boot(&TarZstd::new()).unwrap();
unsafe { std::env::remove_var("KORP_MAP_PARQUET") };
assert_eq!(
placed,
vec!["korp-map".to_string()],
"install placed the basemap"
);
assert!(placed_at.exists(), "parquet placed at the resolved path");
assert_eq!(
sha256_file(&placed_at).unwrap(),
sha256_file(&parquet).unwrap(),
"placed parquet is byte-identical to the source"
);
}
/// A binaries-only bundle carries no data component: the seam reports no
/// placements and install-on-boot places nothing (graceful degrade → the
/// product falls back to its synthetic basemap).
#[test]
fn binaries_only_bundle_places_no_data() {
let scratch = tempfile::tempdir().unwrap();
let korp_bin = scratch.path().join("korp-appliance");
std::fs::write(&korp_bin, b"#!korp\n").unwrap();
let bundle = scratch.path().join("demo-nomap.bundle");
DemoContainer::pack(
&scratch.path().join("staging"),
"demo",
&[("korp-demo", korp_bin.as_path())],
&bundle,
)
.unwrap();
let container = DemoContainer::new("c", &bundle, "/x");
assert!(
container.boot().data_placements.is_empty(),
"no data packed → no placement"
);
let placed = container.install_data_on_boot(&TarZstd::new()).unwrap();
assert!(placed.is_empty(), "nothing placed");
}
/// An unknown data component is rejected at pack time (loud failure, never a
/// silently packed file nothing knows how to place).
#[test]
fn unknown_data_component_is_rejected() {
let scratch = tempfile::tempdir().unwrap();
let f = scratch.path().join("mystery.bin");
std::fs::write(&f, b"?").unwrap();
let err = DemoContainer::pack_with_data(
&scratch.path().join("staging"),
"demo",
&[],
&[("no-such-component", f.as_path())],
&scratch.path().join("x.bundle"),
)
.unwrap_err();
assert!(
err.to_string().contains("no-such-component"),
"names the bad component: {err}"
);
}
}