src/machine.rs
//! MACHINE — reusable VM/container **lifecycle control** (server-side, feature-gated).
//!
//! Design note: `.nornir/machine-control-design.md`.
//!
//! ## Why
//! Downstream test suites (holger, nornir, tunnr) need to boot a *real machine* to
//! test what a plain container cannot: **systemd / PID1 / full init**, real network
//! namespaces for **true airgap egress isolation**, the holger systemd unit, and
//! kernel-level behaviour. This module gives them one [`MachineController`] trait
//! with two impls — [`kvm::KvmController`] (qemu/KVM, behind `kvm`) and
//! [`container::ContainerController`] (podman/docker, behind `container`).
//!
//! ## Lean/heavy split (mirrors holger's static/dynamic philosophy)
//! Skidbladnir has two profiles: an **embedded CLIENT** that runs *on* the target
//! device (must stay lean — no hypervisor deps) and **server-side orchestration**
//! for airgap (where running KVMs/containers + ISO caching lives). The whole
//! `machine` module is therefore behind the `machine` feature and is **never**
//! compiled into the lean embedded client. The trait + [`MachineSpec`] + [`Handle`]
//! + [`ImageCache`] are pure-Rust (no heavy deps); the actual hypervisor/runtime
//! drives sit behind the `kvm` and `container` sub-features.
//!
//! ## What's real (honest, per the prompt)
//! - **Trait + types + [`ImageCache`]** — real, pure, unit-tested.
//! - **[`kvm::KvmController`]** — a thin adapter over the existing, tested
//! [`crate::unfold::kvm`] path. `start` authors the cloud-init NoCloud seed as a
//! **pure-Rust FAT image** (via `draupnir::seed::build_seed_image`, the one seed
//! builder — no `genisoimage`) and boots the base disk under per-drive `snapshot=on` (no
//! `qemu-img`); `stop` SIGTERMs the child. `exec` is **real, pure Rust** over the
//! **qemu-guest-agent** `guest-exec` protocol on a unix socket (no `ssh`), and
//! `wait_ready(CloudInitDone | SystemdUnit)` drives it. The previously-deferred
//! bits are done; the ONLY external process is launching qemu — the hypervisor —
//! which pure Rust cannot replace short of becoming a VMM.
//! - **[`container::ContainerController`]** — drives `podman`/`docker` via
//! `std::process` (CLI parity, no shell glue): `start`/`stop`/`exec` are real;
//! `wait_ready` polls the readiness model. Gated on the runtime being present.
//! - **znippy-backed image sets** — behind the `znippy` feature, reusing
//! [`crate::archive::ZnippyArchive`](crate::archive::ZnippyArchive) as the content-addressed image store.
use std::path::PathBuf;
use anyhow::Result;
// The previously-deferred "KVM guest channel" (design `machine-control-design.md`
// §6 TODO 1) is closed in **pure Rust** via the **qemu-guest-agent**: qemu exposes
// a host-side unix socket (`-chardev socket … -device virtserialport,name=
// org.qemu.guest_agent.0`); [`kvm::KvmController::exec`] speaks the guest-agent
// JSON protocol (`guest-exec` + `guest-exec-status`) over it directly — no `ssh`
// subprocess, no shelling. The socket path is carried in [`MachineSpec::qga_sock`].
/// Network mode for a machine — the **airgap** dimension that makes this worth
/// booting a real VM/namespace for.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum NetMode {
/// User-mode / NAT networking (qemu `user`, podman default bridge). Egress to
/// the host network is possible — fine for fetching, NOT an airgap test.
#[default]
Nat,
/// **Isolated / airgap**: no egress. qemu gets no `-netdev` (or a dummy);
/// containers run `--network=none`. This is the mode a true egress-isolation
/// test asserts against (the guest must NOT reach the outside world).
Isolated,
/// A named, pre-existing host network/bridge (e.g. libvirt `default`, a podman
/// network name). The caller owns its isolation policy.
Named(String),
}
/// A host↔guest bind mount (share a dir into the machine — e.g. a test fixture or
/// the holger systemd unit to install).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Mount {
pub host: PathBuf,
pub guest: PathBuf,
pub read_only: bool,
}
/// First-boot provisioning payload. KVM consumes cloud-init / Ignition as a seed
/// ISO (NoCloud) or fw_cfg; containers ignore it (they have no init firstboot) and
/// rely on the image + `exec` instead.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Provision {
/// No firstboot provisioning.
#[default]
None,
/// cloud-init NoCloud: `user-data` (+ optional `meta-data`) rendered into a
/// seed ISO the guest mounts at boot.
CloudInit {
user_data: String,
meta_data: Option<String>,
},
/// CoreOS/Fedora **Ignition** JSON, delivered via fw_cfg.
Ignition { config: String },
}
/// How we decide a machine is **ready** to be tested against. The controller
/// blocks in [`MachineController::wait_ready`] until this holds (or times out).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Readiness {
/// Ready as soon as it's started (caller will probe itself).
#[default]
Immediate,
/// A TCP port on the guest (mapped to the host for containers, or the guest IP
/// for VMs) must accept a connection.
TcpPort(u16),
/// `cloud-init status --wait` reports done (VM firstboot finished).
CloudInitDone,
/// A systemd target/unit is active inside the guest (e.g. `multi-user.target`,
/// or the `holger.service` we just installed) — checked via [`MachineController::exec`].
SystemdUnit(String),
}
/// The image to boot/run. For KVM this is a disk image (qcow2/raw) or an install
/// ISO; for containers it is an image ref (`registry/name:tag`). The `cache_key`,
/// when set, lets [`ImageCache`] resolve a content-addressed local copy.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ImageRef {
/// A local file path (disk image or ISO) — VM.
Path(PathBuf),
/// A container image reference — container.
Container(String),
/// A logical name resolved through an [`ImageCache`] to a local path by hash.
Cached { name: String, sha256: String },
}
/// What to boot/run and how — the controller-agnostic machine description. A test
/// fills this in and the chosen [`MachineController`] interprets it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MachineSpec {
/// A stable, human name (qemu `-name`, libvirt domain, container `--name`).
pub name: String,
pub image: ImageRef,
pub cpus: u32,
pub memory_mb: u32,
pub net: NetMode,
pub provision: Provision,
pub mounts: Vec<Mount>,
pub readiness: Readiness,
/// Optional install ISO to attach in addition to `image` (VM only).
pub iso: Option<PathBuf>,
/// Optional **data ISO** (read-only iso9660) attached as a second CD — the
/// install-matrix ships the rendered unit + the static probe/test-service +
/// the `ServiceSpec` JSON here (universally mountable, no 9p kernel dep). VM only.
pub data_iso: Option<PathBuf>,
/// Optional host file wired to a guest **virtio-serial** port named
/// `org.skidbladnir.report`: the in-guest probe writes its one-line
/// `ProbeReport` JSON to `/dev/virtio-ports/org.skidbladnir.report` and qemu
/// appends it here — the airgap-correct report-back channel (works even with
/// [`NetMode::Isolated`]). VM only.
pub serial_out: Option<PathBuf>,
/// Optional host **unix-socket** path for the qemu-guest-agent channel. When
/// set, the KVM controller adds the `org.qemu.guest_agent.0` virtserialport and
/// [`MachineController::exec`] speaks the guest-agent protocol over it (pure
/// Rust, no `ssh`). Requires `qemu-guest-agent` running in the guest. VM only.
pub qga_sock: Option<PathBuf>,
/// **Must this guest's writes to its boot disk survive the boot?** VM only.
///
/// `false` (the default) is the safe one: the disk is booted COW-protected
/// (`snapshot=on`), so a shared, content-addressed base OS image can never be
/// mutated by a boot. The install matrix relies on that — it boots the base
/// images straight out of `$SKID_ISOS` with no copy.
///
/// `true` says the caller owns this disk — it handed over a per-run copy or an
/// overlay — and needs it to persist because the *point* is what the guest wrote.
/// It is what makes **two boots of one machine** expressible: install on the
/// first boot, then observe on a second boot what the init system does with the
/// installed system at startup. Set it without owning the disk and you mutate the
/// base image every later run depends on; leave it unset when you needed it and
/// the second boot silently gets a pristine image and re-runs first-boot
/// provisioning (measured on oden 2026-08-03 — see `crate::vmsuite`).
///
/// Maps onto `draupnir::BootSpec::persist_disk` (draupnir >= 0.1.9).
pub persist_disk: bool,
}
impl MachineSpec {
/// A VM spec from a disk image path (sensible test defaults: 2 CPU / 2 GiB / NAT).
pub fn vm(name: impl Into<String>, disk: impl Into<PathBuf>) -> Self {
MachineSpec {
name: name.into(),
image: ImageRef::Path(disk.into()),
cpus: 2,
memory_mb: 2048,
net: NetMode::default(),
provision: Provision::default(),
mounts: Vec::new(),
readiness: Readiness::default(),
iso: None,
data_iso: None,
serial_out: None,
qga_sock: None,
persist_disk: false,
}
}
/// A container spec from an image ref.
pub fn container(name: impl Into<String>, image: impl Into<String>) -> Self {
MachineSpec {
name: name.into(),
image: ImageRef::Container(image.into()),
cpus: 2,
memory_mb: 2048,
net: NetMode::default(),
provision: Provision::default(),
mounts: Vec::new(),
readiness: Readiness::default(),
iso: None,
data_iso: None,
serial_out: None,
qga_sock: None,
persist_disk: false,
}
}
pub fn resources(mut self, cpus: u32, memory_mb: u32) -> Self {
self.cpus = cpus;
self.memory_mb = memory_mb;
self
}
pub fn net(mut self, net: NetMode) -> Self {
self.net = net;
self
}
pub fn provision(mut self, p: Provision) -> Self {
self.provision = p;
self
}
pub fn mount(mut self, m: Mount) -> Self {
self.mounts.push(m);
self
}
pub fn readiness(mut self, r: Readiness) -> Self {
self.readiness = r;
self
}
pub fn iso(mut self, iso: impl Into<PathBuf>) -> Self {
self.iso = Some(iso.into());
self
}
pub fn data_iso(mut self, iso: impl Into<PathBuf>) -> Self {
self.data_iso = Some(iso.into());
self
}
pub fn serial_out(mut self, path: impl Into<PathBuf>) -> Self {
self.serial_out = Some(path.into());
self
}
pub fn qga_sock(mut self, path: impl Into<PathBuf>) -> Self {
self.qga_sock = Some(path.into());
self
}
/// Keep the guest's writes to this disk (see [`MachineSpec::persist_disk`]).
/// Only for a disk the caller owns — a per-run copy or an overlay.
pub fn persist_disk(mut self, persist: bool) -> Self {
self.persist_disk = persist;
self
}
/// Decision helper: does this spec **require** a real VM (systemd/PID1/init or
/// true airgap egress isolation), or can a fast/stateless container serve?
/// This is the "test picks KVM-vs-container" rule from the design note.
/// `true` → use [`kvm::KvmController`]; `false` → a container is enough.
pub fn needs_real_machine(&self) -> bool {
matches!(self.net, NetMode::Isolated)
|| matches!(
self.provision,
Provision::CloudInit { .. } | Provision::Ignition { .. }
)
|| matches!(
self.readiness,
Readiness::CloudInitDone | Readiness::SystemdUnit(_)
)
}
}
/// An opaque, controller-specific handle to a running machine. The backend
/// recognizes its own handle by [`Handle::kind`].
/// Part of the CONTROLLER seam, so it keeps the `machine` gate the data model no
/// longer needs.
#[cfg(feature = "machine")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Handle {
/// Backend tag: `"kvm"`, `"libvirt"`, `"container"`.
pub kind: String,
/// The backend's id for the machine (qemu pid as string, libvirt domain name,
/// container id/name).
pub id: String,
/// The name the spec asked for (for logging / teardown).
pub name: String,
}
/// Captured output of a command run inside a guest.
#[cfg(feature = "machine")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Output {
pub status: i32,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
#[cfg(feature = "machine")]
impl Output {
pub fn success(&self) -> bool {
self.status == 0
}
pub fn stdout_str(&self) -> std::borrow::Cow<'_, str> {
String::from_utf8_lossy(&self.stdout)
}
}
/// **Server-side, feature-gated** machine lifecycle control. One trait, two impls
/// ([`kvm::KvmController`], [`container::ContainerController`]). A test holds a
/// `&dyn MachineController`, starts a [`MachineSpec`], waits for readiness, runs
/// commands, asserts, and stops — without caring which backend booted it.
#[cfg(feature = "machine")]
pub trait MachineController {
/// Boot/run the machine described by `spec`; return a [`Handle`] to it.
fn start(&self, spec: &MachineSpec) -> Result<Handle>;
/// Stop + remove the machine (idempotent-ish: stopping a gone machine is Ok).
fn stop(&self, handle: &Handle) -> Result<()>;
/// Run `cmd` inside the guest and capture its output.
fn exec(&self, handle: &Handle, cmd: &[&str]) -> Result<Output>;
/// Block until the machine satisfies `readiness` (or `timeout` elapses).
fn wait_ready(
&self,
handle: &Handle,
readiness: &Readiness,
timeout: std::time::Duration,
) -> Result<()>;
}
// ---------------------------------------------------------------------------
// ISO / image cache — content-addressed local cache of base images + seed ISOs.
// Pure-Rust, lean (sha2 + std::fs only); the znippy-backed image *set* lives
// behind the `znippy` feature.
// ---------------------------------------------------------------------------
/// A content-addressed cache of base images + cloud-init seed ISOs, keyed by the
/// SHA-256 of the bytes. Layout:
///
/// ```text
/// <root>/
/// blobs/<sha256> # the image/ISO bytes, named by content hash
/// names/<logical-name> # a text pointer file: the sha256 the name maps to
/// ```
///
/// Content-addressing gives **dedup** (two names, same bytes → one blob) and
/// **verify-on-receipt** (the path *is* the hash; a re-hash on read detects
/// corruption — the airgap law). Invalidation is implicit: a changed image has a
/// new hash, so it lands as a new blob and the name pointer is repointed; the old
/// blob is orphaned and swept by [`ImageCache::gc`]. The plain-dir layout is the
/// always-works fallback; [`ImageCache::import_znippy_set`] (feature `znippy`)
/// is the dedup/delta/airgap-carry upgrade.
#[derive(Debug, Clone)]
pub struct ImageCache {
root: PathBuf,
}
impl ImageCache {
/// Open (creating if needed) a cache rooted at `root`.
pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
let root = root.into();
std::fs::create_dir_all(root.join("blobs"))?;
std::fs::create_dir_all(root.join("names"))?;
Ok(ImageCache { root })
}
fn blob_path(&self, sha256: &str) -> PathBuf {
self.root.join("blobs").join(sha256)
}
fn name_path(&self, name: &str) -> PathBuf {
// Names may contain `:` / `/` (image refs); flatten to a safe filename.
let safe: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
self.root.join("names").join(safe)
}
/// Store `bytes` under their content hash; return the sha256. Idempotent — an
/// already-present blob is left as-is (dedup).
pub fn put_bytes(&self, bytes: &[u8]) -> Result<String> {
let sha = crate::archive::sha256_hex(bytes);
let dst = self.blob_path(&sha);
if !dst.exists() {
let tmp = dst.with_extension("tmp");
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, &dst)?;
}
Ok(sha)
}
/// Store a file's bytes under their content hash; return the sha256.
pub fn put_file(&self, src: impl AsRef<std::path::Path>) -> Result<String> {
let sha = crate::archive::sha256_file(src.as_ref())?;
let dst = self.blob_path(&sha);
if !dst.exists() {
let tmp = dst.with_extension("tmp");
std::fs::copy(src.as_ref(), &tmp)?;
std::fs::rename(&tmp, &dst)?;
}
Ok(sha)
}
/// Map a logical `name` (e.g. `holger-base`, `fedora-40.qcow2`) to a content
/// hash so callers can fetch by name later.
pub fn tag(&self, name: &str, sha256: &str) -> Result<()> {
std::fs::write(self.name_path(name), sha256.as_bytes())?;
Ok(())
}
/// Resolve a logical `name` to its content hash, if tagged.
pub fn resolve(&self, name: &str) -> Option<String> {
std::fs::read_to_string(self.name_path(name))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// The on-disk path of a cached blob — **verified** (re-hash on read; an
/// integrity failure is an error, not a silent serve). This is the path a
/// controller hands to qemu / `--volume`. Returns `Ok(None)` if absent.
pub fn path_for(&self, sha256: &str) -> Result<Option<PathBuf>> {
let p = self.blob_path(sha256);
if !p.exists() {
return Ok(None);
}
let got = crate::archive::sha256_file(&p)?;
if got != sha256 {
anyhow::bail!("cache blob {sha256} failed verify-on-read (got {got}) — corrupt");
}
Ok(Some(p))
}
/// Resolve an [`ImageRef`] to a local path through the cache. `Path` is passed
/// through; `Cached` is verified and resolved; `Container` has no path (the
/// runtime resolves the ref itself) → `Ok(None)`.
pub fn resolve_ref(&self, image: &ImageRef) -> Result<Option<PathBuf>> {
match image {
ImageRef::Path(p) => Ok(Some(p.clone())),
ImageRef::Cached { sha256, .. } => self.path_for(sha256),
ImageRef::Container(_) => Ok(None),
}
}
/// Garbage-collect: delete any blob whose hash is not currently pointed at by
/// some name. Returns the number of blobs swept. (Invalidation = re-tag a name
/// to a new hash, then `gc()` reaps the orphan.)
pub fn gc(&self) -> Result<usize> {
use std::collections::HashSet;
let mut live: HashSet<String> = HashSet::new();
if let Ok(names) = std::fs::read_dir(self.root.join("names")) {
for e in names.flatten() {
if let Ok(h) = std::fs::read_to_string(e.path()) {
live.insert(h.trim().to_string());
}
}
}
let mut swept = 0;
if let Ok(blobs) = std::fs::read_dir(self.root.join("blobs")) {
for e in blobs.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if name.ends_with(".tmp") {
continue;
}
if !live.contains(&name) {
std::fs::remove_file(e.path()).ok();
swept += 1;
}
}
}
Ok(swept)
}
/// Import a **znippy archive** of images (feature `znippy`) into the cache: each
/// archive entry is read (verify-on-receipt via znippy's content hash), stored
/// as a blob, and tagged by its entry path. This is the dedup + airgap-carry
/// image-set path — a whole set of base images crosses the gap as one
/// content-addressed [`crate::archive::ZnippyArchive`](crate::archive::ZnippyArchive) and unpacks into the
/// local cache with zero re-hashing surprises. Returns `(name, sha256)` pairs.
#[cfg(feature = "znippy")]
pub fn import_znippy_set(&self, archive: &std::path::Path) -> Result<Vec<(String, String)>> {
use crate::archive::Archive;
let engine = crate::archive::ZnippyArchive::new();
let mut out = Vec::new();
for entry in engine.list(archive)? {
// read_entry already verifies the content hash inside the znippy engine.
let bytes = engine.read_entry(archive, &entry)?;
let sha = self.put_bytes(&bytes)?;
self.tag(&entry, &sha)?;
out.push((entry, sha));
}
Ok(out)
}
}
// ---------------------------------------------------------------------------
// KVM controller — thin adapter over the existing unfold::kvm / unfold::libvirt.
// ---------------------------------------------------------------------------
/// qemu/KVM-backed [`MachineController`] (feature `kvm`). Reuses the existing,
/// tested [`crate::unfold::kvm::KvmTarget`] argv builder and the pure-Rust libvirt
/// RPC path. This is the backend for tests that need systemd/PID1/init or true
/// airgap egress isolation.
#[cfg(feature = "kvm")]
pub mod kvm {
use super::*;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::process::Command;
use std::sync::Mutex;
// The shared draupnir KVM launcher's `Boot::boot` seam (drives tunnr).
use draupnir::Boot as _;
/// A finished `guest-exec`: `(exitcode, stdout, stderr)`.
pub type ExecExit = (i32, Vec<u8>, Vec<u8>);
/// KVM launcher. `start` maps the [`MachineSpec`] onto a [`draupnir::BootSpec`]
/// ([`ImageSource::BootloaderDisk`](draupnir::ImageSource::BootloaderDisk) — the
/// bootloader-in-disk, no-`-kernel` firmware boot Skidbladnir's images use) and
/// fires it up through **`draupnir::kvm::KvmBoot`** — the ONE shared KVM launch
/// home (which drives tunnr's `boot_test` qemu/OVMF primitive), instead of a
/// duplicate hand-rolled qemu argv. cloud-init rides through
/// `draupnir::BootSpec::cloud_init`; the host-specific devices tunnr does not
/// model natively (the `org.skidbladnir.report` virtio-serial report port, the
/// `org.qemu.guest_agent.0` guest-agent port, the read-only data drive, an
/// install `-cdrom`, `-cpu host`) ride through `extra_qemu_args`. `exec` speaks
/// the guest-agent protocol over the recorded socket; `stop` powers the VM off
/// through draupnir's [`Lifecycle`](draupnir::Lifecycle). All pure Rust — no
/// `genisoimage`, no `qemu-img`, no `ssh`; the qemu spawn now lives in tunnr.
pub struct KvmController {
/// name → qemu-guest-agent unix-socket path (for [`KvmController::exec`]).
agents: Mutex<HashMap<String, PathBuf>>,
/// The shared draupnir KVM backend (drives tunnr's boot primitive and owns
/// the live `BootHandle` registry that `stop` powers off through).
boot: draupnir::kvm::KvmBoot,
/// name → the live [`draupnir::Machine`] `start` booted, so [`stop`] can
/// power off the exact VM through draupnir's [`Lifecycle`](draupnir::Lifecycle).
machines: Mutex<HashMap<String, draupnir::Machine>>,
}
impl KvmController {
pub fn new() -> Self {
KvmController {
agents: Mutex::new(HashMap::new()),
boot: draupnir::kvm::KvmBoot::new(),
machines: Mutex::new(HashMap::new()),
}
}
/// **The guest's serial console so far**, for a machine started by this
/// controller — `None` when the name is unknown.
///
/// The report port (`serial_out`) carries only what the in-guest scenario
/// deliberately emits, so a guest that died before reaching it produces an
/// EMPTY report — which is shaped exactly like a suite whose cases all failed
/// to run, and says nothing about why. The console is the only place the
/// answer exists (cloud-init errors, a kernel panic, a missing payload disk),
/// and tunnr keeps it in memory, so it has to be asked for while the handle is
/// alive. A suite that cannot print this costs a whole boot per diagnosis.
pub fn console(&self, name: &str) -> Option<String> {
let m = self.machines.lock().unwrap();
m.get(name)
.and_then(|machine| self.boot.serial_log(machine))
}
/// Adapt a Skidbladnir [`Provision::CloudInit`] into the constellation's ONE
/// NoCloud seed input — a [`draupnir::CloudInit`] — so the actual seed
/// authoring is done by `draupnir::seed::*` (the single seed builder,
/// dup-audit L5), not a twin here. `None` for non-CloudInit provisions.
///
/// Behaviour-preserving: when the spec omits `meta-data`, Skidbladnir's own
/// default (`instance-id`/`local-hostname` = `skidbladnir`) is filled in
/// **explicitly** so the seed bytes stay identical — draupnir's own default
/// (`draupnir`) never applies through this path. `network-config` is not part
/// of a Skidbladnir `Provision`, so it stays `None`.
pub(crate) fn cloud_init(p: &Provision) -> Option<draupnir::CloudInit> {
match p {
Provision::CloudInit {
user_data,
meta_data,
} => {
let md = meta_data.clone().unwrap_or_else(|| {
"instance-id: skidbladnir\nlocal-hostname: skidbladnir\n".to_string()
});
Some(draupnir::CloudInit {
user_data: user_data.clone(),
meta_data: Some(md),
network_config: None,
})
}
_ => None,
}
}
/// Map a [`MachineSpec`] (disk-boot case) onto a [`draupnir::BootSpec`] for
/// the shared [`draupnir::kvm::KvmBoot`] launcher. The Skidbladnir boot model
/// is bootloader-in-disk (no `-kernel`; OVMF firmware-boots the qcow2/raw
/// directly), which is exactly draupnir's
/// [`ImageSource::BootloaderDisk`](draupnir::ImageSource::BootloaderDisk).
///
/// - RAM/cores ← `memory_mb`/`cpus`.
/// - cloud-init NoCloud ← [`Self::cloud_init`] (draupnir authors the `cidata`
/// seed and attaches it as a read-only virtio disk via tunnr's `with_seed`).
/// - [`NetMode::Isolated`] (airgap) → [`draupnir::NetMode::None`] — tunnr's
/// `-nic none`, no egress. Every other Skidbladnir net mode
/// ([`NetMode::Nat`]/[`NetMode::Named`]) → [`draupnir::NetMode::Default`]
/// (tunnr's user-mode virtio-net). See `to_boot_spec` gaps in the module note.
/// - The host-specific QEMU devices tunnr does not model natively ride through
/// [`Self::extra_qemu_args`].
///
/// **Base-image COW guard (resolved 2026-08-03).** A `BootloaderDisk` boot is
/// COW-protected by draupnir (tunnr opens the base `-drive` `snapshot=on`), so
/// a shared, content-addressed base image is never mutated by a boot — still
/// the default here. What was missing was the *opt-out*: a caller that booted
/// its OWN per-run copy in order to boot it a SECOND time had no way to keep
/// the writes, so its second boot silently got a pristine image and re-ran
/// first-boot provisioning. [`MachineSpec::persist_disk`] now carries that
/// intent through to `draupnir::BootSpec::persist_disk` (draupnir >= 0.1.9).
/// The earlier note here claimed the guard itself was "not expressible through
/// `BootloaderDisk`"; that was wrong and is retracted.
pub(crate) fn to_boot_spec(spec: &MachineSpec, disk: &Path) -> draupnir::BootSpec {
let mut b = draupnir::BootSpec::kvm_bootloader_disk(
spec.name.clone(),
disk.to_string_lossy().into_owned(),
);
b.mem_mb = spec.memory_mb;
b.cores = spec.cpus;
b.cloud_init = Self::cloud_init(&spec.provision);
b.net = match spec.net {
// Airgap: no egress NIC — tunnr's `-nic none`.
NetMode::Isolated => draupnir::NetMode::None,
// NAT / a named host network → tunnr's default user-mode virtio-net.
// (`NetMode::Named` has no draupnir equivalent; it degrades to the
// default user-mode net — a gap for tests that need a specific bridge.)
NetMode::Nat | NetMode::Named(_) => draupnir::NetMode::Default,
};
b.extra_qemu_args = Self::extra_qemu_args(spec);
// Only ever turns COW OFF, and only when the caller said it owns the disk.
b.persist_disk = spec.persist_disk;
b
}
/// The host-specific QEMU devices tunnr's `BootSpec` does not model natively,
/// appended verbatim after tunnr's own argv via
/// [`draupnir::BootSpec::extra_qemu_args`]: `-cpu host` (fast KVM boot), an
/// install/boot `-cdrom`, the read-only **data** virtio disk (the matrix
/// mounts it by label), the `org.skidbladnir.report` virtio-serial report
/// port (probe → host file, read by `matrix::poll_report`), and the
/// `org.qemu.guest_agent.0` guest-agent port (pure-Rust `exec`). The base disk
/// and the cloud-init seed are NOT here — tunnr attaches those itself (the
/// disk as the boot drive, the seed via `with_seed`). Pure → unit-tested.
pub(crate) fn extra_qemu_args(spec: &MachineSpec) -> Vec<String> {
let mut a: Vec<String> = Vec::new();
// Use the host CPU under KVM for a fast boot (the matrix is heavy). tunnr's
// base is `q35,accel=kvm:tcg` with no `-cpu`, so this appends cleanly.
if crate::unfold::kvm::KvmTarget::kvm_available() {
a.push("-cpu".into());
a.push("host".into());
}
// A boot/install ISO as a `-cdrom` (our image source is the bootloader
// disk, so an extra ISO rides here rather than as the boot payload).
if let Some(iso) = &spec.iso {
a.push("-cdrom".into());
a.push(iso.to_string_lossy().into_owned());
}
// Data payload: a read-only virtio disk (the guest finds it by label).
if let Some(data) = &spec.data_iso {
a.push("-drive".into());
a.push(format!(
"file={},format=raw,if=virtio,readonly=on",
data.to_string_lossy()
));
}
// One virtio-serial controller carries both the report port and the
// guest-agent port.
let want_serial = spec.serial_out.is_some() || spec.qga_sock.is_some();
if want_serial {
a.push("-device".into());
a.push("virtio-serial-pci".into());
}
if let Some(out) = &spec.serial_out {
a.push("-chardev".into());
a.push(format!("file,id=skidreport,path={}", out.to_string_lossy()));
a.push("-device".into());
a.push("virtserialport,chardev=skidreport,name=org.skidbladnir.report".into());
}
if let Some(sock) = &spec.qga_sock {
a.push("-chardev".into());
a.push(format!(
"socket,id=qga0,path={},server=on,wait=off",
sock.to_string_lossy()
));
a.push("-device".into());
a.push("virtserialport,chardev=qga0,name=org.qemu.guest_agent.0".into());
}
a
}
/// One read of a complete JSON line/object from the guest-agent socket.
fn read_json(sock: &mut UnixStream) -> Result<serde_json::Value> {
let mut acc = Vec::new();
let mut byte = [0u8; 1];
loop {
match sock.read(&mut byte) {
Ok(0) => break,
Ok(_) => {
if byte[0] == b'\n' {
if !acc.is_empty() {
break;
}
} else {
acc.push(byte[0]);
// Opportunistic parse: the agent may not send a newline.
if byte[0] == b'}' {
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&acc) {
return Ok(v);
}
}
}
}
Err(e) => return Err(anyhow::anyhow!("guest-agent read: {e}")),
}
}
serde_json::from_slice(&acc).map_err(|e| anyhow::anyhow!("guest-agent JSON parse: {e}"))
}
/// Parse the `pid` out of a `guest-exec` reply (pure → unit-testable).
pub fn parse_exec_pid(v: &serde_json::Value) -> Result<i64> {
v.get("return")
.and_then(|r| r.get("pid"))
.and_then(|p| p.as_i64())
.ok_or_else(|| anyhow::anyhow!("guest-exec: no pid in {v}"))
}
/// Parse a `guest-exec-status` reply (pure → unit-testable). `Ok(None)` while
/// still running; `Ok(Some(ExecExit))` once `exited`.
pub fn parse_exec_status(v: &serde_json::Value) -> Result<Option<ExecExit>> {
use base64::Engine;
let r = v
.get("return")
.ok_or_else(|| anyhow::anyhow!("guest-exec-status: no return in {v}"))?;
if !r.get("exited").and_then(|e| e.as_bool()).unwrap_or(false) {
return Ok(None);
}
let code = r.get("exitcode").and_then(|c| c.as_i64()).unwrap_or(-1) as i32;
let dec = |k: &str| -> Vec<u8> {
r.get(k)
.and_then(|d| d.as_str())
.and_then(|s| base64::engine::general_purpose::STANDARD.decode(s).ok())
.unwrap_or_default()
};
Ok(Some((code, dec("out-data"), dec("err-data"))))
}
/// Run `cmd` in the guest via the **qemu-guest-agent** `guest-exec` /
/// `guest-exec-status` protocol over `sock` — pure Rust, no `ssh`. The command
/// runs under `/bin/sh -c "<joined>"` so callers can pass shell-style argv.
fn guest_agent_exec(sock_path: &Path, cmd: &[&str]) -> Result<Output> {
let mut sock = UnixStream::connect(sock_path)
.map_err(|e| anyhow::anyhow!("connect guest-agent {}: {e}", sock_path.display()))?;
sock.set_read_timeout(Some(std::time::Duration::from_secs(10)))?;
sock.set_write_timeout(Some(std::time::Duration::from_secs(10)))?;
// Sync first (drains any stale bytes; confirms the agent is live).
let sync = serde_json::json!({"execute":"guest-sync","arguments":{"id":1234}});
sock.write_all(format!("{sync}\n").as_bytes())?;
let _ = Self::read_json(&mut sock);
// guest-exec /bin/sh -c "<joined>"
let joined = cmd.join(" ");
let req = serde_json::json!({
"execute":"guest-exec",
"arguments":{"path":"/bin/sh","arg":["-c", joined],"capture-output":true}
});
sock.write_all(format!("{req}\n").as_bytes())?;
let pid = Self::parse_exec_pid(&Self::read_json(&mut sock)?)?;
// poll status until exited (≤ ~30s)
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
let st = serde_json::json!({"execute":"guest-exec-status","arguments":{"pid":pid}});
sock.write_all(format!("{st}\n").as_bytes())?;
if let Some((code, out, err)) =
Self::parse_exec_status(&Self::read_json(&mut sock)?)?
{
return Ok(Output {
status: code,
stdout: out,
stderr: err,
});
}
if std::time::Instant::now() >= deadline {
anyhow::bail!("guest-exec `{joined}` did not exit within timeout");
}
std::thread::sleep(std::time::Duration::from_millis(300));
}
}
}
impl Default for KvmController {
fn default() -> Self {
Self::new()
}
}
impl MachineController for KvmController {
fn start(&self, spec: &MachineSpec) -> Result<Handle> {
// Resolve the disk via a Path/Cached ImageRef; Container is not a VM.
let disk = match &spec.image {
ImageRef::Path(p) => p.clone(),
ImageRef::Cached { sha256, .. } => {
anyhow::bail!(
"KvmController needs the cache resolved before start (Cached sha {sha256}); \
call ImageCache::path_for and pass ImageRef::Path"
)
}
ImageRef::Container(r) => {
anyhow::bail!(
"KvmController cannot boot a container image ref `{r}` — use ContainerController"
)
}
};
if !crate::unfold::kvm::KvmTarget::kvm_available() {
if std::path::Path::new("/dev/kvm").exists() {
eprintln!(
"note: /dev/kvm present but NOT accessible (not in the `kvm` group?) — \
qemu will use TCG (slow) for `{}`. Fix: sudo usermod -aG kvm $USER, then re-login.",
spec.name
);
} else {
eprintln!(
"note: /dev/kvm absent — qemu will use TCG (slow) for `{}`",
spec.name
);
}
}
let have_qemu = Command::new("qemu-system-x86_64")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !have_qemu {
anyhow::bail!(
"qemu-system-x86_64 not found — KvmController is a server-side/test-host \
capability; cannot start `{}` here",
spec.name
);
}
// Ensure the report-file / guest-agent-socket dir exists before boot (the
// matrix already mkdir's it; belt-and-suspenders for other callers). The
// seed no longer lands here — draupnir authors it under its own temp dir.
let work = spec
.serial_out
.as_ref()
.and_then(|p| p.parent())
.map(|p| p.to_path_buf())
.or_else(|| {
spec.qga_sock
.as_ref()
.and_then(|p| p.parent())
.map(|p| p.to_path_buf())
})
.unwrap_or_else(|| {
disk.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf()
});
std::fs::create_dir_all(&work)?;
// Map onto the shared BootSpec and fire up through draupnir's ONE KVM
// launcher (drives tunnr's qemu/OVMF `boot_test`); no duplicate argv, no
// local qemu spawn. cloud-init + net + sizing are draupnir-native; the
// report/guest-agent/data/cdrom devices ride through `extra_qemu_args`.
let bspec = Self::to_boot_spec(spec, &disk);
let machine = self
.boot
.boot(&bspec)
.map_err(|e| anyhow::anyhow!("draupnir kvm boot `{}`: {e}", spec.name))?;
if let Some(sock) = &spec.qga_sock {
self.agents
.lock()
.unwrap()
.insert(spec.name.clone(), sock.clone());
}
let handle = Handle {
kind: "kvm".into(),
id: machine.id.clone(),
name: spec.name.clone(),
};
// Remember the live draupnir Machine so `stop` powers off the exact VM.
self.machines
.lock()
.unwrap()
.insert(spec.name.clone(), machine);
Ok(handle)
}
fn stop(&self, handle: &Handle) -> Result<()> {
self.agents.lock().unwrap().remove(&handle.name);
// Power off the exact VM through draupnir's Lifecycle (tunnr SIGKILLs the
// qemu process group). Ignore "no live VM" so stop stays idempotent — a
// VM already gone (killed / never booted) is a success, as before.
let machine = self.machines.lock().unwrap().remove(&handle.name);
if let Some(machine) = machine {
use draupnir::Lifecycle;
let _ = self.boot.power_off(&machine);
}
Ok(())
}
fn exec(&self, handle: &Handle, cmd: &[&str]) -> Result<Output> {
// Real exec over the **qemu-guest-agent** unix socket — pure Rust, no ssh.
// Honest error if no guest-agent socket was wired for this guest.
let sock = {
let agents = self.agents.lock().unwrap();
agents.get(&handle.name).cloned()
};
let sock = sock.ok_or_else(|| {
anyhow::anyhow!(
"KvmController::exec has no guest-agent socket for `{}` — boot it with \
MachineSpec.qga_sock set (needs qemu-guest-agent running in the guest)",
handle.name
)
})?;
Self::guest_agent_exec(&sock, cmd)
}
fn wait_ready(
&self,
handle: &Handle,
readiness: &Readiness,
timeout: std::time::Duration,
) -> Result<()> {
match readiness {
Readiness::Immediate => Ok(()),
Readiness::TcpPort(port) => super::wait_tcp("127.0.0.1", *port, timeout),
// CloudInitDone / SystemdUnit are real over the guest-agent channel.
Readiness::CloudInitDone => {
let deadline = std::time::Instant::now() + timeout;
loop {
if let Ok(o) = self.exec(handle, &["cloud-init", "status"]) {
let s = o.stdout_str();
if s.contains("status: done") {
return Ok(());
}
if s.contains("status: error") {
anyhow::bail!("cloud-init reported error in `{}`", handle.name);
}
}
if std::time::Instant::now() >= deadline {
anyhow::bail!(
"cloud-init not done within timeout for `{}`",
handle.name
);
}
std::thread::sleep(std::time::Duration::from_secs(3));
}
}
Readiness::SystemdUnit(unit) => {
let deadline = std::time::Instant::now() + timeout;
loop {
if let Ok(o) = self.exec(handle, &["systemctl", "is-active", unit]) {
if o.stdout_str().trim() == "active" {
return Ok(());
}
}
if std::time::Instant::now() >= deadline {
anyhow::bail!(
"unit `{unit}` not active within timeout for `{}`",
handle.name
);
}
std::thread::sleep(std::time::Duration::from_secs(2));
}
}
}
}
}
}
// ---------------------------------------------------------------------------
// Container controller — podman/docker via std::process.
// ---------------------------------------------------------------------------
/// podman/docker-backed [`MachineController`] (feature `container`). For fast,
/// stateless tests that do NOT need a real init/kernel. Drives the runtime via
/// `std::process` (CLI parity, no shell glue), picking `podman` then `docker`.
#[cfg(feature = "container")]
pub mod container {
use super::*;
pub struct ContainerController {
runtime: String,
}
impl ContainerController {
/// Detect a runtime (`podman`, then `docker`). Errors if neither is present.
pub fn detect() -> Result<Self> {
for rt in ["podman", "docker"] {
if std::process::Command::new(rt)
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
return Ok(ContainerController { runtime: rt.into() });
}
}
anyhow::bail!("no container runtime found (need podman or docker)")
}
/// Use an explicit runtime binary (`podman` / `docker`).
pub fn with_runtime(runtime: impl Into<String>) -> Self {
ContainerController {
runtime: runtime.into(),
}
}
pub fn runtime(&self) -> &str {
&self.runtime
}
/// Build the `run -d` argv for `spec` (pure → unit-testable without the
/// runtime). Honors [`NetMode`] and mounts.
pub fn run_argv(&self, spec: &MachineSpec) -> Result<Vec<String>> {
let image = match &spec.image {
ImageRef::Container(r) => r.clone(),
ImageRef::Path(p) => anyhow::bail!(
"ContainerController needs a container ImageRef, got Path({}) — use KvmController",
p.display()
),
ImageRef::Cached { name, .. } => name.clone(),
};
let mut a: Vec<String> = vec![
"run".into(),
"-d".into(),
"--name".into(),
spec.name.clone(),
];
match &spec.net {
NetMode::Nat => {}
NetMode::Isolated => {
a.push("--network".into());
a.push("none".into());
}
NetMode::Named(n) => {
a.push("--network".into());
a.push(n.clone());
}
}
for m in &spec.mounts {
let ro = if m.read_only { ":ro" } else { "" };
a.push("-v".into());
a.push(format!("{}:{}{}", m.host.display(), m.guest.display(), ro));
}
a.push(image);
Ok(a)
}
}
impl MachineController for ContainerController {
fn start(&self, spec: &MachineSpec) -> Result<Handle> {
let argv = self.run_argv(spec)?;
let out = std::process::Command::new(&self.runtime)
.args(&argv)
.output()
.map_err(|e| anyhow::anyhow!("spawn `{} run`: {e}", self.runtime))?;
if !out.status.success() {
anyhow::bail!(
"`{} run` failed for `{}`: {}",
self.runtime,
spec.name,
String::from_utf8_lossy(&out.stderr)
);
}
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
Ok(Handle {
kind: "container".into(),
id: if id.is_empty() { spec.name.clone() } else { id },
name: spec.name.clone(),
})
}
fn stop(&self, handle: &Handle) -> Result<()> {
// `rm -f` stops + removes; a missing container is success (idempotent).
let _ = std::process::Command::new(&self.runtime)
.args(["rm", "-f", &handle.id])
.output();
Ok(())
}
fn exec(&self, handle: &Handle, cmd: &[&str]) -> Result<Output> {
let mut a: Vec<String> = vec!["exec".into(), handle.id.clone()];
a.extend(cmd.iter().map(|s| s.to_string()));
let out = std::process::Command::new(&self.runtime)
.args(&a)
.output()
.map_err(|e| {
anyhow::anyhow!("`{} exec` on `{}`: {e}", self.runtime, handle.name)
})?;
Ok(Output {
status: out.status.code().unwrap_or(-1),
stdout: out.stdout,
stderr: out.stderr,
})
}
fn wait_ready(
&self,
handle: &Handle,
readiness: &Readiness,
timeout: std::time::Duration,
) -> Result<()> {
let deadline = std::time::Instant::now() + timeout;
loop {
let ok = match readiness {
Readiness::Immediate => true,
Readiness::SystemdUnit(unit) => self
.exec(handle, &["systemctl", "is-active", unit])
.map(|o| o.success())
.unwrap_or(false),
// A mapped port: probe with a real TCP connect (no `sh -c`
// </dev/tcp` bashism), matching the KVM controller's
// `wait_tcp`. A single attempt — the outer loop retries.
Readiness::TcpPort(port) => std::net::TcpStream::connect_timeout(
&std::net::SocketAddr::from(([127, 0, 0, 1], *port)),
std::time::Duration::from_millis(300),
)
.is_ok(),
// cloud-init doesn't run in a plain container.
Readiness::CloudInitDone => anyhow::bail!(
"ContainerController has no cloud-init (Readiness::CloudInitDone) — use KvmController"
),
};
if ok {
return Ok(());
}
if std::time::Instant::now() >= deadline {
anyhow::bail!("wait_ready({readiness:?}) timed out for `{}`", handle.name);
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
}
}
}
/// Poll a TCP `host:port` until it accepts or `timeout` elapses — the host-side
/// readiness probe for the KVM controller's [`Readiness::TcpPort`] (the container
/// controller probes inside its own netns and cannot use a host-side connect).
#[cfg(feature = "kvm")]
fn wait_tcp(host: &str, port: u16, timeout: std::time::Duration) -> Result<()> {
let deadline = std::time::Instant::now() + timeout;
let addr = format!("{host}:{port}");
loop {
if std::net::TcpStream::connect_timeout(
&addr
.parse()
.map_err(|e| anyhow::anyhow!("bad addr {addr}: {e}"))?,
std::time::Duration::from_millis(300),
)
.is_ok()
{
return Ok(());
}
if std::time::Instant::now() >= deadline {
anyhow::bail!("tcp {addr} not ready within timeout");
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
}
// ---------------------------------------------------------------------------
// jera routing seam (feature `jera`) — begin routing the machine spawns through
// jera (edda), the ONE job/container/VM manager, instead of raw podman/qemu.
// ---------------------------------------------------------------------------
/// **jera routing seam** (feature `jera`) — the additive, swap-ready first step of
/// routing this module's container/VM spawns through **jera** (`edda/crates/jera`),
/// the constellation's ONE job/container/VM manager, instead of shelling straight to
/// `podman`/`qemu` (the jera-dedup audit named `machine.rs` the worst bypass offender).
///
/// This step adds **no new spawn path and removes none** — the working
/// [`container::ContainerController`] / [`kvm::KvmController`] `Command` paths are
/// untouched and remain the fallback. What it adds is the *seam the swap needs*:
///
/// - [`MachineSpec::job_policy`] — classify a spec into the [`jera::JobPolicy`]
/// (`Container` ↔ an OCI image ref, `Vm` ↔ a disk image) that jera hands to its
/// boot library. This is the one place "which jera job kind is this machine".
/// - [`MachineSpec::to_jera_container_spec`] — map a container spec onto a
/// [`jera::container::ContainerSpec`] (which itself maps onto a
/// `draupnir::BootSpec` via jera's `to_draupnir`), so a future routed `start`
/// boots the SAME container through jera→draupnir. Proven daemon-free by asserting
/// the mapped spec `validate()`s at the draupnir layer.
/// - [`MachineSpec::jera_unsupported`] / [`MachineSpec::jera_routable`] — the HONEST
/// gap: the knobs a `MachineSpec` carries that jera's current `ContainerSpec` API
/// cannot express. jera has since grown a **net mode** (the airgap `--network none`)
/// AND **bind mounts**, so the container gap has shrunk to the ONE remaining case —
/// an arbitrary **named** host-network — and a NAT / airgap / mount-bearing
/// container is now routable. A non-empty gap still means a live swap would silently
/// DROP behaviour (regress airgap egress isolation, L2), so the direct `podman` path
/// stays for that case.
///
/// **Airgap live-route (LANDED, container path).** An [`NetMode::Isolated`] container
/// maps onto `jera::container::NetMode::None`, which `to_draupnir` threads onto
/// draupnir's `HostConfig.network_mode` = `"none"` → a real `--network none` egress
/// isolated run. The remaining container knob jera lacks (a detached
/// handle + `exec`-into stateless lifecycle, so a routed `start` can `run -d` / `exec`
/// / `rm -f`) and the VM knobs (kernel-less disk boot + net/cloud-init/iso) are logged
/// for the jera/draupnir agents in `workspace_nordisk/Skidbladnir_agents_problems.md`.
/// Design: `.nornir/machine-jera-routing.md`.
#[cfg(feature = "jera")]
pub mod jera_route {
use super::*;
impl MachineSpec {
/// Classify this spec into the [`jera::JobPolicy`] a routed spawn would use:
/// an OCI image ref ([`ImageRef::Container`]) → [`jera::JobPolicy::Container`]
/// (draupnir's OCI backend); a disk image ([`ImageRef::Path`] /
/// [`ImageRef::Cached`]) → [`jera::JobPolicy::Vm`] (draupnir's KVM backend).
/// The single source of truth for "which jera job kind is this machine".
pub fn job_policy(&self) -> jera::JobPolicy {
match &self.image {
ImageRef::Container(_) => jera::JobPolicy::Container,
ImageRef::Path(_) | ImageRef::Cached { .. } => jera::JobPolicy::Vm,
}
}
/// Map a **container** [`MachineSpec`] onto the [`jera::container::ContainerSpec`]
/// a routed spawn would boot through jera (→ `draupnir::BootSpec` via jera's
/// `ContainerSpec::to_draupnir`). Carries the image ref + the machine `name`
/// (so a teardown addresses the exact container). Bails on a VM disk image —
/// a VM routes through jera's `vm` seam, not the container one.
///
/// The airgap net wire (LANDED): jera's `ContainerSpec` now carries a
/// [`net`](jera::container::NetMode) mode and **bind mounts**, so this mapping
/// threads them THROUGH → [`jera::container::ContainerSpec::to_draupnir`] sets
/// draupnir's `HostConfig.network_mode`, so a routed [`NetMode::Isolated`] spec
/// produces a real `--network none` airgap container (no egress). The one knob
/// jera still cannot express is an arbitrary **named** host-network
/// ([`NetMode::Named`]) — jera's `NetMode` is `Default | None | Host | Bridge`,
/// not an arbitrary name — so a Named spec keeps the `"net"` gap (see
/// [`jera_unsupported`](Self::jera_unsupported)) and stays on the direct
/// `podman` path via the [`jera_routable`](Self::jera_routable) guard.
pub fn to_jera_container_spec(&self) -> Result<jera::container::ContainerSpec> {
let image = match &self.image {
ImageRef::Container(r) => r.clone(),
ImageRef::Cached { name, .. } => name.clone(),
ImageRef::Path(p) => anyhow::bail!(
"to_jera_container_spec: `{}` is a VM disk image — route a VM through \
jera's `vm` seam (JobPolicy::Vm), not `jera::container`",
p.display()
),
};
Ok(jera::container::ContainerSpec {
image,
name: self.name.clone(),
// Airgap: thread the net mode onto jera's ContainerSpec. `Isolated`
// ⇒ `NetMode::None` ⇒ jera→draupnir `--network none` (the load-bearing
// egress-isolation guarantee). `Named` has no jera equivalent → mapped
// to the engine default here but gap-guarded (never routed) so it can
// never silently swallow a named-network intent.
net: self.jera_net(),
// Bind mounts thread through jera's BindMount → draupnir's `-v` run.
mounts: self
.mounts
.iter()
.map(|m| jera::container::BindMount {
host: m.host.display().to_string(),
container: m.guest.display().to_string(),
ro: m.read_only,
})
.collect(),
..Default::default()
})
}
/// Map this spec's [`NetMode`] onto jera's container [`NetMode`]
/// (jera::container::NetMode): [`Nat`](NetMode::Nat) → `Default` (no
/// `--network` flag), [`Isolated`](NetMode::Isolated) → `None` (the airgap
/// `--network none`), and an arbitrary [`Named`](NetMode::Named) →
/// `Default` (jera has no arbitrary-named net; it is gap-guarded by
/// [`jera_unsupported`](Self::jera_unsupported) so it is never routed and thus
/// never silently downgraded). The single source of the container net mapping.
pub fn jera_net(&self) -> jera::container::NetMode {
match &self.net {
NetMode::Nat => jera::container::NetMode::Default,
NetMode::Isolated => jera::container::NetMode::None,
NetMode::Named(_) => jera::container::NetMode::Default,
}
}
/// The knobs this spec carries that jera's **current** `ContainerSpec` API
/// CANNOT express — the exact gap blocking a faithful live swap. jera has since
/// grown a net mode + bind mounts (LANDED), so the gap has shrunk to the ONE
/// remaining case: `"net"` when the spec asks for an arbitrary **named**
/// host-network ([`NetMode::Named`]) — jera's `NetMode` is
/// `Default | None | Host | Bridge`, with no arbitrary name. [`NetMode::Nat`]
/// (→ `Default`) and [`NetMode::Isolated`] (→ `None`, the airgap `--network
/// none`) now map faithfully; bind **mounts** now carry through jera's
/// `BindMount` → draupnir `-v` run, so they are no longer a gap. An empty list
/// means jera can carry this container faithfully today.
pub fn jera_unsupported(&self) -> Vec<&'static str> {
let mut gaps = Vec::new();
if matches!(self.net, NetMode::Named(_)) {
gaps.push("net");
}
gaps
}
/// Whether this container spec can be routed through jera **faithfully today**
/// — it is an OCI-image container (not a VM disk) AND carries no knob jera's
/// `ContainerSpec` cannot express ([`jera_unsupported`](Self::jera_unsupported)
/// is empty). Now that jera carries a net mode + mounts, a **NAT** container,
/// an **airgap** ([`NetMode::Isolated`]) container, AND a **mount-bearing**
/// container all qualify — only an arbitrary **named**-network container keeps
/// the direct `podman` fallback (no regression). This is the guard a routed
/// `start` checks before delegating the airgap launch to jera→draupnir.
pub fn jera_routable(&self) -> bool {
matches!(self.image, ImageRef::Container(_) | ImageRef::Cached { .. })
&& self.jera_unsupported().is_empty()
}
// -------------------------------------------------------------------
// KVM / VM seam — the mirror of the container seam above for the
// `kvm::KvmController::start` path (which shells straight to `qemu`).
// -------------------------------------------------------------------
/// Map a **VM** [`MachineSpec`] onto the [`jera::vm::BootSpec`] a routed spawn
/// would boot through jera → draupnir's KVM backend (the mirror of
/// [`to_jera_container_spec`](Self::to_jera_container_spec) for the qemu path).
/// Carries the machine `name`, the disk image path, and the size
/// (`cpus`/`memory_mb`). Bails on an OCI image ref — a container routes through
/// the container seam ([`JobPolicy::Container`]), not `jera::vm`.
///
/// Skidbladnir boots a **full disk via its own bootloader** (no separate
/// `-kernel`; the KVM controller maps this onto
/// [`draupnir::ImageSource::BootloaderDisk`], which attaches the qcow2 as a
/// virtio drive and lets its in-image bootloader run), so `kernel`/`rootfs`
/// are left empty and the disk carries in `BootSpec::disk`. jera's VM payload cannot yet
/// express this kernel-less disk boot, nor the airgap net mode, cloud-init seed,
/// or extra-ISO attach this spec may carry (see
/// [`jera_vm_unsupported`](Self::jera_vm_unsupported)); those knobs are dropped
/// by this mapping, so a live route is gated on
/// [`jera_vm_routable`](Self::jera_vm_routable) — the direct `qemu` path stays
/// the fallback until jera/draupnir grow a bootloader-in-disk VM mode.
pub fn to_jera_vm_spec(&self) -> Result<jera::vm::BootSpec> {
let disk = match &self.image {
ImageRef::Path(p) => p.to_string_lossy().into_owned(),
// A cached disk resolves to its logical name here; the caller resolves
// it to a verified path via `ImageCache` before an actual boot.
ImageRef::Cached { name, .. } => name.clone(),
ImageRef::Container(r) => anyhow::bail!(
"to_jera_vm_spec: `{r}` is an OCI image ref — route a container through \
jera's container seam (JobPolicy::Container), not `jera::vm`"
),
};
// No separate kernel/rootfs — a Skidbladnir VM is a bootloader-in-disk boot.
let mut vm = jera::vm::BootSpec::kernel_rootfs(&self.name, "", "");
vm.rootfs = String::new();
vm.disk = disk;
vm.mem_mb = self.memory_mb;
vm.cores = self.cpus;
Ok(vm)
}
/// Map a **VM** [`MachineSpec`] onto the serde-ready jera **agent** VM payload
/// ([`jera::agent::VmBoot`]) — the self-contained, wire-safe spec a remote
/// worker maps back onto jera's VM runner in the multi-machine dispatch path
/// ([`jera::agent::WorkPayload::Vm`]). `image` = the disk ref; `cores`/`mem_mb`
/// carry. Bails on an OCI image ref (a container dispatches as
/// [`jera::agent::WorkPayload::Container`], not `Vm`).
pub fn to_jera_vm_payload(&self) -> Result<jera::agent::VmBoot> {
let image = match &self.image {
ImageRef::Path(p) => p.to_string_lossy().into_owned(),
ImageRef::Cached { name, .. } => name.clone(),
ImageRef::Container(r) => anyhow::bail!(
"to_jera_vm_payload: `{r}` is an OCI image ref — dispatch a container as \
WorkPayload::Container, not WorkPayload::Vm"
),
};
Ok(jera::agent::VmBoot {
image,
cores: self.cpus,
mem_mb: self.memory_mb as u64,
})
}
/// The knobs a VM [`MachineSpec`] carries that jera's **current** VM payload
/// ([`jera::vm::BootSpec`] → draupnir's KVM `ImageSource`) CANNOT express — the
/// exact gap blocking a faithful live swap of [`kvm::KvmController::start`].
/// Stable, sorted labels:
/// - `"disk-boot"` — **always present** for a VM: draupnir's KVM image sources
/// ([`Disk`]/`KernelRootfs`) both **require a `-kernel`** (`validate()` rejects
/// an empty kernel path), and jera's `vm::BootSpec::to_draupnir` is only
/// compiled under `vm-tunnr` anyway — there is **no kernel-less
/// bootloader-in-disk VM mode**, which is exactly how Skidbladnir boots. This
/// is the load-bearing structural gap (the VM analogue of the container
/// `"net"` gap), so no Skidbladnir VM is routable through jera today.
/// - `"net"` — anything but [`NetMode::Nat`]: the airgap `-nic none` (egress
/// isolation, this module's reason to exist) or a named bridge. jera's VM
/// payload has no net mode.
/// - `"provision"` — a [`Provision::CloudInit`]/[`Provision::Ignition`] seed:
/// jera's `vm::BootSpec` carries no cloud-init (draupnir's `BootSpec` does, but
/// jera's VM payload drops it), so first-boot provisioning is lost.
/// - `"iso"` — an install [`iso`](MachineSpec::iso) or [`data_iso`]: jera's VM
/// payload has no extra-CD attach.
pub fn jera_vm_unsupported(&self) -> Vec<&'static str> {
let mut gaps = Vec::new();
// Structural: a Skidbladnir VM is a kernel-less, bootloader-in-disk boot,
// which jera/draupnir's kernel-required VM payload cannot express today.
gaps.push("disk-boot");
if !matches!(self.net, NetMode::Nat) {
gaps.push("net");
}
if !matches!(self.provision, Provision::None) {
gaps.push("provision");
}
if self.iso.is_some() || self.data_iso.is_some() {
gaps.push("iso");
}
gaps
}
/// Whether this VM spec can be routed through jera **faithfully today** — it is
/// a disk-image VM (not an OCI container) AND carries no knob jera's VM payload
/// cannot express ([`jera_vm_unsupported`](Self::jera_vm_unsupported) is empty).
/// Because the kernel-less `"disk-boot"` gap is **always** present for a VM
/// (jera/draupnir has no bootloader-in-disk VM mode), this is **currently always
/// `false`** — every VM keeps the direct `qemu` path (no regression). It is the
/// guard a future routed `start` checks before delegating to jera; the day jera
/// gains a kernel-less disk boot (+ net + cloud-init + iso), the gap-list
/// shrinks, this opens up, and the tripwire test flips — the signal to land the
/// live VM route.
pub fn jera_vm_routable(&self) -> bool {
matches!(self.image, ImageRef::Path(_) | ImageRef::Cached { .. })
&& self.jera_vm_unsupported().is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The classifier maps an OCI image ref to a Container job and a disk image
/// to a VM job — the policy jera hands to its boot library. RED if the arms
/// are swapped.
#[test]
fn job_policy_classifies_container_vs_vm() {
assert_eq!(
MachineSpec::container("c", "docker.io/library/alpine:3").job_policy(),
jera::JobPolicy::Container,
);
assert_eq!(
MachineSpec::vm("v", "/img/base.qcow2").job_policy(),
jera::JobPolicy::Vm,
);
// A cached disk image is still a VM.
let mut s = MachineSpec::vm("v2", "/img/base.qcow2");
s.image = ImageRef::Cached {
name: "fedora-40".into(),
sha256: "ab".repeat(32),
};
assert_eq!(s.job_policy(), jera::JobPolicy::Vm);
}
/// A container spec maps onto a jera `ContainerSpec` carrying image + name,
/// and that jera spec maps onward onto a VALID `draupnir::BootSpec` — proving
/// the Skidbladnir → jera → draupnir spawn chain end-to-end with NO daemon.
/// A VM disk image is refused (routes through jera's `vm` seam instead).
#[test]
fn container_spec_maps_through_jera_onto_a_valid_draupnir_bootspec() {
let spec = MachineSpec::container("holger-c", "git.example.com/nordisk/holger:latest");
let js = spec
.to_jera_container_spec()
.expect("container maps to a jera spec");
assert_eq!(js.image, "git.example.com/nordisk/holger:latest");
assert_eq!(
js.name, "holger-c",
"the machine name carries (kill-addressable)"
);
// The jera spec maps onward to a draupnir container BootSpec that validates
// — the actual boot path a routed start would take (no podman needed). We
// assert via Debug so Skidbladnir need not link draupnir's types directly
// (jera owns that dep); the container backend + image must both carry.
let d = js.to_draupnir();
d.validate()
.expect("mapped draupnir container spec is valid");
let dbg = format!("{d:?}");
assert!(
dbg.contains("Container"),
"routes to draupnir's OCI/container backend: {dbg}"
);
assert!(
dbg.contains("git.example.com/nordisk/holger:latest"),
"the image ref carries through jera→draupnir: {dbg}"
);
// A VM disk image is not a container — refused with a pointer to the vm seam.
let err = MachineSpec::vm("v", "/img/base.qcow2")
.to_jera_container_spec()
.expect_err("a disk image is not a jera container");
assert!(
format!("{err}").contains("vm"),
"points at the vm seam: {err}"
);
}
/// The honest-gap tripwire — NOW FLIPPED for airgap + mounts (jera grew the
/// net mode + bind mounts). A NAT, an **airgap**, and a **mount-bearing**
/// container are all routable; only an arbitrary **named** host-network keeps
/// the `"net"` gap (jera can't express a named net) and the direct podman path.
/// RED-when-broken: reverting the net/mount mapping puts airgap/mounts back in
/// the gap and this fails.
#[test]
fn jera_routable_for_nat_airgap_and_mount_containers_not_named_net() {
// NAT, no mounts → faithfully routable, empty gap.
let ok = MachineSpec::container("c", "alpine:3");
assert!(ok.jera_unsupported().is_empty());
assert!(ok.jera_routable(), "a plain NAT container is routable");
// Airgap isolation → maps onto jera NetMode::None → routable (LANDED).
let iso = MachineSpec::container("c", "alpine:3").net(NetMode::Isolated);
assert!(
iso.jera_unsupported().is_empty(),
"airgap now carries via net=None"
);
assert!(
iso.jera_routable(),
"airgap container routes through jera→draupnir --network none"
);
// A bind mount → maps onto jera BindMount → routable (LANDED).
let mnt = MachineSpec::container("c", "alpine:3").mount(Mount {
host: "/h".into(),
guest: "/g".into(),
read_only: true,
});
assert!(
mnt.jera_unsupported().is_empty(),
"mounts carry via jera BindMount"
);
assert!(mnt.jera_routable());
// An airgap + mount container together: both carry, still routable.
let iso_mnt = MachineSpec::container("c", "alpine:3")
.net(NetMode::Isolated)
.mount(Mount {
host: "/h".into(),
guest: "/g".into(),
read_only: false,
});
assert!(iso_mnt.jera_unsupported().is_empty());
assert!(iso_mnt.jera_routable());
// An arbitrary NAMED host-network is the one remaining gap (jera's NetMode
// is Default/None/Host/Bridge, not an arbitrary name) → NOT routable.
let named = MachineSpec::container("c", "alpine:3").net(NetMode::Named("br0".into()));
assert_eq!(named.jera_unsupported(), vec!["net"]);
assert!(
!named.jera_routable(),
"a named net has no jera equivalent — direct podman fallback"
);
// A VM disk is never container-routable.
assert!(!MachineSpec::vm("v", "/d.qcow2").jera_routable());
}
/// AIRGAP LIVE-ROUTE assertion (L4, RED-when-broken): an `Isolated` container's
/// mapped `jera::container::ContainerSpec` carries `net = NetMode::None`, and
/// that maps ONWARD onto a `draupnir::BootSpec` whose network mode is `none`
/// (the live `--network none` egress-isolation knob) — i.e. the airgap intent
/// actually reaches the network-none knob through jera→draupnir. A NAT spec
/// carries `Default` (byte-identical, no `--network`). RED: neutralize the
/// `Isolated → None` mapping in [`jera_net`](MachineSpec::jera_net) and the
/// `net=None` / `net: None` assertions catch the egress-allowed regression.
#[test]
fn airgap_container_routes_through_jera_with_network_none() {
let iso = MachineSpec::container("air", "alpine:3").net(NetMode::Isolated);
let js = iso
.to_jera_container_spec()
.expect("airgap container maps to a jera spec");
// The load-bearing knob: the jera request carries net=None.
assert_eq!(
js.net,
jera::container::NetMode::None,
"airgap must reach jera's --network none knob"
);
assert!(iso.jera_routable(), "airgap is now routable through jera");
// Onward onto draupnir: the live BootSpec carries network mode `none`.
let d = js.to_draupnir();
d.validate().expect("mapped airgap draupnir spec is valid");
let dbg = format!("{d:?}");
assert!(
dbg.contains("net: None"),
"draupnir BootSpec must carry --network none (airgap egress isolation): {dbg}"
);
// A NAT container is byte-identical (Default net, no --network flag).
let nat = MachineSpec::container("nat", "alpine:3");
assert_eq!(
nat.to_jera_container_spec().unwrap().net,
jera::container::NetMode::Default,
"a NAT container keeps the engine default (no --network)"
);
// A bind mount carries onto jera's BindMount → draupnir `-v` bind string.
let mnt = MachineSpec::container("m", "alpine:3").mount(Mount {
host: "/host/data".into(),
guest: "/data".into(),
read_only: true,
});
let jm = mnt.to_jera_container_spec().unwrap();
assert_eq!(jm.bind_strings(), vec!["/host/data:/data:ro".to_string()]);
}
/// The VM seam maps a disk-image `MachineSpec` onto jera's VM payload
/// (`jera::vm::BootSpec`) carrying name + disk + size, and onto the serde-ready
/// agent `VmBoot` — the mirror of `to_jera_container_spec`. A container image
/// ref is refused (that routes through the container seam). RED if the fields
/// don't carry or a container is accepted as a VM.
#[test]
fn vm_spec_maps_onto_the_jera_vm_payload_and_agent_vmboot() {
let spec = MachineSpec::vm("holger-vm", "/srv/base.qcow2").resources(4, 4096);
let vm = spec
.to_jera_vm_spec()
.expect("a disk VM maps onto a jera BootSpec");
assert_eq!(vm.name, "holger-vm");
assert_eq!(
vm.disk, "/srv/base.qcow2",
"the disk carries (boot-addressable)"
);
assert_eq!(vm.mem_mb, 4096);
assert_eq!(vm.cores, 4);
assert!(
vm.kernel.is_empty() && vm.rootfs.is_empty(),
"bootloader-in-disk: no kernel/rootfs"
);
let payload = spec
.to_jera_vm_payload()
.expect("maps onto the agent VmBoot");
assert_eq!(payload.image, "/srv/base.qcow2");
assert_eq!(payload.cores, 4);
assert_eq!(payload.mem_mb, 4096u64);
// A cached disk carries its logical name through both mappings.
let mut cached = MachineSpec::vm("v2", "/x");
cached.image = ImageRef::Cached {
name: "fedora-40".into(),
sha256: "ab".repeat(32),
};
assert_eq!(cached.to_jera_vm_spec().unwrap().disk, "fedora-40");
assert_eq!(cached.to_jera_vm_payload().unwrap().image, "fedora-40");
// An OCI container ref is not a VM — refused with a pointer at the container seam.
let err = MachineSpec::container("c", "alpine:3")
.to_jera_vm_spec()
.expect_err("a container image is not a jera VM");
assert!(
format!("{err}").contains("container seam"),
"points at the container seam: {err}"
);
assert!(
MachineSpec::container("c", "alpine:3")
.to_jera_vm_payload()
.is_err()
);
}
/// The VM honest-gap tripwire: a Skidbladnir VM is a kernel-less
/// bootloader-in-disk boot, which jera/draupnir's kernel-required VM payload
/// cannot express — so the `"disk-boot"` gap is ALWAYS present and no VM is
/// routable through jera today (the direct `qemu` path stays; no regression).
/// Airgap net, a cloud-init seed, and an extra ISO stack on further gaps.
/// RED-when-broken: if jera later gains a kernel-less disk boot (+ net + seed +
/// iso) and someone drops these gaps, `jera_vm_routable` flips and this fails —
/// forcing the live-route decision to be made deliberately.
#[test]
fn jera_vm_routable_is_gated_by_the_missing_disk_boot_and_airgap_knobs() {
// A plain NAT VM: the structural disk-boot gap alone → NOT routable.
let plain = MachineSpec::vm("v", "/img/base.qcow2");
assert_eq!(plain.jera_vm_unsupported(), vec!["disk-boot"]);
assert!(
!plain.jera_vm_routable(),
"a kernel-less disk boot is unexpressible in jera today"
);
// Airgap egress isolation stacks the net gap (would silently lose -nic none).
let air = MachineSpec::vm("v", "/img/base.qcow2").net(NetMode::Isolated);
assert_eq!(air.jera_vm_unsupported(), vec!["disk-boot", "net"]);
assert!(!air.jera_vm_routable());
// A cloud-init firstboot seed stacks the provision gap (jera VM drops it).
let ci = MachineSpec::vm("v", "/img/base.qcow2").provision(Provision::CloudInit {
user_data: "#cloud-config\n".into(),
meta_data: None,
});
assert_eq!(ci.jera_vm_unsupported(), vec!["disk-boot", "provision"]);
// An install/data ISO stacks the iso gap.
let iso = MachineSpec::vm("v", "/img/base.qcow2")
.iso("/srv/install.iso")
.data_iso("/srv/skiddata.img");
assert_eq!(iso.jera_vm_unsupported(), vec!["disk-boot", "iso"]);
// All gaps reported together, stable order.
let all = MachineSpec::vm("v", "/img/base.qcow2")
.net(NetMode::Named("br0".into()))
.provision(Provision::Ignition {
config: "{}".into(),
})
.iso("/srv/install.iso");
assert_eq!(
all.jera_vm_unsupported(),
vec!["disk-boot", "net", "provision", "iso"]
);
assert!(!all.jera_vm_routable());
// A container image is never VM-routable (routes through the container seam).
assert!(!MachineSpec::container("c", "alpine:3").jera_vm_routable());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn machinespec_builders_set_fields() {
let s = MachineSpec::vm("vm1", "/img/base.qcow2")
.resources(4, 4096)
.net(NetMode::Isolated)
.readiness(Readiness::SystemdUnit("holger.service".into()));
assert_eq!(s.cpus, 4);
assert_eq!(s.memory_mb, 4096);
assert_eq!(s.net, NetMode::Isolated);
assert_eq!(s.image, ImageRef::Path("/img/base.qcow2".into()));
}
/// The KVM-vs-container decision rule: systemd/init/airgap → real VM.
#[test]
fn needs_real_machine_picks_kvm_for_systemd_init_airgap() {
// Airgap egress isolation → must be a real VM.
assert!(
MachineSpec::container("c", "img")
.net(NetMode::Isolated)
.needs_real_machine()
);
// systemd-unit readiness → real init → VM.
assert!(
MachineSpec::vm("v", "/d.qcow2")
.readiness(Readiness::SystemdUnit("holger.service".into()))
.needs_real_machine()
);
// cloud-init firstboot → VM.
assert!(
MachineSpec::vm("v", "/d.qcow2")
.provision(Provision::CloudInit {
user_data: "#cloud-config\n".into(),
meta_data: None
})
.needs_real_machine()
);
// Fast/stateless NAT container with a TCP probe → container is enough.
assert!(
!MachineSpec::container("c", "img")
.readiness(Readiness::TcpPort(8080))
.needs_real_machine()
);
}
/// ImageCache: content-addressed put → dedup → tag/resolve → verify-on-read.
#[test]
fn image_cache_content_addressed_dedup_and_verify() {
let dir = tempfile::tempdir().unwrap();
let cache = ImageCache::open(dir.path()).unwrap();
let a = cache.put_bytes(b"base-image-bytes").unwrap();
let b = cache.put_bytes(b"base-image-bytes").unwrap();
assert_eq!(a, b, "identical bytes dedup to one hash");
// One blob on disk despite two puts.
let blobs: Vec<_> = std::fs::read_dir(dir.path().join("blobs"))
.unwrap()
.collect();
assert_eq!(blobs.len(), 1, "deduped to a single blob");
cache.tag("fedora-40", &a).unwrap();
cache.tag("alias", &a).unwrap();
assert_eq!(cache.resolve("fedora-40").as_deref(), Some(a.as_str()));
// path_for verifies on read.
let p = cache.path_for(&a).unwrap().expect("present");
assert!(p.exists());
// resolve_ref of a Cached ref returns the verified path.
let r = cache
.resolve_ref(&ImageRef::Cached {
name: "fedora-40".into(),
sha256: a.clone(),
})
.unwrap();
assert_eq!(r, Some(p));
}
/// GC reaps a blob no name points at anymore (the invalidation path).
#[test]
fn image_cache_gc_sweeps_orphans() {
let dir = tempfile::tempdir().unwrap();
let cache = ImageCache::open(dir.path()).unwrap();
let old = cache.put_bytes(b"v1").unwrap();
cache.tag("img", &old).unwrap();
// New version: repoint the name (invalidation), old blob orphaned.
let new = cache.put_bytes(b"v2").unwrap();
cache.tag("img", &new).unwrap();
let swept = cache.gc().unwrap();
assert_eq!(swept, 1, "the orphaned v1 blob is swept");
assert!(cache.path_for(&old).unwrap().is_none());
assert!(cache.path_for(&new).unwrap().is_some());
}
/// Corrupting a blob makes path_for fail the verify-on-read (airgap integrity).
#[test]
fn image_cache_detects_corruption_on_read() {
let dir = tempfile::tempdir().unwrap();
let cache = ImageCache::open(dir.path()).unwrap();
let sha = cache.put_bytes(b"genuine").unwrap();
// Tamper with the blob bytes.
std::fs::write(dir.path().join("blobs").join(&sha), b"tampered").unwrap();
assert!(
cache.path_for(&sha).is_err(),
"verify-on-read catches tampering"
);
}
/// container run argv carries name/net/mounts/image (pure, no runtime needed).
#[cfg(feature = "container")]
#[test]
fn container_run_argv_shape() {
use container::ContainerController;
let c = ContainerController::with_runtime("podman");
let spec = MachineSpec::container("test1", "docker.io/library/alpine:3")
.net(NetMode::Isolated)
.mount(Mount {
host: "/host/data".into(),
guest: "/data".into(),
read_only: true,
});
let argv = c.run_argv(&spec).unwrap();
let joined = argv.join(" ");
assert!(joined.contains("run -d"));
assert!(joined.contains("--name test1"));
assert!(joined.contains("--network none"), "isolated → no egress");
assert!(joined.contains("/host/data:/data:ro"));
assert!(joined.ends_with("docker.io/library/alpine:3"));
}
/// KVM isolated → the BootSpec carries draupnir's airgap net (`NetMode::None`,
/// tunnr's `-nic none`); NAT/Named → the default user-mode net. The disk-boot
/// case maps to `ImageSource::BootloaderDisk` (no `-kernel`, OVMF firmware boot).
#[cfg(feature = "kvm")]
#[test]
fn kvm_isolated_boot_spec_has_airgap_net() {
let disk = std::path::Path::new("/srv/base.qcow2");
let air = MachineSpec::vm("vm-air", "/srv/base.qcow2").net(NetMode::Isolated);
let b = kvm::KvmController::to_boot_spec(&air, disk);
assert_eq!(
b.net,
draupnir::NetMode::None,
"isolated → airgap -nic none"
);
assert!(
matches!(b.image, draupnir::ImageSource::BootloaderDisk { .. }),
"disk boot → firmware BootloaderDisk (no -kernel)"
);
let nat = MachineSpec::vm("vm-nat", "/srv/base.qcow2"); // default NetMode::Nat
assert_eq!(
kvm::KvmController::to_boot_spec(&nat, disk).net,
draupnir::NetMode::Default,
"NAT → default user-mode net"
);
}
/// The cloud-init seed render, now via the ONE builder: Skidbladnir's `Provision`
/// is adapted to a `draupnir::CloudInit` and `draupnir::seed::seed_files` renders
/// the NoCloud `user-data` + `meta-data` pair; non-CloudInit yields nothing.
/// RED-WHEN-BROKEN equivalence: an omitted `meta-data` must still carry
/// Skidbladnir's OWN default hostname (`skidbladnir`), NOT draupnir's, so routing
/// onto the shared builder is byte-preserving for the lifted call sites.
#[cfg(feature = "kvm")]
#[test]
fn nocloud_seed_files_render() {
let p = Provision::CloudInit {
user_data: "#cloud-config\nruncmd: [echo hi]\n".into(),
meta_data: None,
};
let ci = kvm::KvmController::cloud_init(&p).expect("CloudInit renders");
let files = draupnir::seed::seed_files(&ci);
assert_eq!(files[0].0, "user-data");
assert!(files[0].1.contains("#cloud-config"));
assert_eq!(files[1].0, "meta-data");
assert!(
files[1].1.contains("instance-id"),
"a default meta-data is supplied"
);
// Behaviour-preserving: Skidbladnir's default hostname survives, draupnir's does not.
assert!(
files[1].1.contains("skidbladnir"),
"Skidbladnir's own default meta-data hostname"
);
assert!(
!files[1].1.contains("draupnir"),
"draupnir's default must NOT leak through"
);
assert!(kvm::KvmController::cloud_init(&Provision::None).is_none());
}
/// The matrix launch: `extra_qemu_args` wires the read-only data disk, the
/// virtio-serial report port, and the pure-Rust guest-agent port (the devices
/// tunnr does not model natively); the base disk + cloud-init seed are attached
/// by tunnr itself (draupnir carries cloud-init on the BootSpec), so they are
/// deliberately absent from the extra args.
#[cfg(feature = "kvm")]
#[test]
fn extra_qemu_args_wire_data_serial_and_guest_agent() {
let spec = MachineSpec::vm("vm-m", "/srv/base.qcow2")
.data_iso("/srv/skiddata.img")
.serial_out("/srv/vm-m/report.jsonl")
.qga_sock("/srv/vm-m/qga.sock");
let joined = kvm::KvmController::extra_qemu_args(&spec).join(" ");
assert!(
joined.contains("file=/srv/skiddata.img,format=raw,if=virtio,readonly=on"),
"data disk"
);
assert!(
joined.contains("virtserialport,chardev=skidreport,name=org.skidbladnir.report"),
"report port"
);
assert!(
joined.contains("file,id=skidreport,path=/srv/vm-m/report.jsonl"),
"report → host file"
);
assert!(
joined.contains("socket,id=qga0,path=/srv/vm-m/qga.sock,server=on,wait=off"),
"guest-agent socket"
);
assert!(
joined.contains("virtserialport,chardev=qga0,name=org.qemu.guest_agent.0"),
"guest-agent port"
);
// The base disk + seed are tunnr's job, NOT extra args.
assert!(
!joined.contains("/srv/base.qcow2"),
"base disk is tunnr's boot drive"
);
assert!(
!joined.contains("seed"),
"seed is attached by tunnr's with_seed"
);
// cloud-init rides on the BootSpec (draupnir authors + attaches the seed).
let boot = MachineSpec::vm("vm-c", "/srv/base.qcow2");
let with_ci = MachineSpec {
provision: Provision::CloudInit {
user_data: "#cloud-config\n".into(),
meta_data: None,
},
..boot
};
let bspec =
kvm::KvmController::to_boot_spec(&with_ci, std::path::Path::new("/srv/base.qcow2"));
assert!(
bspec.cloud_init.is_some(),
"cloud-init carried onto draupnir BootSpec"
);
}
/// RED-when-broken — `persist_disk` must reach draupnir, and must default OFF.
///
/// Both halves matter and they fail in opposite, equally quiet ways. Default ON
/// would let the install matrix mutate the shared base images it boots straight
/// out of `$SKID_ISOS`, poisoning every later run. Not carried through at all and
/// a caller that asked for persistence silently gets a throwaway overlay — the
/// second boot of a two-boot test then reports confident facts about a machine
/// that was never installed (measured on oden 2026-08-03: the per-run disk copy
/// was byte-identical to the base after a full install).
#[cfg(feature = "kvm")]
#[test]
fn persist_disk_defaults_off_and_is_carried_onto_the_draupnir_bootspec() {
let disk = std::path::Path::new("/srv/per-run-copy.qcow2");
let spec = MachineSpec::vm("vm-p", disk);
assert!(
!spec.persist_disk,
"COW protection is the DEFAULT — a shared base is never mutated"
);
assert!(
!kvm::KvmController::to_boot_spec(&spec, disk).persist_disk,
"and that default reaches draupnir"
);
let owned = MachineSpec::vm("vm-p", disk).persist_disk(true);
assert!(
kvm::KvmController::to_boot_spec(&owned, disk).persist_disk,
"a caller that owns its disk must get its writes kept — this is what makes two \
boots of one machine expressible"
);
}
/// The pure-Rust guest-agent reply parsers (no socket needed).
#[cfg(feature = "kvm")]
#[test]
fn guest_agent_reply_parsers() {
let pidv: serde_json::Value = serde_json::json!({"return":{"pid":42}});
assert_eq!(kvm::KvmController::parse_exec_pid(&pidv).unwrap(), 42);
// still running → None
let running: serde_json::Value = serde_json::json!({"return":{"exited":false}});
assert!(
kvm::KvmController::parse_exec_status(&running)
.unwrap()
.is_none()
);
// exited with base64 "active\n" on stdout
let done: serde_json::Value = serde_json::json!({
"return":{"exited":true,"exitcode":0,"out-data":"YWN0aXZlCg=="}
});
let (code, out, _err) = kvm::KvmController::parse_exec_status(&done)
.unwrap()
.unwrap();
assert_eq!(code, 0);
assert_eq!(String::from_utf8_lossy(&out), "active\n");
}
/// The seed image draupnir now authors (via `KvmController::build_seed_image` →
/// `draupnir::seed::build_seed_image`) is a labelled FAT holding the two NoCloud
/// files, read back via fatfs (proves cloud-init will find `user-data`/`meta-data`).
#[cfg(feature = "kvm")]
#[test]
fn build_fat_seed_image_roundtrips() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("seed.img");
let p = Provision::CloudInit {
user_data: "#cloud-config\nruncmd:\n - [echo, hi]\n".into(),
meta_data: None,
};
// draupnir now authors + attaches the seed on the BootSpec's cloud-init; the
// adapter's job is the `Provision → draupnir::CloudInit` map, then the ONE
// shared builder makes the labelled FAT image.
let ci = kvm::KvmController::cloud_init(&p).expect("CloudInit renders");
draupnir::seed::build_seed_image(&out, &ci).expect("CloudInit → image");
// Read it back through fatfs and confirm the files + content survive.
let img = std::fs::File::options()
.read(true)
.write(true)
.open(&out)
.unwrap();
let fs = fatfs::FileSystem::new(img, fatfs::FsOptions::new()).unwrap();
let names: Vec<String> = fs
.root_dir()
.iter()
.map(|e| e.unwrap().file_name())
.collect();
assert!(
names.iter().any(|n| n == "user-data"),
"user-data present, got {names:?}"
);
assert!(names.iter().any(|n| n == "meta-data"), "meta-data present");
assert_eq!(fs.volume_label().to_ascii_lowercase(), "cidata");
}
/// HEAVY (real container runtime): full start → exec → stop round-trip. SKIPS
/// when no podman/docker is present (so the default run stays hermetic).
#[cfg(feature = "container")]
#[test]
#[ignore = "heavy: needs a real container runtime (podman/docker)"]
fn container_start_exec_stop_roundtrip() {
use container::ContainerController;
let c = match ContainerController::detect() {
Ok(c) => c,
Err(e) => {
eprintln!("skip: {e}");
return;
}
};
let spec = MachineSpec::container("skidbladnir-selftest", "docker.io/library/alpine:3")
// keep it alive so we can exec into it
.net(NetMode::Nat);
// alpine exits immediately without a command; override via a sleep image arg
// is out of MachineSpec's scope, so this heavy test documents the path.
let h = match c.start(&spec) {
Ok(h) => h,
Err(e) => {
eprintln!("skip (pull/run failed, likely airgap): {e}");
return;
}
};
let _ = c.exec(&h, &["echo", "hi"]);
c.stop(&h).expect("stop");
}
}