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/examples/cloud_media.rs

//! **Turn a composed medium into the container its cloud actually imports.**
//!
//! A medium and its wrapper are two different facts, and conflating them is how
//! `gunnar` v1.3.0 came to ship three `.iso` files for three clouds of which
//! exactly one has a door for an ISO:
//!
//! ```text
//! upcloud     MediumBring::IsoCdrom                                  .iso is right
//! aws-nitro   Several("VM Import (raw/vhd/vmdk/ova) … no ISO attach") .iso cannot be imported
//! azure-gen2  ImportVhd                                              needs a fixed VHD
//! ```
//!
//! The bytes were correct in all three — the right kernel donor, the right
//! `buses`/`nic`, `medium_boot = "gpt"` on the UEFI-only row. Only the wrapper
//! was wrong, and a wrapper is not something a customer can fix.
//!
//! This example is the MANUAL door, and it is deliberately thin: it picks the
//! writer from [`presets::PRESETS`] via [`CloudMedium::for_preset`], so the
//! choice comes from the declared roster and not from whoever is typing. The
//! DECLARED door — a `[[build.installer]]` cell yielding its own import format
//! — is the real fix and is a separate change; until that lands, this exists so
//! a release can be completed without a human deciding what Azure takes.
//!
//! ```bash
//! cargo run -p nornir-build-thing --example cloud_media -- \
//!     --preset azure-gen2 --src gunnar-installer-azure.iso --out-dir ./out
//! # or let the roster pick every distinct medium for a list of presets:
//! cargo run -p nornir-build-thing --example cloud_media -- \
//!     --preset upcloud --preset aws-nitro --preset azure-gen2 --src … --out-dir …
//! ```
//!
//! Every product is READ BACK through the module's own parsers before it is
//! reported — a VHD whose footer will not parse, or whose declared size is not
//! what was asked for, is a failure here rather than an upload that Azure
//! rejects hours later. That read-back exists because the boot proof alone is
//! blind to it: a VHD that lies about its size still boots, since OVMF finds
//! the primary GPT and the ESP long before the end of the disk.

use std::path::{Path, PathBuf};
use std::process::ExitCode;

use nornir_build_thing::image_writer::{
    read_vhd_footer, write_medium, CloudMedium, MediumProduct, Qcow2Reader,
};
use nornir_build_thing::target::presets;

/// Lowercase hex, so a digest can be pasted into a `sha256sum -c` line.
fn hex(b: &[u8; 32]) -> String {
    b.iter().map(|x| format!("{x:02x}")).collect()
}

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("cloud_media: {e:#}");
            ExitCode::FAILURE
        }
    }
}

struct Args {
    presets: Vec<String>,
    media: Vec<String>,
    src: PathBuf,
    out_dir: PathBuf,
    stem: Option<String>,
}

fn parse_args() -> anyhow::Result<Args> {
    let mut a = Args {
        presets: Vec::new(),
        media: Vec::new(),
        src: PathBuf::new(),
        out_dir: PathBuf::new(),
        stem: None,
    };
    let mut it = std::env::args().skip(1);
    while let Some(f) = it.next() {
        let mut want = |name: &str| -> anyhow::Result<String> {
            it.next()
                .ok_or_else(|| anyhow::anyhow!("`{name}` takes a value"))
        };
        match f.as_str() {
            "--preset" => a.presets.push(want("--preset")?),
            // An explicit medium word, for the case where no preset names it.
            "--medium" => a.media.push(want("--medium")?),
            "--src" => a.src = PathBuf::from(want("--src")?),
            "--out-dir" => a.out_dir = PathBuf::from(want("--out-dir")?),
            "--stem" => a.stem = Some(want("--stem")?),
            "-h" | "--help" => {
                eprintln!(
                    "usage: cloud_media --src <image> --out-dir <dir> \\\n         \
                     [--preset <name>]… [--medium raw|vhd|tar.gz|qcow2]… [--stem <name>]\n\n\
                     presets: {}",
                    presets::names().join(" ")
                );
                std::process::exit(0);
            }
            other => anyhow::bail!("unknown flag `{other}` (try --help)"),
        }
    }
    if a.src.as_os_str().is_empty() {
        anyhow::bail!("--src is required");
    }
    if a.out_dir.as_os_str().is_empty() {
        anyhow::bail!("--out-dir is required");
    }
    if a.presets.is_empty() && a.media.is_empty() {
        anyhow::bail!("name at least one --preset or --medium");
    }
    Ok(a)
}

