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
//! # NoCloud cloud-init seed — draupnir's owned, pure seed builder
//!
//! The **NoCloud** datasource is how cloud-init provisions a machine at first boot
//! from a **local** source (no metadata server): it looks for a volume — a vfat
//! image or a directory — labelled `cidata` carrying a small file set
//! (`user-data`, `meta-data`, and an optional `network-config`) and applies it.
//!
//! This module is the **pure builder** that emits that seed. It is the *source of
//! truth* for the NoCloud seed across the constellation — ported from
//! `Skidbladnir/src/machine.rs`'s `KvmController::nocloud_seed_files` /
//! `build_seed_image` / `build_fat_image` so draupnir (the one boot engine) owns
//! it, and Skidbladnir/jera consume it rather than re-rolling their own.
//!
//! ## Two outputs, one file set
//!
//! - [`seed_files`] — the **pure** render of the NoCloud file set from a
//!   [`CloudInit`]. Zero dependencies, always compiled. Everything else is built
//!   on top of it, so the *contents* of a seed are identical whichever container
//!   carries them.
//! - [`write_seed_dir`] — write the file set into a plain **seed directory** (a
//!   NoCloud `seedfrom` dir / a bind-mount / a `-fw_cfg` source). Pure std, always
//!   compiled — the always-works fallback that needs no image tooling.
//! - [`build_seed_image`] — author a **vfat seed image** (volume label `cidata`)
//!   in **pure Rust** via `fatfs` — no `genisoimage`/`mkisofs` subprocess. Behind
//!   the `seed` feature (the only part that pulls a dependency); this is what the
//!   KVM backend attaches to a VM as a read-only virtio drive.
//!
//! Only the seed *image* costs a dependency; the file-set and the seed *directory*
//! are pure std, so any boot path (KVM today; a container init that reads a NoCloud
//! dir tomorrow) can attach a seed with no extra crates.

use crate::{CloudInit, Error, Result};
use std::path::{Path, PathBuf};

/// The canonical NoCloud **volume label**. cloud-init matches it
/// case-insensitively; `cidata` is the canonical spelling. It labels both the vfat
/// [`build_seed_image`] output and (conventionally) a [`write_seed_dir`] mount.
pub const NOCLOUD_LABEL: &str = "cidata";

/// The default `meta-data` supplied when a [`CloudInit`] omits one — a minimal
/// document carrying an `instance-id` and `local-hostname` so cloud-init always
/// finds the two files a NoCloud datasource requires.
pub const DEFAULT_META_DATA: &str = "instance-id: draupnir\nlocal-hostname: draupnir\n";

/// **Pure** render of the NoCloud seed file set — `[("user-data", …),
/// ("meta-data", …)]`, plus `("network-config", …)` when the [`CloudInit`] carries
/// one. When `meta-data` is omitted, [`DEFAULT_META_DATA`] is supplied so both
/// required files are always present. This is the testable core every other output
/// in this module is built from (zero deps, always compiled).
pub fn seed_files(ci: &CloudInit) -> Vec<(&'static str, String)> {
    let md = ci
        .meta_data
        .clone()
        .unwrap_or_else(|| DEFAULT_META_DATA.to_string());
    let mut files = vec![("user-data", ci.user_data.clone()), ("meta-data", md)];
    // cloud-init reads an optional `network-config` from the same NoCloud volume.
    if let Some(nc) = &ci.network_config {
        files.push(("network-config", nc.clone()));
    }
    files
}

/// Write the NoCloud [`seed_files`] into `dir` as a plain **seed directory** (the
/// pure-std, no-image-tooling output): `dir/user-data`, `dir/meta-data`, and
/// `dir/network-config` when present. `dir` is created if it does not exist.
/// Returns the directory path. Always compiled (no feature, no dependency).
pub fn write_seed_dir(dir: impl AsRef<Path>, ci: &CloudInit) -> Result<PathBuf> {
    let dir = dir.as_ref();
    std::fs::create_dir_all(dir)
        .map_err(|e| Error::Backend(format!("mkdir {}: {e}", dir.display())))?;
    for (name, contents) in seed_files(ci) {
        let path = dir.join(name);
        std::fs::write(&path, contents.as_bytes())
            .map_err(|e| Error::Backend(format!("write {}: {e}", path.display())))?;
    }
    Ok(dir.to_path_buf())
}

