draupnir 0.1.8

Draupnir — the nordisk boot/provisioning library: fire up a runtime from one BootSpec across three backends (KVM via tunnr · OCI container · Redfish bare-metal virtual-media) and drive its power lifecycle. Odin's ring that drips eight identical copies → boot a fleet of identical machines from one ISO.
Documentation
//! **KVM backend** — fire up an appliance VM by driving **tunnr**.
//!
//! Draupnir does not reimplement VM boot. tunnr owns the QEMU + OVMF (UEFI) + KVM
//! launch and exposes a self-contained, kill-able primitive —
//! `tunnr_vm::boot_test(BootSpec) -> BootHandle`. This backend is the thin adapter
//! that maps a Draupnir [`BootSpec`] onto tunnr's `BootSpec`, calls `boot_test`,
//! and adapts the returned `BootHandle` into a [`Machine`] / [`Lifecycle`].
//!
//! The trait wiring compiles unconditionally; the live tunnr call is behind the
//! `backend-tunnr` feature (an optional path dep on `tunnr-vm`, exactly how `jera`
//! wires it — switch to a version once tunnr publishes to crates.io). Without the
//! feature every entry point returns an honest [`Error::Unsupported`] rather than
//! pretending to boot.
//!
//! **Live handles.** [`Boot::boot`] returns a plain-data [`Machine`] (an id + power
//! state), but [`Lifecycle`] needs tunnr's `BootHandle` to kill/poll the exact QEMU
//! process group. The backend therefore keeps a small registry
//! (`machine id → BootHandle`) so `power_off`/`status` address the VM that `boot`
//! actually launched.

use crate::{Boot, BootSpec, CloudInit, Error, Lifecycle, Machine, PowerState, Result};
#[cfg(feature = "backend-tunnr")]
use crate::ImageSource;

#[cfg(feature = "backend-tunnr")]
use std::collections::HashMap;
#[cfg(feature = "backend-tunnr")]
use std::sync::{Arc, Mutex};

/// The registry of live tunnr boot handles, keyed by [`Machine::id`].
#[cfg(feature = "backend-tunnr")]
type LiveHandles = Arc<Mutex<HashMap<String, tunnr_vm::BootHandle>>>;

/// The KVM boot backend (drives tunnr).
#[derive(Default, Clone)]
pub struct KvmBoot {
    /// Live tunnr handles for the VMs this backend booted, so [`Lifecycle`] can
    /// kill/poll the exact QEMU process group. `BootHandle` is a cheap `Arc` clone.
    #[cfg(feature = "backend-tunnr")]
    live: LiveHandles,
}

impl std::fmt::Debug for KvmBoot {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("KvmBoot").finish_non_exhaustive()
    }
}

impl KvmBoot {
    /// Construct the KVM backend.
    pub fn new() -> Self {
        Self::default()
    }

    /// **Pure** render of the cloud-init NoCloud seed file set —
    /// `[("user-data", …), ("meta-data", …)]` (plus `network-config` when set).
    /// A thin re-export of the owned builder [`crate::seed::seed_files`] so the
    /// seed lives in exactly one place (draupnir's [`crate::seed`] module); kept
    /// here for the KVM backend's existing call sites.
    pub fn nocloud_seed_files(ci: &CloudInit) -> Vec<(&'static str, String)> {
        crate::seed::seed_files(ci)
    }