fn run() -> anyhow::Result<()> {
    let args = parse_args()?;
    if !args.src.is_file() {
        anyhow::bail!("source `{}` is not a file", args.src.display());
    }
    let stem = args.stem.clone().unwrap_or_else(|| {
        args.src
            .file_stem()
            .map(|s| s.to_string_lossy().into_owned())
            .unwrap_or_else(|| "medium".to_string())
    });

    // Resolve every request to a writer, keeping WHY each one was chosen so the
    // report can say "azure-gen2 takes ImportVhd" rather than just "vhd".
    let mut wanted: Vec<(CloudMedium, String)> = Vec::new();
    for name in &args.presets {
        let p = presets::preset(name).ok_or_else(|| {
            anyhow::anyhow!(
                "unknown preset `{name}` — the roster is: {}",
                presets::names().join(" ")
            )
        })?;
        match CloudMedium::for_preset(p) {
            Some(m) => {
                let why = format!("{} takes {}", p.name, p.medium.describe());
                if !wanted.iter().any(|(x, _)| *x == m) {
                    wanted.push((m, why));
                }
            }
            None => anyhow::bail!(
                "preset `{}` brings its medium as {} — no writer in this module produces that, \
                 and inventing one here would be a second answer to a question the roster owns",
                p.name,
                p.medium.describe()
            ),
        }
    }
    for word in &args.media {
        let m = CloudMedium::ALL
            .iter()
            .copied()
            .find(|m| m.as_str() == word)
            .ok_or_else(|| {
                anyhow::anyhow!(
                    "unknown medium `{word}` — this module writes: {}",
                    CloudMedium::ALL.iter().map(|m| m.as_str()).collect::<Vec<_>>().join(" ")
                )
            })?;
        if !wanted.iter().any(|(x, _)| *x == m) {
            wanted.push((m, "named explicitly".to_string()));
        }
    }

    std::fs::create_dir_all(&args.out_dir)?;
    println!("source  {}", args.src.display());
    println!("out-dir {}\n", args.out_dir.display());

    let mut products = Vec::new();
    for (medium, why) in &wanted {
        let dst = args
            .out_dir
            .join(format!("{stem}.{}", medium.file_extension()));
        let p = write_medium(*medium, &args.src, &dst)?;
        verify(&p)?;
        println!(
            "{:<8} {:<44} {:>13} B on disk · virtual {:>13} B · padded {:>7} B\n         \
             why: {}\n         sha256(source) {}",
            p.medium.as_str(),
            p.path.file_name().unwrap_or_default().to_string_lossy(),
            p.file_size,
            p.virtual_size,
            p.padded,
            why,
            hex(&p.content_sha256),
        );
        products.push(p);
    }

    // Every product must carry the SAME source digest — they are four wrappers
    // around one disk, and a differing one means a writer altered the payload
    // rather than containing it.
    if let Some(first) = products.first() {
        for p in &products[1..] {
            anyhow::ensure!(
                p.content_sha256 == first.content_sha256,
                "`{}` and `{}` disagree about the source digest ({} vs {}) — a wrapper changed the \
                 payload, which no wrapper may do",
                first.path.display(),
                p.path.display(),
                hex(&first.content_sha256),
                hex(&p.content_sha256)
            );
        }
        println!("\nall {} product(s) share source sha256 {}", products.len(), hex(&first.content_sha256));
    }
    Ok(())
}

/// **Read the artifact back through this module's own parser.**
///
/// The boot proof cannot do this job: a VHD that halves its `current_size`
/// still boots, because OVMF finds the primary GPT and the ESP in the first
/// tens of MiB and never reads the backup GPT at the end. So the container's
/// declared geometry is checked here, where it is readable, instead of being
/// discovered by the cloud that refuses the upload.
fn verify(p: &MediumProduct) -> anyhow::Result<()> {
    match p.medium {
        CloudMedium::Vhd => {
            let f = read_vhd_footer(&p.path)?;
            anyhow::ensure!(
                f.current_size == p.virtual_size,
                "{}: footer says {} B, the product says {} B",
                p.path.display(),
                f.current_size,
                p.virtual_size
            );
            anyhow::ensure!(
                p.virtual_size % (1024 * 1024) == 0,
                "{}: virtual size {} B is not a whole number of MiB — Azure refuses that, and it \
                 refuses it after the upload",
                p.path.display(),
                p.virtual_size
            );
        }
        CloudMedium::Qcow2 => {
            let r = Qcow2Reader::open(&p.path)?;
            anyhow::ensure!(
                r.header().size == p.virtual_size,
                "{}: qcow2 header says {} B, the product says {} B",
                p.path.display(),
                r.header().size,
                p.virtual_size
            );
        }
        // A raw image IS its disk; a tarball is checked by its member name in
        // the writer itself. Both are read back by size.
        CloudMedium::Raw | CloudMedium::GceTarGz => {
            let n = std::fs::metadata(&p.path)?.len();
            anyhow::ensure!(n == p.file_size, "{}: size moved after writing", p.path.display());
        }
    }
    let _: &Path = &p.path;
    Ok(())
}