/// Materialize a [`CloudInit`] into a NoCloud **seed image** at `out`: a FAT12/16
/// image, volume-labelled [`NOCLOUD_LABEL`] (`cidata`), holding [`seed_files`] at
/// its root. **Pure Rust** via `fatfs` — no `genisoimage`/`mkisofs` subprocess
/// (zero-shell), the same authoring Skidbladnir uses. Returns the image path. The
/// KVM backend attaches this as a read-only virtio drive; the guest's cloud-init
/// finds it by its `cidata` vfat label at first boot. (feature `seed`)
#[cfg(feature = "seed")]
pub fn build_seed_image(out: impl AsRef<Path>, ci: &CloudInit) -> Result<PathBuf> {
    let out = out.as_ref();
    let blobs: Vec<(String, Vec<u8>)> = seed_files(ci)
        .into_iter()
        .map(|(n, c)| (n.to_string(), c.into_bytes()))
        .collect();
    build_fat_image(out, NOCLOUD_LABEL, &blobs)?;
    Ok(out.to_path_buf())
}

/// Author a **FAT12/16 image** at `out`, volume-labelled `label` (≤11 chars),
/// holding `files` (name → bytes) at the root — pure Rust via `fatfs`, into an
/// in-memory buffer written out in one shot. Sized to the payload + 1 MiB slack,
/// min 2 MiB, rounded up to 512 KiB. (feature `seed`)
#[cfg(feature = "seed")]
pub fn build_fat_image(out: &Path, label: &str, files: &[(String, Vec<u8>)]) -> Result<()> {
    use std::io::Write;
    if let Some(dir) = out.parent() {
        std::fs::create_dir_all(dir)
            .map_err(|e| Error::Backend(format!("mkdir {}: {e}", dir.display())))?;
    }
    let payload: u64 = files.iter().map(|(_, b)| b.len() as u64).sum();
    let needed = (payload + 1024 * 1024).max(2 * 1024 * 1024);
    let size = needed.div_ceil(512 * 1024) * 512 * 1024;
    let mut cursor = std::io::Cursor::new(vec![0u8; size as usize]);
    let mut lbl = [b' '; 11];
    for (i, b) in label.bytes().take(11).enumerate() {
        lbl[i] = b;
    }
    fatfs::format_volume(&mut cursor, fatfs::FormatVolumeOptions::new().volume_label(lbl))
        .map_err(|e| Error::Backend(format!("format FAT {}: {e}", out.display())))?;
    {
        let fs = fatfs::FileSystem::new(&mut cursor, fatfs::FsOptions::new())
            .map_err(|e| Error::Backend(format!("open FAT {}: {e}", out.display())))?;
        let root = fs.root_dir();
        for (name, bytes) in files {
            let mut file = root
                .create_file(name)
                .map_err(|e| Error::Backend(format!("create {name} in FAT: {e}")))?;
            file.truncate().ok();
            file.write_all(bytes)
                .map_err(|e| Error::Backend(format!("write {name} in FAT: {e}")))?;
            file.flush().ok();
        }
    }
    std::fs::write(out, cursor.into_inner())
        .map_err(|e| Error::Backend(format!("write {}: {e}", out.display())))?;
    Ok(())
}

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

    /// A unique scratch path under the system temp dir (no `tempfile` dev-dep,
    /// matching the repo's existing convention).
    fn scratch(tag: &str) -> PathBuf {
        std::env::temp_dir().join(format!("draupnir-seed-{tag}-{}", std::process::id()))
    }

    /// RED-WHEN-BROKEN: a known `meta-data` + `user-data` input renders the EXACT
    /// expected seed contents — the instance-id/hostname in `meta-data` and the
    /// verbatim `user-data` YAML. If the render drifts, this fails on the byte.
    #[test]
    fn known_input_renders_exact_seed_contents() {
        let user_data = "#cloud-config\nhostname: web-01\nruncmd:\n  - [systemctl, start, holger]\n";
        let ci = CloudInit {
            user_data: user_data.into(),
            meta_data: Some("instance-id: iid-web-01\nlocal-hostname: web-01\n".into()),
            network_config: None,
        };
        let files = seed_files(&ci);

        assert_eq!(files.len(), 2, "just user-data + meta-data when no network-config");
        // user-data is the verbatim YAML.
        assert_eq!(files[0].0, "user-data");
        assert_eq!(files[0].1, user_data);
        // meta-data carries the exact instance-id + hostname.
        assert_eq!(files[1].0, "meta-data");
        assert_eq!(files[1].1, "instance-id: iid-web-01\nlocal-hostname: web-01\n");
    }

    /// An omitted `meta-data` is filled with [`DEFAULT_META_DATA`] carrying both an
    /// `instance-id` and a `local-hostname` (cloud-init needs both files).
    #[test]
    fn omitted_meta_data_gets_the_default_instance_id_and_hostname() {
        let ci = CloudInit::user_data("#cloud-config\n");
        let files = seed_files(&ci);
        assert_eq!(files[1].0, "meta-data");
        assert_eq!(files[1].1, DEFAULT_META_DATA);
        assert!(files[1].1.contains("instance-id:"));
        assert!(files[1].1.contains("local-hostname:"));
    }

    /// A `network-config` is emitted as the third file only when present.
    #[test]
    fn network_config_is_the_optional_third_file() {
        let plain = CloudInit::user_data("#cloud-config\n");
        assert!(!seed_files(&plain).iter().any(|(n, _)| *n == "network-config"));

        let net = CloudInit::user_data("#cloud-config\n")
            .with_network_config("version: 2\nethernets:\n  eth0:\n    dhcp4: true\n");
        let files = seed_files(&net);
        assert_eq!(files.len(), 3);
        let nc = files
            .iter()
            .find(|(n, _)| *n == "network-config")
            .expect("network-config present");
        assert_eq!(nc.1, "version: 2\nethernets:\n  eth0:\n    dhcp4: true\n");
    }

    /// The **seed directory** output writes each file with its exact contents on
    /// disk (the pure-std, no-image-tooling path).
    #[test]
    fn write_seed_dir_writes_each_file_verbatim() {
        let dir = scratch("dir");
        let _ = std::fs::remove_dir_all(&dir);
        let ci = CloudInit {
            user_data: "#cloud-config\npackages: [curl]\n".into(),
            meta_data: Some("instance-id: iid-42\nlocal-hostname: node-42\n".into()),
            network_config: Some("version: 2\n".into()),
        };
        let out = write_seed_dir(&dir, &ci).unwrap();
        assert_eq!(out, dir);

        let read = |name: &str| std::fs::read_to_string(dir.join(name)).unwrap();
        assert_eq!(read("user-data"), "#cloud-config\npackages: [curl]\n");
        assert_eq!(read("meta-data"), "instance-id: iid-42\nlocal-hostname: node-42\n");
        assert_eq!(read("network-config"), "version: 2\n");

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// FUNCTIONAL: author the vfat seed image, read it back through `fatfs`, and
    /// confirm the volume label is `cidata` and every file's contents survive the
    /// round-trip (so a guest's cloud-init will find and read them). (feature `seed`)
    #[cfg(feature = "seed")]
    #[test]
    fn build_seed_image_roundtrips_label_and_contents() {
        use std::io::Read;
        let out = scratch("img").with_extension("img");
        let _ = std::fs::remove_file(&out);
        let ci = CloudInit {
            user_data: "#cloud-config\nruncmd:\n  - [echo, hi]\n".into(),
            meta_data: Some("instance-id: iid-img\nlocal-hostname: img-host\n".into()),
            network_config: Some("version: 2\nethernets:\n  eth0:\n    dhcp4: true\n".into()),
        };
        let path = 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(), NOCLOUD_LABEL);

        let read = |name: &str| {
            let mut buf = String::new();
            fs.root_dir().open_file(name).unwrap().read_to_string(&mut buf).unwrap();
            buf
        };
        assert_eq!(read("user-data"), "#cloud-config\nruncmd:\n  - [echo, hi]\n");
        assert_eq!(read("meta-data"), "instance-id: iid-img\nlocal-hostname: img-host\n");
        assert_eq!(read("network-config"), "version: 2\nethernets:\n  eth0:\n    dhcp4: true\n");

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