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

nordisk/Skidbladnir

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