    /// Map a Draupnir [`BootSpec`] onto tunnr's `tunnr_vm::BootSpec`.
    ///
    /// Field-for-field this is the seam `jera::vm` already wires. Only compiled
    /// with `backend-tunnr` (the type comes from the optional `tunnr-vm` dep).
    #[cfg(feature = "backend-tunnr")]
    pub fn to_tunnr_spec(&self, spec: &BootSpec) -> Result<tunnr_vm::BootSpec> {
        use std::path::PathBuf;
        use std::time::Duration;

        let (kernel, rootfs_or_disk) = match &spec.image {
            ImageSource::KernelRootfs { kernel, rootfs } => (kernel.clone(), rootfs.clone()),
            ImageSource::Disk { kernel, disk } => {
                // tunnr attaches a non-cpio image as a virtio -drive; a kernel is
                // still required for the direct-kernel boot path and now travels
                // in the spec (validated non-empty by BootSpec::validate).
                (kernel.clone(), disk.clone())
            }
            other => {
                return Err(Error::Spec(format!("kvm backend cannot boot {other:?}")))
            }
        };
        let mut boot = tunnr_vm::BootSpec::smoke(PathBuf::from(kernel), PathBuf::from(rootfs_or_disk));
        boot.mem_mb = spec.mem_mb;
        boot.cores = spec.cores;
        boot.headless = true;
        // Let a slow appliance boot be tuned without a rebuild (parity with jera).
        if let Some(secs) = std::env::var("DRAUPNIR_VM_BOOT_TIMEOUT")
            .ok()
            .and_then(|s| s.parse().ok())
        {
            boot.timeout = Duration::from_secs(secs);
        }
        if !spec.cmdline.is_empty() {
            boot.kernel_cmdline = spec.cmdline.clone();
        }
        // cloud-init NoCloud: author the seed image and attach it as a read-only
        // virtio drive (verbatim `extra_qemu_args` — tunnr's spec-appended seam).
        // The guest's cloud-init finds it by its `cidata` vfat label at first boot.
        if let Some(ci) = &spec.cloud_init {
            let seed = Self::build_seed_image(&Self::seed_path(&spec.name), ci)?;
            boot.extra_qemu_args.push("-drive".into());
            boot.extra_qemu_args.push(format!(
                "file={},format=raw,if=virtio,readonly=on",
                seed.display()
            ));
        }
        Ok(boot)
    }

    /// The temp path the NoCloud seed image for `name` is authored at.
    #[cfg(feature = "backend-tunnr")]
    fn seed_path(name: &str) -> std::path::PathBuf {
        std::env::temp_dir().join(format!("draupnir-{name}-seed.img"))
    }

    /// Materialize a [`CloudInit`] into a **NoCloud seed image** at `out` — a thin
    /// call onto the owned builder [`crate::seed::build_seed_image`] (vfat, volume
    /// label `cidata`, pure-Rust `fatfs`, no `genisoimage`/`mkisofs`). The seed
    /// authoring lives in exactly one place ([`crate::seed`]); this is the KVM
    /// backend's call site.
    #[cfg(feature = "backend-tunnr")]
    pub fn build_seed_image(out: &std::path::Path, ci: &CloudInit) -> Result<std::path::PathBuf> {
        crate::seed::build_seed_image(out, ci)
    }
}

impl Boot for KvmBoot {
    fn boot(&self, spec: &BootSpec) -> Result<Machine> {
        spec.validate()?;
        #[cfg(feature = "backend-tunnr")]
        {
            let tunnr_spec = self.to_tunnr_spec(spec)?;
            let handle = tunnr_vm::boot_test(tunnr_spec)
                .map_err(|e| Error::Backend(format!("tunnr boot_test: {e}")))?;
            let id = handle.id().to_string();
            let machine = Machine::started(&id, spec);
            self.live.lock().unwrap().insert(id, handle);
            Ok(machine)
        }
        #[cfg(not(feature = "backend-tunnr"))]
        {
            let _ = spec;
            Err(Error::Unsupported(
                "kvm backend needs the `backend-tunnr` feature (drives tunnr's KVM boot primitive)"
                    .into(),
            ))
        }
    }
}

impl Lifecycle for KvmBoot {
    fn power_on(&self, machine: &Machine) -> Result<()> {
        let _ = machine;
        // tunnr's primitive is fire-and-forget (a booted VM has no re-power line);
        // the honest answer is "boot a fresh instance", not a fake success.
        Err(Error::Unsupported(
            "kvm/tunnr VMs are fire-and-forget: re-power by calling draupnir::boot() again".into(),
        ))
    }

    fn power_off(&self, machine: &Machine) -> Result<()> {
        #[cfg(feature = "backend-tunnr")]
        {
            let handle = self
                .live
                .lock()
                .unwrap()
                .remove(&machine.id)
                .ok_or_else(|| Error::Backend(format!("no live tunnr VM for {}", machine.id)))?;
            handle.kill();
            Ok(())
        }
        #[cfg(not(feature = "backend-tunnr"))]
        {
            let _ = machine;
            Err(Error::Unsupported(
                "kvm backend needs the `backend-tunnr` feature".into(),
            ))
        }
    }

    fn status(&self, machine: &Machine) -> Result<PowerState> {
        #[cfg(feature = "backend-tunnr")]
        {
            let guard = self.live.lock().unwrap();
            let Some(handle) = guard.get(&machine.id) else {
                return Ok(PowerState::Unknown);
            };
            Ok(map_status(handle.poll_status()))
        }
        #[cfg(not(feature = "backend-tunnr"))]
        {
            let _ = machine;
            Err(Error::Unsupported(
                "kvm backend needs the `backend-tunnr` feature".into(),
            ))
        }
    }
}

