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(())
}