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

nordisk/edda

crates/nornir-build-thing/src/vm.rs

//! **VM / appliance BOOT build** — a third build *target kind* alongside the
//! cargo [`BuildVariant`](crate::variant::BuildVariant) and the
//! [`ContainerBuild`](crate::container::ContainerBuild) image.
//!
//! Where a `ContainerBuild` describes an OCI/appliance *image*, a [`VmBuild`]
//! describes a **bootable virtual machine**: a `kernel`, an optional `rootfs`
//! (initramfs) and/or `disk` image, the machine size (`mem_mb`, `cores`), the
//! kernel `boot_params` (cmdline), and the boot `format` (a plain QEMU boot, a
//! microVM, or a full appliance). It is the "vm type in the build-thing
//! structure": the descriptor a nornir **test-boot** maps onto tunnr's boot
//! primitive (`BootSpec`) to spin the built target up as a KVM guest.
//!
//! Like [`container`](crate::container) it is *data* (pure serde) and
//! dependency-light: it materializes the QEMU **argv** ([`VmBuild::qemu_args`])
//! but runs nothing itself — the host (nornir's test-boot, or tunnr) drives the
//! hypervisor. It is content-addressable ([`VmBuild::content_id`]) so a VM boot
//! can carry provenance the same way a cargo/image build does. A `rootfs`/`disk`
//! layer can chain the artifact a cargo [`variant`](crate::variant) produced via
//! [`from_variant`](VmBuild::from_variant) — the "build holger, then boot it as a
//! VM" chain.
//!
//! ```
//! use nornir_build_thing::{VmBuild, VmFormat};
//!
//! let vm = VmBuild {
//!     name: "holger-appliance".into(),
//!     kernel: "/boot/vmlinuz".into(),
//!     rootfs: "artifacts/rootfs.cpio.gz".into(),
//!     mem_mb: 512,
//!     cores: 2,
//!     boot_params: vec!["console=ttyS0".into(), "panic=-1".into()],
//!     format: VmFormat::Qemu,
//!     ..Default::default()
//! };
//! assert!(vm.qemu_args().iter().any(|a| a == "-kernel"));
//! assert_eq!(vm.content_id().len(), 64);
//! ```

use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};

/// The boot flavor a [`VmBuild`] describes. Deserializes from a bare TOML string
/// (`format = "qemu"`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "String", into = "String")]
pub enum VmFormat {
    /// A plain QEMU/KVM boot (kernel + rootfs/disk) — the portable default.
    Qemu,
    /// A lightweight microVM (`-machine microvm`), minimal device model.
    MicroVm,
    /// A full appliance boot (UEFI/OVMF firmware, a real disk image).
    Appliance,
}

impl Default for VmFormat {
    fn default() -> Self {
        VmFormat::Qemu
    }
}

impl From<String> for VmFormat {
    fn from(s: String) -> Self {
        match s.trim().to_ascii_lowercase().as_str() {
            "" | "qemu" | "kvm" => VmFormat::Qemu,
            "microvm" | "micro" => VmFormat::MicroVm,
            "appliance" | "uefi" | "disk" => VmFormat::Appliance,
            _ => VmFormat::Qemu,
        }
    }
}

impl From<VmFormat> for String {
    fn from(f: VmFormat) -> Self {
        f.as_str().to_string()
    }
}

impl VmFormat {
    /// Stable label (matches the string form).
    pub fn as_str(&self) -> &'static str {
        match self {
            VmFormat::Qemu => "qemu",
            VmFormat::MicroVm => "microvm",
            VmFormat::Appliance => "appliance",
        }
    }

    /// The `-machine` type QEMU boots this flavor with.
    pub fn machine(&self) -> &'static str {
        match self {
            VmFormat::Qemu => "q35,accel=kvm:tcg",
            VmFormat::MicroVm => "microvm,accel=kvm:tcg",
            VmFormat::Appliance => "q35,accel=kvm:tcg",
        }
    }
}