/// Map a tunnr `BootStatus` onto draupnir's [`PowerState`]: still booting or booted
/// OK ⇒ powered `On`; any terminal (failed/killed/timed-out) ⇒ `Off`.
#[cfg(feature = "backend-tunnr")]
fn map_status(s: tunnr_vm::BootStatus) -> PowerState {
    match s {
        tunnr_vm::BootStatus::Booting | tunnr_vm::BootStatus::BootedOk => PowerState::On,
        tunnr_vm::BootStatus::Failed(_)
        | tunnr_vm::BootStatus::Killed
        | tunnr_vm::BootStatus::TimedOut => PowerState::Off,
    }
}

#[cfg(test)]
mod seed_tests {
    use super::*;

    #[test]
    fn nocloud_seed_files_render_user_data_and_a_default_meta_data() {
        let ci = CloudInit::user_data("#cloud-config\nruncmd:\n  - [echo, hi]\n");
        let files = KvmBoot::nocloud_seed_files(&ci);
        assert_eq!(files[0].0, "user-data");
        assert_eq!(files[0].1, "#cloud-config\nruncmd:\n  - [echo, hi]\n");
        assert_eq!(files[1].0, "meta-data");
        // A default meta-data is supplied when the spec omits it.
        assert!(files[1].1.contains("instance-id"), "default meta-data has an instance-id");
        assert!(files[1].1.contains("local-hostname"));
    }

    #[test]
    fn nocloud_seed_files_pass_through_an_explicit_meta_data() {
        let ci = CloudInit {
            user_data: "#cloud-config\n".into(),
            meta_data: Some("instance-id: node-7\n".into()),
            network_config: None,
        };
        let files = KvmBoot::nocloud_seed_files(&ci);
        assert_eq!(files[1].1, "instance-id: node-7\n");
    }

    #[test]
    fn nocloud_seed_files_omit_network_config_by_default_and_emit_it_when_set() {
        // No network-config → just the two required files.
        let plain = CloudInit::user_data("#cloud-config\n");
        let files = KvmBoot::nocloud_seed_files(&plain);
        assert_eq!(files.len(), 2);
        assert!(!files.iter().any(|(n, _)| *n == "network-config"));

        // With one → a third `network-config` file carrying the document.
        let net = CloudInit::user_data("#cloud-config\n")
            .with_network_config("version: 2\nethernets:\n  eth0:\n    dhcp4: true\n");
        let files = KvmBoot::nocloud_seed_files(&net);
        assert_eq!(files.len(), 3);
        let nc = files.iter().find(|(n, _)| *n == "network-config").expect("network-config present");
        assert!(nc.1.contains("dhcp4: true"));
    }
}

#[cfg(all(test, feature = "backend-tunnr"))]
mod tests {
    use super::*;
    use crate::ImageSource;

    /// Author the NoCloud seed image, then read it back through `fatfs` and confirm
    /// the two files + their contents survive and the volume label is `cidata`
    /// (proves the guest's cloud-init will find `user-data`/`meta-data`).
    #[test]
    fn build_seed_image_writes_a_cidata_fat_with_both_files() {
        let out = std::env::temp_dir()
            .join(format!("draupnir-seedtest-{}.img", std::process::id()));
        let _ = std::fs::remove_file(&out);
        let ci = CloudInit::user_data("#cloud-config\nruncmd:\n  - [echo, hi]\n");
        let path = KvmBoot::build_seed_image(&out, &ci).unwrap();
        assert_eq!(path, out);

        let img = std::fs::File::options().read(true).write(true).open(&out).unwrap();
        let fs = fatfs::FileSystem::new(img, fatfs::FsOptions::new()).unwrap();
        assert_eq!(fs.volume_label().to_ascii_lowercase(), "cidata");
        let mut names: Vec<String> = fs.root_dir().iter().map(|e| e.unwrap().file_name()).collect();
        names.sort();
        assert!(names.iter().any(|n| n == "user-data"), "user-data present, got {names:?}");
        assert!(names.iter().any(|n| n == "meta-data"), "meta-data present, got {names:?}");
        // Content survives the round-trip.
        use std::io::Read;
        let mut buf = String::new();
        fs.root_dir().open_file("user-data").unwrap().read_to_string(&mut buf).unwrap();
        assert!(buf.contains("runcmd"), "user-data content survives: {buf:?}");

        let _ = std::fs::remove_file(&out);
    }