/// One concrete **VM boot point** — the bootable sibling of a cargo
/// [`BuildVariant`](crate::variant::BuildVariant) and a
/// [`ContainerBuild`](crate::container::ContainerBuild).
///
/// Serializes as a `[[build.vm]]` table:
/// ```toml
/// [[build.vm]]
/// name = "holger-appliance"
/// kernel = "/boot/vmlinuz"
/// rootfs = "artifacts/rootfs.cpio.gz"
/// mem_mb = 512
/// cores = 2
/// boot_params = ["console=ttyS0", "panic=-1"]
/// format = "qemu"
/// from_variant = "full"   # boot the cargo `full` variant's artifact
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VmBuild {
    /// Stable handle (`nornir build --vm <name>` / the test-boot row).
    pub name: String,
    /// Path to the kernel image (`-kernel`), e.g. a Buildroot `bzImage`.
    pub kernel: String,
    /// Path to the initramfs / rootfs (`-initrd`), or `""` when the VM boots a
    /// full `disk` image instead.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub rootfs: String,
    /// Path to a bootable disk image (`-drive`), or `""` when the VM boots
    /// `kernel` + `rootfs` directly.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub disk: String,
    /// Guest memory in MiB (`-m`).
    #[serde(default = "default_mem_mb")]
    pub mem_mb: u32,
    /// Guest vCPU count (`-smp`).
    #[serde(default = "default_cores")]
    pub cores: u32,
    /// Kernel command-line parameters (`-append`), joined with spaces.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub boot_params: Vec<String>,
    /// The boot flavor.
    #[serde(default, skip_serializing_if = "is_qemu")]
    pub format: VmFormat,
    /// When set, the `kernel`/`rootfs`/`disk` payload is (or is produced by) the
    /// artifact this cargo build variant produced — the "build it, then boot it"
    /// chain. The host stages the variant's artifact before booting.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub from_variant: Option<String>,
    /// **Consumption edges** (`[build.vm.edges.<feature>]`) — a root feature
    /// bound to another build thing's artifact, `repo/variant : typ`,
    /// type-checked at load against the donor's declaration (design f3, the
    /// same edge every kind carries). Empty for every existing descriptor.
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub edges: std::collections::BTreeMap<String, crate::tree::EdgeDecl>,
}

fn default_mem_mb() -> u32 {
    512
}
fn default_cores() -> u32 {
    1
}
fn is_qemu(f: &VmFormat) -> bool {
    *f == VmFormat::Qemu
}

impl Default for VmBuild {
    fn default() -> Self {
        VmBuild {
            name: String::new(),
            kernel: String::new(),
            rootfs: String::new(),
            disk: String::new(),
            mem_mb: default_mem_mb(),
            cores: default_cores(),
            boot_params: Vec::new(),
            format: VmFormat::default(),
            from_variant: None,
            edges: std::collections::BTreeMap::new(),
        }
    }
}

impl VmBuild {
    /// This descriptor's artifact kind — always
    /// [`ArtifactKind::Vm`](crate::variant::ArtifactKind::Vm). The unifying label
    /// that lets one dispatcher / enumeration / provenance `kind` span vms
    /// alongside the cargo variants.
    pub fn artifact_kind(&self) -> crate::variant::ArtifactKind {
        crate::variant::ArtifactKind::Vm
    }

    /// The cargo build variant this VM chains as its payload (via
    /// [`from_variant`](Self::from_variant)) — the build that must run first so
    /// its artifact can be staged into the boot image.
    pub fn variant_deps(&self) -> Vec<&str> {
        self.from_variant.as_deref().into_iter().collect()
    }

    /// The kernel command line (`boot_params` joined with spaces).
    pub fn cmdline(&self) -> String {
        self.boot_params.join(" ")
    }

    /// Materialize the QEMU **argv** to boot this VM (headless serial console) —
    /// the analogue of [`ContainerBuild::build_args`](crate::container::ContainerBuild::build_args).
    /// Deterministic, so the same descriptor always renders the same argv (the
    /// input a content-addressed boot hashes). The host prepends the `qemu-system-*`
    /// binary and drives it.
    pub fn qemu_args(&self) -> Vec<String> {
        let mut a = vec![
            "-machine".to_string(),
            self.format.machine().to_string(),
            "-m".to_string(),
            self.mem_mb.to_string(),
            "-smp".to_string(),
            self.cores.to_string(),
            "-nographic".to_string(),
            "-no-reboot".to_string(),
        ];
        if !self.kernel.is_empty() {
            a.push("-kernel".to_string());
            a.push(self.kernel.clone());
        }
        if !self.rootfs.is_empty() {
            a.push("-initrd".to_string());
            a.push(self.rootfs.clone());
        }
        if !self.disk.is_empty() {
            a.push("-drive".to_string());
            a.push(format!("file={},format=raw", self.disk));
        }
        a.push("-serial".to_string());
        a.push("mon:stdio".to_string());
        if !self.boot_params.is_empty() {
            a.push("-append".to_string());
            a.push(self.cmdline());
        }
        a
    }

    /// A human-readable one-liner, e.g. `qemu-system-x86_64 -machine q35… -kernel …`.
    pub fn command_line(&self, qemu_bin: &str) -> String {
        format!("{qemu_bin} {}", self.qemu_args().join(" "))
    }

    /// Validate the descriptor: a name, a kernel, and a rootfs or a disk to boot.
    pub fn validate(&self) -> Result<()> {
        if self.name.trim().is_empty() {
            return Err(anyhow!("vm build: empty name"));
        }
        crate::tree::validate_edge_rows(&format!("vm '{}'", self.name), None, &self.edges, &[])?;
        if self.kernel.trim().is_empty() && self.disk.trim().is_empty() {
            return Err(anyhow!(
                "vm '{}': needs a `kernel` (with `rootfs`) or a bootable `disk`",
                self.name
            ));
        }
        if self.disk.trim().is_empty() && self.rootfs.trim().is_empty() && !self.kernel.is_empty() {
            return Err(anyhow!(
                "vm '{}': a `kernel` boot needs a `rootfs` (initramfs) or a `disk`",
                self.name
            ));
        }
        if self.mem_mb == 0 {
            return Err(anyhow!("vm '{}': mem_mb must be > 0", self.name));
        }
        if self.cores == 0 {
            return Err(anyhow!("vm '{}': cores must be > 0", self.name));
        }
        Ok(())
    }

    /// The canonical bytes a VM boot's content id is taken over: the materialized
    /// QEMU argv plus the format — a stable boot-spec fingerprint that survives
    /// serde round-trips.
    pub fn canonical_bytes(&self) -> Vec<u8> {
        let mut s = self.qemu_args().join(" ");
        s.push_str(&format!("#format:{}", self.format.as_str()));
        if let Some(v) = &self.from_variant {
            s.push_str(&format!("#from_variant:{v}"));
        }
        s.into_bytes()
    }