    /// A spec carrying cloud-init makes `to_tunnr_spec` append a read-only virtio
    /// `-drive` for the seed image; a spec without it appends nothing.
    #[test]
    fn to_tunnr_spec_attaches_the_seed_drive_only_when_cloud_init_is_present() {
        let plain = BootSpec::kvm_kernel_rootfs("plain", "/bzImage", "/rootfs.cpio.gz");
        let t = KvmBoot::new().to_tunnr_spec(&plain).unwrap();
        assert!(!t.extra_qemu_args.iter().any(|a| a.contains("seed.img")), "no seed drive without cloud-init");

        let seeded = BootSpec::kvm_kernel_rootfs("seeded", "/bzImage", "/rootfs.cpio.gz")
            .with_cloud_init(CloudInit::user_data("#cloud-config\n"));
        let t = KvmBoot::new().to_tunnr_spec(&seeded).unwrap();
        let drive = t
            .extra_qemu_args
            .windows(2)
            .find(|w| w[0] == "-drive" && w[1].contains("draupnir-seeded-seed.img"))
            .expect("a -drive for the seed image is appended");
        assert!(drive[1].contains("format=raw"), "raw virtio drive: {:?}", drive[1]);
        assert!(drive[1].contains("readonly=on"), "read-only: {:?}", drive[1]);
        let _ = std::fs::remove_file(KvmBoot::seed_path("seeded"));
    }

    #[test]
    fn kvm_spec_maps_kernel_rootfs_mem_and_cores_onto_tunnr() {
        let mut spec = BootSpec::kvm_kernel_rootfs("appliance", "/bzImage", "/rootfs.cpio.gz");
        spec.mem_mb = 1024;
        spec.cores = 4;
        spec.cmdline = "korp.smoke=1".into();
        let t = KvmBoot::new().to_tunnr_spec(&spec).unwrap();
        assert_eq!(t.kernel, std::path::PathBuf::from("/bzImage"));
        assert_eq!(t.rootfs_or_disk, std::path::PathBuf::from("/rootfs.cpio.gz"));
        assert_eq!(t.mem_mb, 1024);
        assert_eq!(t.cores, 4);
        assert!(t.headless);
        assert_eq!(t.kernel_cmdline, "korp.smoke=1");
    }

    #[test]
    fn kvm_spec_maps_a_disk_boot_with_its_kernel_onto_tunnr() {
        // Regression: a Disk boot must carry a real `-kernel` onto tunnr, not an
        // empty string (the old cmdline_kernel() stub returned "").
        let spec = BootSpec::kvm_disk("disky", "/bzImage", "/disk.qcow2");
        let t = KvmBoot::new().to_tunnr_spec(&spec).unwrap();
        assert_eq!(t.kernel, std::path::PathBuf::from("/bzImage"));
        assert_eq!(t.rootfs_or_disk, std::path::PathBuf::from("/disk.qcow2"));
        assert!(!t.kernel.as_os_str().is_empty(), "disk boot kernel must be non-empty");
    }

    #[test]
    fn kvm_spec_rejects_a_non_kvm_image() {
        let mut spec = BootSpec::kvm_kernel_rootfs("bad", "/k", "/r");
        spec.image = ImageSource::OciImage("redis:7".into());
        assert!(matches!(KvmBoot::new().to_tunnr_spec(&spec), Err(Error::Spec(_))));
    }

    #[test]
    fn status_of_an_unknown_machine_is_unknown() {
        let kvm = KvmBoot::new();
        let m = Machine {
            id: "boot-does-not-exist".into(),
            spec_name: "x".into(),
            backend: crate::Backend::Kvm,
            power: PowerState::Unknown,
        };
        assert_eq!(kvm.status(&m).unwrap(), PowerState::Unknown);
    }

    #[test]
    fn map_status_folds_live_states_to_on_and_every_terminal_to_off() {
        // The two live states are `On`; every terminal (failed for any reason,
        // killed, timed-out) collapses to `Off` — a booted VM that later died is
        // powered off, not still on.
        assert_eq!(map_status(tunnr_vm::BootStatus::Booting), PowerState::On);
        assert_eq!(map_status(tunnr_vm::BootStatus::BootedOk), PowerState::On);
        assert_eq!(
            map_status(tunnr_vm::BootStatus::Failed("qemu exited 1".into())),
            PowerState::Off
        );
        assert_eq!(map_status(tunnr_vm::BootStatus::Killed), PowerState::Off);
        assert_eq!(map_status(tunnr_vm::BootStatus::TimedOut), PowerState::Off);
    }
}