    /// A content-addressed id (lowercase-hex SHA-256 over [`canonical_bytes`]) —
    /// the VM-boot analogue of a cargo/image build's provenance digest.
    pub fn content_id(&self) -> String {
        nornir_hash::sha256_hex(&self.canonical_bytes())
    }
}

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

    fn appliance() -> VmBuild {
        VmBuild {
            name: "holger-appliance".to_string(),
            kernel: "/boot/vmlinuz".to_string(),
            rootfs: "artifacts/rootfs.cpio.gz".to_string(),
            mem_mb: 512,
            cores: 2,
            boot_params: vec!["console=ttyS0".to_string(), "panic=-1".to_string()],
            format: VmFormat::Qemu,
            from_variant: Some("full".to_string()),
            ..Default::default()
        }
    }

    #[test]
    fn qemu_args_carry_machine_mem_cores_kernel_and_cmdline() {
        let vm = appliance();
        let a = vm.qemu_args();
        assert!(a.windows(2).any(|w| w == ["-m", "512"]));
        assert!(a.windows(2).any(|w| w == ["-smp", "2"]));
        assert!(a.windows(2).any(|w| w == ["-kernel", "/boot/vmlinuz"]));
        assert!(a.windows(2).any(|w| w == ["-initrd", "artifacts/rootfs.cpio.gz"]));
        assert!(a.iter().any(|x| x == "-nographic"));
        assert!(a.windows(2).any(|w| w == ["-append", "console=ttyS0 panic=-1"]));
        // deterministic
        assert_eq!(vm.qemu_args(), appliance().qemu_args());
        assert_eq!(
            vm.command_line("qemu-system-x86_64"),
            format!("qemu-system-x86_64 {}", a.join(" "))
        );
    }

    #[test]
    fn variant_deps_lists_the_chained_cargo_build() {
        assert_eq!(appliance().variant_deps(), vec!["full"]);
    }

    #[test]
    fn validate_catches_missing_kernel_rootfs_and_zero_size() {
        assert!(appliance().validate().is_ok());

        let mut vm = appliance();
        vm.kernel = String::new();
        vm.disk = String::new();
        assert!(vm.validate().is_err(), "no kernel and no disk rejected");

        // kernel with neither rootfs nor disk is rejected.
        let mut vm = appliance();
        vm.rootfs = String::new();
        vm.disk = String::new();
        assert!(vm.validate().is_err(), "kernel needs a rootfs or disk");

        // a pure disk boot (no kernel) is fine.
        let disk_vm = VmBuild {
            name: "disk".into(),
            kernel: String::new(),
            disk: "guest.raw".into(),
            ..Default::default()
        };
        assert!(disk_vm.validate().is_ok());

        let mut vm = appliance();
        vm.mem_mb = 0;
        assert!(vm.validate().is_err(), "zero memory rejected");
        let mut vm = appliance();
        vm.cores = 0;
        assert!(vm.validate().is_err(), "zero cores rejected");
    }

    #[test]
    fn descriptor_and_content_id_round_trip() {
        let vm = appliance();
        let id = vm.content_id();
        assert_eq!(id.len(), 64);

        let json = serde_json::to_string_pretty(&vm).unwrap();
        let back: VmBuild = serde_json::from_str(&json).unwrap();
        assert_eq!(back, vm, "VmBuild survives a JSON round-trip");
        assert_eq!(back.content_id(), id, "content id is stable across round-trip");

        // A distinct VM has a distinct id.
        let mut other = appliance();
        other.mem_mb = 1024;
        assert_ne!(other.content_id(), id, "distinct VMs have distinct ids");
    }

    #[test]
    fn vm_format_parses_and_round_trips_all_spellings() {
        assert_eq!(VmFormat::from("qemu".to_string()), VmFormat::Qemu);
        assert_eq!(VmFormat::from("".to_string()), VmFormat::Qemu);
        assert_eq!(VmFormat::from("MicroVM".to_string()), VmFormat::MicroVm);
        assert_eq!(VmFormat::from("appliance".to_string()), VmFormat::Appliance);
        assert_eq!(VmFormat::from("uefi".to_string()), VmFormat::Appliance);
        assert_eq!(String::from(VmFormat::MicroVm), "microvm");
        assert_eq!(VmFormat::default(), VmFormat::Qemu);
        assert_eq!(VmFormat::MicroVm.machine(), "microvm,accel=kvm:tcg");
    }

    #[test]
    fn microvm_boot_from_toml() {
        let toml = r#"
name = "micro"
kernel = "/boot/vmlinuz"
rootfs = "rootfs.cpio.gz"
mem_mb = 256
cores = 1
format = "microvm"
"#;
        let vm: VmBuild = toml::from_str(toml).unwrap();
        assert_eq!(vm.format, VmFormat::MicroVm);
        assert_eq!(vm.mem_mb, 256);
        assert!(vm.qemu_args().windows(2).any(|w| w == ["-machine", "microvm,accel=kvm:tcg"]));
    }
}