crates/nornir-build-thing/src/installer.rs
//! **Disk-installer build target** — the typed descriptor a ⚒ Build Thing cell
//! renders to master the korp **ISO disk-installer** and to boot it in KVM against a
//! blank disk (the install proof).
//!
//! Where an [`IsoBuild`](crate::iso::IsoBuild) masters a *live* bootable ISO, an
//! [`InstallerBuild`] describes the ISO that **installs the appliance onto a disk**:
//! it boots as the unattended `korp-installer` PID-1, lays a GPT + ESP, streams the
//! app `.znippy` onto the system partition, and reboots. This descriptor is *data*
//! (pure serde, dependency-light) — it materializes:
//!
//! * the [`IsoBuild`] the existing pure-Rust masterer produces for ▶ Build
//! ([`InstallerBuild::installer_iso`]), so a repo can declare the installer as a
//! normal `[[build.iso]]` cell the dwarves ISO cell already drives; and
//! * the **KVM boot seam** — the extra QEMU args that attach a blank target disk
//! (and optionally a GPU) so the auto-install is testable end-to-end before any
//! hardware ([`InstallerBuild::kvm_disk_args`]), plus the exact serial success
//! marker the boot proof gates on ([`InstallerBuild::success_marker`]).
//!
//! It runs nothing itself; the host (the KVM e2e / a future dwarves installer view)
//! drives QEMU. It is content-addressable ([`InstallerBuild::content_id`]) like the
//! other build kinds.
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use crate::iso::IsoBuild;
#[cfg(test)]
use crate::iso::IsoFormat;
use crate::variant::ArtifactKind;
/// Where the installer reads the app `.znippy` payload from — mirrors
/// `korp_installer::payload::PayloadSource` as build-thing data.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum PayloadKind {
/// **Default** — the `.znippy` is baked into the installer ISO (airgap/offline).
EmbeddedIso,
/// Alternate — pulled from S3/MinIO at install time.
S3 {
/// The S3 endpoint URL.
endpoint: String,
/// The bucket.
bucket: String,
/// The object key.
key: String,
},
}
impl Default for PayloadKind {
fn default() -> Self {
PayloadKind::EmbeddedIso
}
}
impl PayloadKind {
/// A short label for a surface / serial line (`embedded` / `s3://bucket/key`).
pub fn label(&self) -> String {
match self {
PayloadKind::EmbeddedIso => "embedded".to_string(),
PayloadKind::S3 { bucket, key, .. } => format!("s3://{bucket}/{key}"),
}
}
}
/// The **FAT-appliance container runtime** an installer lays down alongside korp —
/// the one selectable `ContainerBackend` a FAT install picks. A LEAN install carries
/// **no** backend (`InstallerBuild::container_backend == None`), pulls in ZERO
/// container 3pp, and boots korp as the single PID-1. Each FAT variant selects exactly
/// ONE of these (never several in one instance) — a customer who mandates podman gets
/// the podman variant, an os-less-minimal box gets youki or microVM.
///
/// This is *build-thing data* (which backend the mastered ISO installs); the actual
/// runtime linkage is korp-installer's cargo-feature-gated `fat-containers` concern.
/// A truly OS-less box (kernel + one PID-1, no daemon) only fits the DAEMONLESS
/// backends — [`Youki`](Self::Youki) (pure-Rust libcontainer) or the container-runtime-free
/// [`MicroVm`](Self::MicroVm); [`Podman`](Self::Podman)/[`Containerd`](Self::Containerd)
/// re-add a userland/service and target dev boxes / shops that mandate them.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContainerBackend {
/// Daemonless pure-Rust libcontainer (rootful) — THE os-less default: no daemon,
/// minimal Rust-native 3pp. FAT sidecars (FalkorDB/MinIO/Spark) launched in-process.
Youki,
/// bollard → podman socket. Daemonless, lean-on-ecosystem; PROVEN today (+PONG on
/// oden). A dev-box / not-strictly-os-less FAT path (re-adds a podman userland).
Podman,
/// containerd/CRI + runc/crun (or Docker) — the industry/k8s standard, for shops
/// that mandate it. Re-adds a daemon, so not os-less-minimal.
Containerd,
/// tunnr QEMU **microVM per sidecar** — NO OCI runtime at all, each sidecar its own
/// KVM microVM. All-ours, strongest isolation (defense/airgap); needs QEMU present.
MicroVm,
}
impl ContainerBackend {
/// The stable lower-kebab label used in a cell name / serial line / content id
/// (`youki`, `podman`, `containerd`, `microvm`).
pub fn label(self) -> &'static str {
match self {
ContainerBackend::Youki => "youki",
ContainerBackend::Podman => "podman",
ContainerBackend::Containerd => "containerd",
ContainerBackend::MicroVm => "microvm",
}
}
/// Parse a backend from its [`label`](Self::label) (case-insensitive); `None` for
/// anything else (including the LEAN "no backend" sentinel).
pub fn from_label(s: &str) -> Option<ContainerBackend> {
match s.trim().to_ascii_lowercase().as_str() {
"youki" => Some(ContainerBackend::Youki),
"podman" => Some(ContainerBackend::Podman),
"containerd" => Some(ContainerBackend::Containerd),
"microvm" | "micro-vm" | "microvm-sidecars" => Some(ContainerBackend::MicroVm),
_ => None,
}
}
/// Whether this backend is **daemonless** — the property a truly OS-less appliance
/// (kernel + one PID-1, no service) requires. `Youki` + `MicroVm` are; `Podman`
/// (socket) + `Containerd` (daemon) re-add a userland/service.
pub fn is_daemonless(self) -> bool {
matches!(self, ContainerBackend::Youki | ContainerBackend::MicroVm)
}
}
/// **The kernel this installer's medium boots, named by the checkout that
/// DECLARES it** — a cross-repo reference onto a `[[build.kernel]]` row.
///
/// Before this field existed the composed installer's bzImage came from an
/// environment variable or the build box's own `/boot/vmlinuz` — "a kernel
/// nobody in this repository chose", with no pin, no receipt and no way to ask
/// what is in it. The donor closes that seam as DATA: the cell names WHICH
/// checkout's `[[build.kernel]]` its medium boots, and both doors (the ⚒ pane's
/// BYGG and `dwarves buildthing … --variant <installer>`) resolve the same
/// declaration to the same built artifact, verified against its
/// [`KernelPin`](crate::kernel::KernelPin).
///
/// The reference is deliberately a NAME PAIR, not a path: the donor checkout is
/// resolved beside the consuming repo by the caller (the constellation layout
/// rule every cross-repo seam here uses), and
/// [`crate::kernel::resolve_built_kernel`] is the one reader — it refuses BY
/// NAME when the donor declares no such kernel, when the kernel is declared but
/// not built, and when the built artifact's pin disagrees with the chosen
/// preset/veto. A donor is a CONSUMER of the built kernel, never a second
/// builder: the kernel is built where it is declared
/// (`dwarves buildthing <repo> --variant <kernel>`), and its version pin stays a
/// product decision of the DONOR repo — the consumer's declaration says only
/// "that one".
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KernelDonor {
/// The donor CHECKOUT's name — its directory beside the consuming repo.
pub repo: String,
/// The `[[build.kernel]]` name inside that checkout.
pub kernel: String,
}
impl KernelDonor {
/// Both halves named, or the reference resolves nothing. Refused HERE, at
/// declaration time, because an empty half would otherwise surface as a
/// "no such checkout" error at compose time — true words about the wrong
/// defect. `who` prefixes the refusal (`installer 'x'` / `iso 'x'`).
pub fn validate(&self, who: &str) -> anyhow::Result<()> {
if self.repo.trim().is_empty() {
anyhow::bail!(
"{who}: kernel_donor.repo is empty — the donor names WHICH checkout declares \
the kernel this medium boots"
);
}
if self.kernel.trim().is_empty() {
anyhow::bail!(
"{who}: kernel_donor.kernel is empty — the donor names WHICH [[build.kernel]] \
in '{}' this medium boots",
self.repo
);
}
Ok(())
}
/// The edge this row spells: `kernel ↔ repo/kernel : kernel`.
pub fn edge(&self) -> crate::tree::Edge {
crate::tree::Edge::new(&self.repo, &self.kernel, crate::variant::ArtifactKind::Kernel)
}
}
/// **How a firmware can start an installer medium off the device it lands on.**
///
/// An `.iso` is an El Torito medium: the boot information lives in a catalog a
/// firmware finds by reading the volume as optical media. Attach it as a cdrom
/// and every firmware boots it. Hand the identical bytes to a machine as a
/// **disk** — which is what every cloud that imports an image does, and what a
/// USB stick is — and a legacy BIOS reads LBA 0, finds no `55 AA` boot sector,
/// and parks. MEASURED 2026-08-30 on gunnar's 38 680 576 B medium: `LBA
/// 0[510..512] = 00 00`, the whole first sector zero, and `--target
/// ovh-public-cloud` left **zero bytes of serial** for 420 s.
///
/// The medium's own shape is the fact that decides this, so it is declared on
/// the medium, not on the machine: [`crate::target::Target`] says what the
/// MACHINE has, and every field of it is a kernel-config consequence, whereas
/// this one changes 512 bytes of an image and no kernel symbol at all.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum MediumBoot {
/// **El Torito only** — the medium boots as a cdrom, and its first sector
/// is all zeros. What every medium did before this word existed.
#[default]
ElTorito,
/// **El Torito AND a hybrid boot sector**: LBA 0 carries real boot code
/// that chainloads a disk-flavoured copy of the same `korp-boot-bios`
/// stage, so ONE artifact boots from a cdrom, from a USB stick and from a
/// cloud that imports it as a disk image. Both copies live in the ISO's
/// reserved 32 KiB system area
/// ([`SYSTEM_AREA_BYTES`](crate::iso_writer::SYSTEM_AREA_BYTES)), so the
/// medium does not grow by a byte.
///
/// Requires a real legacy-BIOS stage — `target.firmware` must be `seabios`
/// or `both`, else the composer refuses by name: a `55 AA` that chainloads
/// nothing is worse than none, because firmware stops looking once it finds
/// one.
Hybrid,
/// **El Torito AND a GPT ESP partition, no boot code at LBA 0**: the ESP is
/// reachable through a GPT in the same reserved system area
/// ([`GptHybrid::Always`](crate::iso_writer::GptHybrid::Always)), so UEFI
/// firmware boots the identical artifact off a cdrom and off a disk it was
/// imported as — the word for a UEFI-only imported-disk cloud (Azure Gen2,
/// 2026-09-05), where `hybrid` cannot be declared (it needs a legacy stage
/// the machine has no firmware for) and `el-torito` alone parks on a
/// zeroed LBA 0. A BIOS finds nothing to run and moves on, which is the
/// truthful outcome on such a medium.
Gpt,
}
impl MediumBoot {
pub fn as_str(self) -> &'static str {
match self {
MediumBoot::ElTorito => "el-torito",
MediumBoot::Hybrid => "hybrid",
MediumBoot::Gpt => "gpt",
}
}
/// Whether the medium carries a boot sector a BIOS can start off a disk.
pub fn is_hybrid(self) -> bool {
matches!(self, MediumBoot::Hybrid)
}
/// Whether the medium carries a GPT ESP partition whatever the ESP's size,
/// so UEFI boots it off a disk.
pub fn lays_gpt(self) -> bool {
matches!(self, MediumBoot::Gpt)
}
/// An undeclared word is the old behaviour, so the emission stays silent.
pub fn is_default(&self) -> bool {
*self == MediumBoot::default()
}
}
/// One concrete **disk-installer build point**.
///
/// Serializes as a `[[build.installer]]` table (or is constructed by the dwarves
/// forge for its ISO/installer cell):
/// ```toml
/// [[build.installer]]
/// name = "korp-installer"
/// variant = "korp-appliance-lean"
/// installer_variant = "korp-installer-pid1" # the cargo build producing the PID-1
/// volume_id = "KORP_INSTALL"
/// target_disk_gib = 64
/// gpu = false
/// boot_params = ["console=ttyS0"]
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstallerBuild {
/// Stable handle (`nornir build --installer <name>` / the KVM boot row).
pub name: String,
/// The appliance variant this installer lays down (e.g. `korp-appliance-lean`).
#[serde(default = "default_variant")]
pub variant: String,
/// The cargo build variant that produces the `korp-installer` PID-1 binary
/// riding the ISO as its boot payload — mastered via the ISO's `from_variant`.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub installer_variant: String,
/// The ISO-9660 volume id.
#[serde(default = "default_volume_id")]
pub volume_id: String,
/// Where the app `.znippy` payload comes from.
#[serde(default)]
pub payload: PayloadKind,
/// **Files packed into the app `.znippy` beside the app** (lane HOLGER-MEDIA,
/// 2026-10-23): entry name inside the archive → the file's path relative to the
/// repository root (`"sandbox/crun" = ".sandbox-out/crun"`). The forge packs them
/// into the SAME archive korp-start verifies at every boot (one root pin, one
/// signature), so the installer, the keep medium and the live app all carry them;
/// a source that is missing refuses the compose by name. The app reads them out
/// of its running slot's archive. What a product stages here is its own verb's
/// (Holger: `cargo xtask scan-worker stage`); a name is one or more plain path
/// segments and never the app's own entry.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub payload_entries: std::collections::BTreeMap<String, String>,
/// The FAT container backend this installer lays down, or `None` for a **LEAN**
/// install (korp PID-1 only, no containers, zero container 3pp). A FAT variant
/// selects exactly one [`ContainerBackend`]. This is the per-install selector the
/// customer picks — the whole matrix is buildable, one backend per instance.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container_backend: Option<ContainerBackend>,
/// Size (GiB) of the blank target disk attached in the KVM install proof.
#[serde(default = "default_disk_gib")]
pub target_disk_gib: u32,
/// Attach a virtio-gpu in the KVM test (mirrors an optional HW GPU).
#[serde(default)]
pub gpu: bool,
/// Kernel/boot command-line parameters mastered onto the ISO.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub boot_params: Vec<String>,
/// **The app's argv tail** — the metal twin of [`ContainerBuild::cmd`]
/// (`crate::container::ContainerBuild::cmd`), so "how the product starts"
/// is ONE word across both shapes. Rendered as a `korp.args=` boot token
/// (comma-joined) by [`effective_boot_params`](Self::effective_boot_params);
/// the installer carries it to the installed disk's cmdline and korp-start
/// decodes it into the exec argv after argv[0]. MEASURED need (2026-08-14,
/// the fleet first-boot proof): without it the metal shape execs the bare
/// entry name — holger printed its help and PID 1 died — while the
/// container cell declared its cmd and served. Comma-joined without
/// escapes, so [`validate`](Self::validate) refuses an element carrying
/// whitespace, a comma or a quote BY NAME at declaration time (the encode
/// is total or the cell does not build).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cmd: Vec<String>,
/// **The app's environment** — the metal twin of `ContainerBuild::env`
/// (the second half of the shape-parity story `cmd` started). Rendered as
/// `korp.env.<KEY>=<value>` boot tokens: dot-prefixed so the kernel never
/// claims them for init's own env, `korp.`-prefixed so the installer's
/// carry delivers them to the INSTALLED cmdline, decoded back into the
/// exec's envp by korp-start. MEASURED need (2026-08-14): holger's
/// appliance gate is `HOLGER_APPLIANCE=1` — an ENV var with no channel to
/// installed metal, so the served appliance ran with its tunnel dark.
/// Same encoding law as `cmd` (no whitespace/comma/quote, refused BY
/// NAME), plus: a KEY that smells like a secret is refused outright — the
/// cmdline is world-readable on the ESP forever.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub env: std::collections::BTreeMap<String, String>,
/// **The mark this installer's boot screen wears** — a path, relative to the
/// declaring repo's root, to the image baked into the first stage's splash.
///
/// The exact twin of [`IsoBuild::boot_logo`](crate::iso::IsoBuild::boot_logo),
/// on the SAME `KORP_BOOT_LOGO` wire into [`korp_installer::splash`], and it
/// exists because the installer cell had no way to say it. MEASURED 2026-08-16:
/// gunnar, korp and holger all shipped the identical nordisk raven because
/// `[[build.installer]]` carried no such field at all — not a rendering
/// accident, a hole in the vocabulary.
///
/// Omitted ⇒ nothing is exported and korp's own `assets/korp-logo.webp` is
/// baked, which is the correct default and stays the default. Formats: the
/// codec list `korp-installer` compiles `image` with (webp, png, jpeg, tiff,
/// bmp); the picture is fitted into 520×360 and never upscaled.
#[serde(rename = "boot-logo", alias = "boot_logo", default, skip_serializing_if = "Option::is_none")]
pub boot_logo: Option<String>,
/// **The declared kernel this medium boots** (`[build.installer.kernel_donor]`)
/// — see [`KernelDonor`]. `None` keeps the pre-donor behaviour: the composer's
/// environment knob or the host's own kernel, which is the honest spelling of
/// "this cell has not chosen one".
///
/// Cell-level and `#[serde(default)]`, so published 0.1.x readers (whose
/// `InstallerBuild` lacks the field) still load the recipe — they ignore the
/// table benignly. The CONSUMER of the field is the composing forge (dwarves),
/// which path-deps this crate; a mirror crate on an older published version
/// must guard the declaration by reading the raw toml (gunnar's
/// `toml_parity` does exactly that) until its pin advances.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kernel_donor: Option<KernelDonor>,
/// **The machine this medium is built FOR** (`[build.installer.target]`):
/// firmware (`uefi | seabios | both`), consoles (`serial`, `vga`, in
/// `console=` order), buses, nic — see [`crate::target`]. Default = both
/// firmwares, `tty0` then `ttyS0`, IDE cdrom + virtio-blk, virtio-net:
/// exactly what the medium did before the group existed, so an undeclared
/// row changes nothing and emits nothing. Published 0.1.x readers drop the
/// table benignly; the CONSUMER is the composer (`korp-installer::isocompose`),
/// which lays the real BIOS stage only when `firmware.lays_bios()` and the
/// EFI section only when `firmware.lays_uefi()`.
#[serde(default, skip_serializing_if = "crate::target::Target::is_default")]
pub target: crate::target::Target,
/// **What `/data` is laid with** (`[build.installer.data]`, `fs = "fat" |
/// "xfs"`) — see [`crate::data`]. Default = FAT: exactly what every
/// medium did before the group existed, so an undeclared row changes
/// nothing and emits nothing. Consumers: the kernel edge
/// ([`crate::requires::DataFacts`]), the installer PID 1 through
/// [`DATA_FS_BOOT_PARAM`], and the composer's content id.
#[serde(default, skip_serializing_if = "crate::data::Data::is_default")]
pub data: crate::data::Data,
/// **How a firmware can START this medium off the device it lands on**
/// (`medium_boot = "el-torito" | "hybrid"`) — see [`MediumBoot`].
#[serde(default, skip_serializing_if = "MediumBoot::is_default")]
pub medium_boot: MediumBoot,
/// **Every other consumption edge** (`[build.installer.edges.<feature>]`)
/// — a root feature bound to `repo/variant : typ`, type-checked at
/// declaration time. See [`EdgeDecl`](crate::tree::EdgeDecl).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub edges: std::collections::BTreeMap<String, crate::tree::EdgeDecl>,
/// **Declared PRESETS** (`[build.installer.presets.<name>]`, name → path →
/// value): named bundles of choices over this variant's whole settings
/// tree — `airgap = { wireguard = "off", dhcp = "off", "kernel.preset" =
/// "nuked" }` — projected as the root `preset` val and chosen with
/// `--opt preset=<name>`. Every path is proven against the tree at
/// declaration time ([`SettingsTree::validate_presets`](crate::tree::SettingsTree::validate_presets)).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub presets: crate::tree::Presets,
/// The preset this medium was built with — bundle AND expansion — set by
/// the door that expanded it; serialized into the attested descriptor.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub applied_preset: Option<crate::tree::AppliedPreset>,
/// **Declared build parameters** (`[[build.installer.params]]`) — the
/// per-site inputs (hub public key, endpoint, site name, a staged
/// hub.conf) and where each lands. See [`crate::params`].
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub params: Vec<crate::params::ParamSpec>,
/// **Declared fixtures** (`[build.installer.fixtures]`, parameter name →
/// kind): which parameters the forge may fill from a fixture it mints
/// itself (`hub_conf = "wg-hub"`), supplied as `--param name=@fixture`.
/// Cell-level rather than a `ParamSpec` field so published 0.1.x readers
/// (which `deny_unknown_fields` on the param row) still load the recipe;
/// see [`crate::params`] ("Declared fixtures"). Validated by
/// [`validate`](Self::validate).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub fixtures: std::collections::BTreeMap<String, crate::params::FixtureKind>,
/// **Declared build OPTIONS** (`[[build.installer.options]]`) — the
/// opt-out axis: a checkbox whose OFF state leaves the code out of the
/// packed app binary entirely. Each row names the cargo feature it
/// decides, the parameters that come and go with it, the cmdline selector
/// it bakes and the symbol families that must be ABSENT when it is off.
/// See [`crate::options`].
///
/// Cell-level, ignored benignly by published 0.1.x readers — including its
/// own contents, so an option may use vocabulary those readers do not have
/// (`param_kinds = { gateway = "ipv4" }`) without making the recipe
/// unloadable for them. Validated by [`validate`](Self::validate).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub options: Vec<crate::options::OptionSpec>,
/// **What this medium needs of its sub build-things regardless of any
/// option** (`[[build.installer.requires]]`, each row `edge = "kernel",
/// surface = "kconfig", symbol/value/reason`) — the row-level half of the
/// closure ([`crate::requires`]); the appliance-boot contract is implied by
/// `kernel_donor` and need not be repeated here. Benign to published 0.1.x
/// readers.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requires: Vec<crate::requires::EdgeRequirement>,
/// The applied `(name, value)` record — set by
/// [`apply_cli_params`](InstallerBuild::apply_cli_params), forwarded onto
/// the rendered ISO, serialized into the attested descriptor.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub applied_params: Vec<crate::params::AppliedParam>,
/// The resolved OPTION states — which toggles produced this artifact, and
/// whether the operator said so or the recipe's default stood. Set by
/// [`apply_cli_options`](Self::apply_cli_options); serialized into the
/// attested descriptor exactly like `applied_params`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub applied_options: Vec<crate::options::AppliedOption>,
}
/// **The cmdline key of the firmware fact** — `korp.target.firmware=<uefi|seabios|both>`,
/// the way `target.firmware` reaches PID 1. Baked by
/// [`InstallerBuild::effective_boot_params`] beside the other `korp.*`
/// selectors ONLY when the `target` group is declared (an undeclared medium
/// stays byte-identical). `korp-installer::cmdline::TARGET_FIRMWARE_KEY` is the
/// same spelling on the reading side: the installer PID 1 lays the legacy-BIOS
/// path (MBR boot code + `korpbios.bin`) on the target disk only when the
/// firmware `lays_bios()`, and `carry_site_tokens` forwards the token to the
/// installed disk's own cmdline.
pub const FIRMWARE_BOOT_PARAM: &str = "korp.target.firmware";
/// The token [`FIRMWARE_BOOT_PARAM`] carries for `fw`.
pub fn firmware_boot_token(fw: crate::target::Firmware) -> String {
format!("{FIRMWARE_BOOT_PARAM}={}", fw.as_str())
}
/// **The cmdline key of the data filesystem** — `korp.data.fs=<fat|xfs>`, the
/// way `data.fs` reaches the installer PID 1. Baked by
/// [`InstallerBuild::effective_boot_params`] beside `korp.target.firmware=`
/// ONLY when the `data` group is declared AND not `fat`: an absent token
/// already means FAT (every pre-group medium), so one fact has one spelling.
/// `korp-installer::cmdline::DATA_FS_KEY` reads it; `install` lays P3 with
/// `korp-mkfs-xfs` for `xfs` and `fatfs` otherwise, and `carry_site_tokens`
/// forwards the token to the installed disk's own cmdline.
pub const DATA_FS_BOOT_PARAM: &str = "korp.data.fs";
/// The token [`DATA_FS_BOOT_PARAM`] carries for `fs`.
pub fn data_fs_boot_token(fs: crate::data::DataFs) -> String {
format!("{DATA_FS_BOOT_PARAM}={}", fs.as_str())
}
/// **The cmdline key of the store's writer arm** — `korp.data.io=<safe|uring>`,
/// the way `data.io` reaches the APP's PID 1 (it is a site token: the
/// installer carries it onto the installed disk's cmdline, the app reads it
/// off `/proc/cmdline`). Baked by [`InstallerBuild::effective_boot_params`]
/// beside `korp.data.fs=` ONLY when the group is declared AND not `safe`: an
/// absent token already means `safe` (every pre-`io` medium), one fact, one
/// spelling. `korp-installer::cmdline::DATA_IO_KEY` is the same spelling on
/// the reading side.
pub const DATA_IO_BOOT_PARAM: &str = "korp.data.io";
/// The token [`DATA_IO_BOOT_PARAM`] carries for `io`.
pub fn data_io_boot_token(io: crate::data::DataIo) -> String {
format!("{DATA_IO_BOOT_PARAM}={}", io.as_str())
}
/// **The cmdline key of the reinstall policy** — `korp.reinstall=wipe|keep`,
/// how `--param reinstall=wipe` reaches the installer PID 1. A product's
/// recipe declares it as an OPTIONAL `Text` param with this `boot_param`
/// (gunnar: `reinstall`), the generic param plumbing lands it on the medium's
/// cmdline, and `korp-installer::cmdline::REINSTALL_KEY` is the same spelling
/// on the reading side: absent = keep = a korp-installed target is UPGRADED
/// (its `/data` kept, only the ESP + rootfs rewritten); `wipe` lays it fresh.
/// Never baked by `effective_boot_params` on its own — an undeclared medium
/// keeps `/data` by construction. See
/// korp-installer/.nornir/data-survives-upgrade.md.
pub const REINSTALL_BOOT_PARAM: &str = "korp.reinstall";
/// The one value of [`REINSTALL_BOOT_PARAM`] that wipes.
pub const REINSTALL_WIPE: &str = "wipe";
/// The `[[build.installer.params]]` row a product declares to expose the
/// policy — the ONE spelling, so gunnar's recipe and any other product's
/// carry the same name, label and key.
pub fn reinstall_param_spec() -> crate::params::ParamSpec {
crate::params::ParamSpec {
name: "reinstall".to_string(),
label: "Reinstall policy for a disk that already carries data: keep (upgrade, /data survives) | wipe (lay it fresh)".to_string(),
kind: crate::params::ParamKind::Text,
required: false,
boot_param: REINSTALL_BOOT_PARAM.to_string(),
staged_file: String::new(),
lands: crate::params::Landing::Data,
}
}
fn default_variant() -> String {
"korp-appliance-lean".to_string()
}
fn default_volume_id() -> String {
"KORP_INSTALL".to_string()
}
fn default_disk_gib() -> u32 {
64
}
impl Default for InstallerBuild {
fn default() -> Self {
InstallerBuild {
name: String::new(),
variant: default_variant(),
installer_variant: String::new(),
volume_id: default_volume_id(),
payload: PayloadKind::default(),
payload_entries: std::collections::BTreeMap::new(),
container_backend: None,
target_disk_gib: default_disk_gib(),
gpu: false,
boot_params: vec!["console=ttyS0".to_string()],
cmd: Vec::new(),
env: std::collections::BTreeMap::new(),
boot_logo: None,
kernel_donor: None,
target: crate::target::Target::default(),
data: crate::data::Data::default(),
medium_boot: MediumBoot::default(),
edges: std::collections::BTreeMap::new(),
presets: crate::tree::Presets::new(),
applied_preset: None,
params: Vec::new(),
fixtures: std::collections::BTreeMap::new(),
options: Vec::new(),
requires: Vec::new(),
applied_params: Vec::new(),
applied_options: Vec::new(),
}
}
}
impl InstallerBuild {
/// This descriptor's artifact kind — an [`ArtifactKind::Iso`] (a bootable ISO),
/// so its launch axis is a serial-console boot, uniform with the other bootable
/// kinds. The installer *semantics* (attach a blank disk, gate on INSTALL-OK)
/// ride the extra seam methods below.
pub fn artifact_kind(&self) -> ArtifactKind {
ArtifactKind::Installer
}
/// The cargo build variant that must be built first (the PID-1 payload).
pub fn variant_deps(&self) -> Vec<&str> {
let mut v = Vec::new();
if !self.installer_variant.is_empty() {
v.push(self.installer_variant.as_str());
}
v
}
/// Materialize the [`IsoBuild`] the pure-Rust masterer produces for ▶ Build —
/// a UEFI installer ISO staging `iso-root` (which carries the `efiboot.img` ESP
/// + the embedded app `.znippy`) and chaining the PID-1 via `from_variant`.
pub fn installer_iso(&self) -> IsoBuild {
IsoBuild {
name: self.name.clone(),
staging: "iso-root".to_string(),
volume_id: self.volume_id.clone(),
boot_params: self.effective_boot_params(),
// The declared firmware decides the El-Torito shape: `uefi`/`both`
// → the UEFI format (the BIOS default entry is laid BESIDE it by the
// composer, or is the refusal stub); `seabios` → BIOS only.
format: self.target.effective().firmware.iso_format(),
from_variant: (!self.installer_variant.is_empty()).then(|| self.installer_variant.clone()),
// The mark travels with the rendered ISO — without this line the field
// would be declarable and silently dropped at `..Default::default()`,
// which is worse than not having it.
boot_logo: self.boot_logo.clone(),
// The parameter DECLARATION and any applied values travel with the
// rendered ISO: the mastering step lands staged-file params on the
// staging tree, and the attested descriptor carries the record.
params: self.params.clone(),
fixtures: self.fixtures.clone(),
// The OPTION declaration and the resolved states travel with the
// rendered ISO for the same reason the parameters do: without
// these two lines the field would be declarable and silently
// dropped at `..Default::default()`, which is worse than not
// having it (the test below watches exactly that).
options: self.options.clone(),
applied_options: self.applied_options.clone(),
applied_params: self.applied_params.clone(),
..Default::default()
}
}
/// **Apply CLI-supplied parameter values** — the installer-cell twin of
/// [`IsoBuild::apply_cli_params`]: landed `boot_params` tokens append to
/// this cell (and ride [`installer_iso`](InstallerBuild::installer_iso)
/// via `effective_boot_params`), the record is set, staged-file contents
/// ride the rendered ISO's `staged_param_files`. A `@fixture` value is
/// refused here (no source can mint one) — see
/// [`apply_cli_params_with`](Self::apply_cli_params_with).
pub fn apply_cli_params(
&mut self,
supplied: &std::collections::BTreeMap<String, String>,
) -> Result<()> {
self.apply_cli_params_with(supplied, &crate::params::NoFixtures)
}
/// [`apply_cli_params`](Self::apply_cli_params) with the source that mints
/// this cell's DECLARED [`fixtures`](Self::fixtures) for a `@fixture`
/// value — the composition's (`InstallerComposer::fixtures`).
pub fn apply_cli_params_with(
&mut self,
supplied: &std::collections::BTreeMap<String, String>,
source: &dyn crate::params::FixtureSource,
) -> Result<()> {
self.apply_cli_selection_with(&std::collections::BTreeMap::new(), supplied, source)
}
/// **Apply the whole operator selection — the OPTION toggles and the
/// parameter values — in the one order that can be correct.**
///
/// 1. resolve the toggles (an unknown `--opt` name is refused here);
/// 2. refuse, BY NAME, any parameter the resolved state forbids — with
/// WireGuard off a supplied `hub_conf` stops the build instead of
/// riding a medium whose binary could never read it;
/// 3. apply the values against the EFFECTIVE spec list (the declared
/// roster minus what the toggles refuse, with the option's sharper
/// `param_kinds` shape applied), so a required parameter of an inactive
/// branch is not demanded and an active one still is;
/// 4. bake each option's cmdline selector from the values that actually
/// landed (`korp.env.GUNNAR_NET=eth0:static:…`);
/// 5. record both, so the attested descriptor and the content id carry the
/// toggles as well as the values.
///
/// Steps 2 and 3 are why this is one method and not two calls a door could
/// make in the wrong order: applying the values first would refuse a
/// missing `static_ip` that DHCP-on makes meaningless.
/// **Apply the DECLARED-GROUP choices of an `--opt` line** — the val
/// bucket of [`SettingsTree::split_opts`](crate::tree::SettingsTree::split_opts),
/// by canonical path.
///
/// A group is a set of vals at reserved paths (`data.fs`, `data.io`), and
/// this is the ONE writer that moves one onto the row — both doors call it
/// (edda's `suite_cli::run_installer_selected` and the forge's
/// `assemble_cell::resolve_cell_options`), so `--opt data.fs=fat` cannot
/// mean one thing to the CLI and another to the pane.
///
/// Lane N (2026-08-30) had to flip `fs = "fat"` in a scratch worktree to
/// build a FAT medium — a whole lane's detour for a value the recipe
/// already named — because a declared group was on no door at all.
///
/// A path this does not know is REFUSED, never dropped: a val the tree
/// offers and no door applies is a flag that changes nothing, which is the
/// exact shape of lie this crate keeps closing.
pub fn apply_group_choices(&mut self, choices: &std::collections::BTreeMap<String, String>) -> Result<()> {
for (path, value) in choices {
match path.as_str() {
crate::tree::DATA_FS_PATH => {
self.data.fs = crate::data::DataFs::parse(value).ok_or_else(|| {
anyhow!("installer '{}': --opt {path}={value} is not a declared filesystem", self.name)
})?;
}
crate::tree::DATA_IO_PATH => {
self.data.io = crate::data::DataIo::parse(value).ok_or_else(|| {
anyhow!("installer '{}': --opt {path}={value} is not a declared writer arm", self.name)
})?;
}
other => {
return Err(anyhow!(
"installer '{}': --opt {other}={value} is a value the tree offers and no door applies — \
wire it where the group it belongs to is read, or stop offering it",
self.name
));
}
}
}
Ok(())
}
pub fn apply_cli_selection_with(
&mut self,
options: &std::collections::BTreeMap<String, bool>,
supplied: &std::collections::BTreeMap<String, String>,
source: &dyn crate::params::FixtureSource,
) -> Result<()> {
let name = self.name.clone();
let resolved = crate::options::resolve_options(&self.options, &self.params, options)
.map_err(|e| anyhow!("installer '{name}': {e:#}"))?;
resolved.refuse_forbidden(supplied).map_err(|e| anyhow!("installer '{name}': {e:#}"))?;
let specs = resolved.effective_specs(&self.options, &self.params);
let fixtures = resolved.effective_fixtures(&self.fixtures);
let landed = crate::params::apply_params_with(&specs, supplied, &fixtures, source)
.map_err(|e| anyhow!("installer '{name}': {e:#}"))?;
let baked = resolved
.render_bakes(&self.options, &landed.record)
.map_err(|e| anyhow!("installer '{name}': {e:#}"))?;
self.boot_params.extend(landed.boot_params);
// The baked selector wins over a hand-declared token with the same key:
// a recipe that still spells `korp.env.GUNNAR_NET=eth0:dhcp` in its
// `boot_params` while ALSO declaring the dhcp option would otherwise
// ship both, and korp-start's `env_pairs` takes the first — the
// operator's choice would lose to the leftover, silently.
for token in baked {
let key = token.split_once('=').map(|(k, _)| k.to_string()).unwrap_or_default();
self.boot_params.retain(|p| !p.starts_with(&format!("{key}=")));
self.boot_params.push(token);
}
self.applied_params = landed.record;
self.applied_options = resolved.record;
Ok(())
}
/// Resolve this cell's declared options against `supplied` WITHOUT
/// applying anything — what a door needs to compute the app build's
/// feature set (and what the audit must find absent) before it builds.
pub fn resolved_options(
&self,
supplied: &std::collections::BTreeMap<String, bool>,
) -> Result<crate::options::ResolvedOptions> {
crate::options::resolve_options(&self.options, &self.params, supplied)
.map_err(|e| anyhow!("installer '{}': {e:#}", self.name))
}
/// The fixture the recipe declares for parameter `name`, if any — what a
/// panel reads to offer the affordance only where it is declared.
pub fn fixture_for(&self, name: &str) -> Option<crate::params::FixtureKind> {
self.fixtures.get(name).copied()
}
/// The exact serial marker the KVM install proof gates on — the installer's
/// terminal success line. Kept in lockstep with
/// `korp_installer::serial::INSTALL_OK_MARKER`.
pub fn success_marker(&self) -> &'static str {
"INSTALLER: INSTALL-OK"
}
/// The absolute boot-logo path this cell declares, resolved against `repo_root`.
/// `None` when the cell declares no mark. Delegates to
/// [`IsoBuild::boot_logo_path`](crate::iso::IsoBuild::boot_logo_path) through the
/// rendered ISO, so a relative `boot-logo` resolves exactly one way whether it was
/// declared on the variant, on the ISO cell or here.
pub fn boot_logo_path(&self, repo_root: &Path) -> Option<PathBuf> {
self.installer_iso().boot_logo_path(repo_root)
}
/// The blank target-disk size in bytes.
pub fn target_disk_bytes(&self) -> u64 {
self.target_disk_gib as u64 * 1024 * 1024 * 1024
}
/// Whether this is a **FAT** install (lays down a container backend) vs **LEAN**
/// (korp PID-1 only, no containers).
pub fn is_fat(&self) -> bool {
self.container_backend.is_some()
}
/// The honest appliance label for a surface / serial line / content id:
/// `lean` (no backend) or `fat-<backend>` (e.g. `fat-youki`, `fat-microvm`).
pub fn appliance_label(&self) -> String {
match self.container_backend {
None => "lean".to_string(),
Some(b) => format!("fat-{}", b.label()),
}
}
/// The kernel boot params this install bakes onto the ISO — the declared
/// [`boot_params`](Self::boot_params) PLUS the honest appliance/backend selector the
/// installed korp reads at boot: `korp.appliance=lean` or `korp.appliance=fat
/// korp.container_backend=<backend>`. The selector is idempotent — never duplicated
/// if the declared params already carry a `korp.appliance=` token.
/// Refuse a `payload_entries` table that cannot mean what it says: an entry
/// name with an empty, `.` or `..` segment, an absolute source, or the app's
/// own entry (`korp.entry=`) named twice.
pub fn check_payload_entries(&self) -> Result<(), String> {
let app_entry = self.effective_boot_params().iter().find_map(|t| t.strip_prefix("korp.entry=").map(str::to_string));
for (name, src) in &self.payload_entries {
let ok = !name.is_empty()
&& !name.starts_with('/')
&& name.split('/').all(|c| !c.is_empty() && c != "." && c != "..");
if !ok {
return Err(format!("installer '{}': payload entry {name:?} is not a relative path of plain segments", self.name));
}
if app_entry.as_deref() == Some(name.as_str()) {
return Err(format!("installer '{}': payload entry {name:?} is the app's own entry (korp.entry=)", self.name));
}
if src.is_empty() || src.starts_with('/') || src.split('/').any(|c| c == "..") {
return Err(format!("installer '{}': payload entry {name:?} names {src:?}, not a path under the repository", self.name));
}
}
Ok(())
}
pub fn effective_boot_params(&self) -> Vec<String> {
let mut params = self.boot_params.clone();
// `target.consoles`, when DECLARED, owns the `console=` tokens: the
// declared list in its order (the last one is `/dev/console`), any
// `console=` the cell spelled by hand removed first. Undeclared
// (`Target::is_default()`) leaves the cell's own tokens untouched —
// that is what every medium built before the group existed does, and
// it must keep doing it byte for byte. A cell that names no `vga`
// gets no `tty0`; one that names no `serial` gets no `ttyS0` — the
// UpCloud fact (no UART) becomes a cmdline that does not point
// `/dev/console` at a port that is not there.
if !self.target.is_default() {
params.retain(|p| !p.starts_with("console="));
let mut with_consoles = self.target.effective().console_tokens();
with_consoles.append(&mut params);
params = with_consoles;
// The firmware fact reaches PID 1 the way every other korp.* fact
// does — as a token the installer reads back off /proc/cmdline —
// so `korp-installer::install` can lay the legacy-BIOS path on the
// installed disk only for a medium whose target boots BIOS. An
// ABSENT token already means `both` (the pre-group medium), so a
// declared `both` bakes nothing: one fact, one spelling.
let fw = self.target.effective().firmware;
if fw != crate::target::Firmware::Both
&& !params.iter().any(|p| p.starts_with(&format!("{FIRMWARE_BOOT_PARAM}=")))
{
params.push(firmware_boot_token(fw));
}
}
// The data filesystem, the same way: declared and not the default,
// one token, the cell's own spelling wins.
if !self.data.is_default()
&& self.data.fs != crate::data::DataFs::Fat
&& !params.iter().any(|p| p.starts_with(&format!("{DATA_FS_BOOT_PARAM}=")))
{
params.push(data_fs_boot_token(self.data.fs));
}
// The writer arm, the same way: declared and not `safe`, one token.
if !self.data.is_default()
&& self.data.io != crate::data::DataIo::Safe
&& !params.iter().any(|p| p.starts_with(&format!("{DATA_IO_BOOT_PARAM}=")))
{
params.push(data_io_boot_token(self.data.io));
}
// korp.appliance: only when the cell did not spell it itself.
if !params.iter().any(|p| p.starts_with("korp.appliance=")) {
match self.container_backend {
None => params.push("korp.appliance=lean".to_string()),
Some(b) => {
params.push("korp.appliance=fat".to_string());
params.push(format!("korp.container_backend={}", b.label()));
}
}
}
// The declared app argv tail rides as ONE comma-joined token — same
// idempotence rule (a cell that spelled korp.args= itself keeps its
// own). NOTE this must NOT hide behind the korp.appliance early-out:
// the first rendering did, and the parity guard (holger build-space
// R8) went red because a cell with its own korp.appliance= lost its
// declared cmd entirely.
if !self.cmd.is_empty() && !params.iter().any(|p| p.starts_with("korp.args=")) {
params.push(format!("korp.args={}", self.cmd.join(",")));
}
// The declared env, one token per var (BTreeMap = deterministic
// order), same idempotence rule per key.
for (k, v) in &self.env {
if !params.iter().any(|p| p.starts_with(&format!("korp.env.{k}="))) {
params.push(format!("korp.env.{k}={v}"));
}
}
params
}
/// The **extra QEMU args** that attach the blank writable target disk (and,
/// when [`gpu`](Self::gpu) is set, a virtio-gpu) alongside the installer ISO —
/// the seam a `BootSpec`'s `extra_qemu_args` carries, since tunnr's one-payload
/// slot holds the ISO. `disk_path` is the blank raw/qcow2 image the harness
/// created.
/// **The container this row's cloud actually imports**, or `None` when the
/// row does not say which machine it is for.
///
/// A medium and its wrapper are two different facts. `gunnar` v1.3.0
/// shipped three `.iso` files for three clouds of which exactly one has a
/// door for an ISO — the bytes were right in all three and the wrapper was
/// wrong in two, and nothing in the declaration could have caught it,
/// because nothing in the declaration said which container the machine
/// takes.
///
/// Derived from [`Target::preset`], and ONLY from it, because the shape
/// alone cannot answer this. FOUR roster rows present the identical device
/// set — `upcloud`, `hetzner-cloud`, `ovh-public-cloud` and
/// `scaleway-instance` are all virtio-blk + virtio-net — and they split two
/// and two on the container: the first pair takes an attached ISO, the
/// second an imported qcow2. A reverse lookup from `buses`/`nic` would have
/// to pick one of four, and picking would be a guess wearing a
/// derivation's clothes.
///
/// So a row that wants its cloud's container names its machine. A row that
/// hand-spells the same fields gets `None` — not because the fields are
/// wrong, but because a shape is not an identity.
#[cfg(not(target_arch = "wasm32"))]
pub fn cloud_medium(&self) -> Option<crate::image_writer::CloudMedium> {
let name = self.target.preset.as_deref()?;
let p = crate::target::presets::preset(name)?;
crate::image_writer::CloudMedium::for_preset(p)
}
/// **Why this row cannot yield an import container**, when it cannot.
///
/// `None` means it can (or that it is an ISO-attach machine and does not
/// need one). A `Some` is the sentence a composer prints instead of
/// silently producing only the `.iso` — which is exactly what happened for
/// AWS and Azure in v1.3.0 and was found by a human reading a roster
/// afterwards.
#[cfg(not(target_arch = "wasm32"))]
pub fn cloud_medium_gap(&self) -> Option<String> {
if self.cloud_medium().is_some() {
return None;
}
match self.target.preset.as_deref() {
None if self.medium_boot.is_default() => None,
None => Some(format!(
"installer `{}` lays a medium that boots off an imported disk (`medium_boot = \"{}\"`) \
but names no machine (`target.preset`), so no cloud container can be derived for it. \
The shape alone cannot decide: upcloud, hetzner-cloud, ovh-public-cloud and \
scaleway-instance share one device set and import two different containers. \
Name the preset.",
self.name,
self.medium_boot.as_str()
)),
// **A named ISO-attach machine owes no import container**, and that
// is not an exemption — it is the answer. Its cloud takes the
// `.iso` this compose already wrote (`crate::iso_writer`), so there
// is no missing artifact to report; `image_writer` has no writer for
// it because there is nothing left to write.
//
// Until 2026-09-07 this fell through to the last arm and answered
// "no writer in `image_writer` produces that container" for
// `upcloud` — contradicting this function's OWN doc comment ("or
// that it is an ISO-attach machine and does not need one") and
// `korp-installer`'s composer ("a cell that names no machine, or
// names an ISO-attach one, gets exactly what it got before"). So
// naming the machine did not silence the sentence the composer
// printed; it swapped one gap for another that was never a gap.
//
// Keyed on `MediumBring::is_iso_attach` and NOTHING broader: a
// machine whose platform imports a disk still owes its container.
// `scaleway-instance` is `ImportQcow2` — one writer exists, so it
// never reaches here — and `scaleway-elastic-metal` is `Metal`,
// which no writer can serve and which is still refused BY NAME
// below. That is the v1.3.0 defect (three `.iso` files mailed to
// three clouds, two of which have no door for one) still caught.
Some(n) if crate::target::presets::preset(n).is_some_and(|p| p.medium.is_iso_attach()) => None,
Some(n) if crate::target::presets::preset(n).is_none() => Some(format!(
"installer `{}` names machine `{n}`, which is not in the roster: {}",
self.name,
crate::target::presets::names().join(", ")
)),
Some(n) => Some(format!(
"installer `{}` names machine `{n}`, whose medium arrives as {} — no writer in \
`image_writer` produces that container",
self.name,
crate::target::presets::preset(n)
.map(|p| p.medium.describe())
.unwrap_or_default()
)),
}
}
/// The format word comes from [`CloudMedium`](crate::image_writer::CloudMedium),
/// which is the one place that knows what each artifact IS. This used to
/// sniff it from the file extension —
/// `if disk_path.ends_with(".qcow2") { "qcow2" } else { "raw" }` — and that
/// was a silent wrong answer for every medium the extension does not name.
/// A `.vhd` was attached `format=raw`, so the firmware read the `conectix`
/// footer where a partition table should be and found nothing bootable.
/// Harmless while raw and qcow2 were the only two disks this crate could
/// produce; a defect the moment it could write a VHD (2026-09-05).
///
/// An unrecognised extension still means `raw`, which is the honest default
/// for a blank disk a harness just created — that is what this seam is
/// usually handed, and a blank disk has no format to detect.
#[cfg(not(target_arch = "wasm32"))]
pub fn kvm_disk_args(&self, disk_path: &str) -> Vec<String> {
let medium = crate::image_writer::CloudMedium::for_extension(disk_path);
let fmt = medium.map_or("raw", |m| m.qemu_format());
let mut a = vec![
"-drive".to_string(),
format!("file={disk_path},if=virtio,format={fmt}"),
];
a.extend(self.kvm_gpu_args());
a
}
/// The **GPU half** of [`kvm_disk_args`](Self::kvm_disk_args) alone — for a
/// caller whose boot seam already attaches the target disk as the spec's own
/// image (draupnir's `kvm_install_from_medium`), so a second `-drive` would be a
/// duplicate. Empty when [`gpu`](Self::gpu) is off.
pub fn kvm_gpu_args(&self) -> Vec<String> {
if !self.gpu {
return Vec::new();
}
// `-vga none` kills QEMU's implicit default std-VGA adapter so the virtio-vga
// below is the SOLE / primary display — the one OVMF's GOP draws to AND the
// one QMP `screendump` (no device arg) captures. Without it QEMU keeps the
// default VGA as primary and the screendump comes back blank while the guest
// actually renders on the secondary virtio adapter.
vec!["-vga".to_string(), "none".to_string(), "-device".to_string(), "virtio-vga".to_string()]
}
/// Validate: a name, a volume id, a plausible target-disk size, and a payload.
pub fn validate(&self) -> Result<()> {
if self.name.trim().is_empty() {
return Err(anyhow!("installer build: empty name"));
}
if self.volume_id.trim().is_empty() {
return Err(anyhow!("installer '{}': empty volume_id", self.name));
}
if self.target_disk_gib == 0 {
return Err(anyhow!("installer '{}': target_disk_gib must be > 0", self.name));
}
if let PayloadKind::S3 { bucket, key, .. } = &self.payload {
if bucket.trim().is_empty() || key.trim().is_empty() {
return Err(anyhow!("installer '{}': S3 payload needs a bucket + key", self.name));
}
}
crate::params::validate_specs(&self.params)
.map_err(|e| anyhow!("installer '{}': {e:#}", self.name))?;
crate::params::validate_fixtures(&self.params, &self.fixtures)
.map_err(|e| anyhow!("installer '{}': {e:#}", self.name))?;
crate::options::validate_options(&self.options, &self.params)
.map_err(|e| anyhow!("installer '{}': {e:#}", self.name))?;
for r in &self.requires {
r.what.validate(&format!("installer '{}' (edge '{}')", self.name, r.edge))?;
}
// The argv tail must survive the comma-joined boot token AND the
// kernel's whitespace split — refused BY NAME here so the encode is
// total: an element with a space would silently become a second
// kernel token, a comma a second argument, a quote gets eaten by the
// cmdline parser's trim_matches('"'), and an empty element vanishes.
for (i, arg) in self.cmd.iter().enumerate() {
if arg.is_empty() {
return Err(anyhow!("installer '{}': cmd[{i}] is empty — an empty argv element cannot ride the korp.args token", self.name));
}
if let Some(bad) = arg.chars().find(|c| c.is_whitespace() || *c == ',' || *c == '"') {
return Err(anyhow!(
"installer '{}': cmd[{i}] ({arg:?}) contains {bad:?} — korp.args is comma-joined on a whitespace-split kernel cmdline with no escapes; spell the argument without it",
self.name
));
}
}
// The env map: the same total-encoding law as cmd, per key AND value —
// and a key that smells like a secret is refused outright (the token
// lands on the ESP's cmdline, world-readable forever).
for (k, v) in &self.env {
if k.is_empty() {
return Err(anyhow!("installer '{}': env has an empty key", self.name));
}
for (what, text) in [("key", k.as_str()), ("value", v.as_str())] {
if let Some(bad) = text.chars().find(|c| c.is_whitespace() || *c == ',' || *c == '"' || *c == '=') {
return Err(anyhow!(
"installer '{}': env {what} {text:?} contains {bad:?} — korp.env.* tokens \
ride a whitespace-split kernel cmdline with no escapes",
self.name
));
}
}
let upper = k.to_uppercase();
for frag in ["SECRET", "TOKEN", "PASSWORD", "PRIVATE", "APIKEY", "API_KEY", "CREDENTIAL"] {
if upper.contains(frag) {
return Err(anyhow!(
"installer '{}': env key {k:?} smells like a secret ({frag}) — the cmdline \
is world-readable on the ESP forever; secrets never ride korp.env.*",
self.name
));
}
}
}
// The kernel donor: both halves named, or the reference resolves nothing.
// Refused HERE, at declaration time, because an empty half would otherwise
// surface as a "no such checkout" error at compose time — true words about
// the wrong defect.
let who = format!("installer '{}'", self.name);
if let Some(d) = &self.kernel_donor {
d.validate(&who)?;
}
self.target.validate(&who)?;
crate::tree::validate_edge_rows(&who, self.kernel_donor.as_ref(), &self.edges, &self.options)?;
// Every preset bundle names paths this variant's tree HAS, with values
// the nodes take — proven here, against the tree of the default pick.
crate::tree::SettingsTree::of_installer(self, None).validate_presets()?;
// The mastered ISO must itself validate.
self.installer_iso().validate()
}
/// The canonical bytes the content id is taken over.
pub fn canonical_bytes(&self) -> Vec<u8> {
let mut s = String::new();
s.push_str(&self.installer_iso().canonical_bytes().iter().map(|b| *b as char).collect::<String>());
s.push_str(&format!("#variant:{}", self.variant));
s.push_str(&format!("#payload:{}", self.payload.label()));
s.push_str(&format!("#backend:{}", self.appliance_label()));
s.push_str(&format!("#disk:{}", self.target_disk_gib));
s.push_str(&format!("#gpu:{}", self.gpu));
// The machine shape, only when declared — two media built for two
// firmwares are two artifacts; an undeclared row keeps every old id.
s.push_str(&self.target.canonical_fragment());
// The data filesystem, only when declared — an xfs /data and a FAT
// /data are two media; an undeclared row keeps every old id.
s.push_str(&self.data.canonical_fragment());
// Only when declared, so every pre-cmd cell keeps its content id —
// and two ISOs that start the product differently can never share one.
if !self.cmd.is_empty() {
s.push_str(&format!("#cmd:{}", self.cmd.join(",")));
}
if !self.env.is_empty() {
for (k, v) in &self.env {
s.push_str(&format!("#env:{k}={v}"));
}
}
// Two media that boot DIFFERENT kernels are different artifacts, and the
// donor is what decides which kernel this one boots. Only written when
// declared, so every donor-less cell keeps the id it already had.
if let Some(d) = &self.kernel_donor {
s.push_str(&format!("#kernel-donor:{}/{}", d.repo, d.kernel));
}
for (f, e) in &self.edges {
s.push_str(&format!("#edge:{f}={}:{}", e.coordinate(), e.typ.as_str()));
}
// The bundle a medium was built from is part of what it IS — recorded
// beside the expanded choices below, which carry the actual decisions.
if let Some(p) = &self.applied_preset {
s.push_str(&format!("#preset:{}", p.bundle));
}
// Two media that differ only by a toggle carry DIFFERENT code, so they
// may never share a content id. Only written when options resolved, so
// every pre-options cell keeps the id it already had.
for o in &self.applied_options {
s.push_str(&format!("#opt:{}={}", o.name, if o.on { "on" } else { "off" }));
}
s.into_bytes()
}
/// A content-addressed id (lowercase-hex SHA-256 over [`canonical_bytes`]).
pub fn content_id(&self) -> String {
nornir_hash::sha256_hex(&self.canonical_bytes())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `payload_entries` (lane HOLGER-MEDIA): read from TOML, absent when empty,
/// and refused by name when a name or a source cannot be what it says.
#[test]
fn payload_entries_read_emit_and_are_checked() {
let text = "name = \"x\"\nboot_params = [\"korp.entry=holger\"]\n[payload_entries]\n\"sandbox/crun\" = \".sandbox-out/crun\"\n\"sandbox/manifest.toml\" = \".sandbox-out/manifest.toml\"\n";
let i: InstallerBuild = toml::from_str(text).unwrap();
assert_eq!(i.payload_entries.len(), 2);
assert_eq!(i.payload_entries["sandbox/crun"], ".sandbox-out/crun");
i.check_payload_entries().unwrap();
let back = toml::to_string(&i).unwrap();
assert!(back.contains("[payload_entries]"), "{back}");
let none: InstallerBuild = toml::from_str("name = \"x\"\n").unwrap();
assert!(none.payload_entries.is_empty());
assert!(!toml::to_string(&none).unwrap().contains("payload_entries"), "absent when empty");
for (name, src, why) in [
("../up", ".sandbox-out/x", "plain segments"),
("/abs", ".sandbox-out/x", "plain segments"),
("sandbox//x", ".sandbox-out/x", "plain segments"),
("holger", ".sandbox-out/x", "the app's own entry"),
("sandbox/x", "/etc/passwd", "under the repository"),
("sandbox/x", "../../x", "under the repository"),
] {
let mut bad = i.clone();
bad.payload_entries.clear();
bad.payload_entries.insert(name.into(), src.into());
let e = bad.check_payload_entries().unwrap_err();
assert!(e.contains(why), "{name} = {src}: {e}");
}
}
/// **`target.firmware` reaches the rendered ISO, and only a declared row
/// is emitted.** Default = today's medium: the UEFI format (the BIOS stage
/// is laid beside it by the composer), no `target` table in the toml, the
/// content id unchanged. `seabios` → the BIOS format; `uefi` keeps the UEFI
/// format and the composer lays no stage. Two media built for two firmwares
/// are two artifacts.
#[test]
fn target_firmware_decides_the_iso_format_and_is_emitted_only_when_declared() {
use crate::target::Firmware;
let plain = holgerish();
assert_eq!(plain.installer_iso().format, IsoFormat::Uefi);
assert!(plain.target.firmware.lays_bios() && plain.target.firmware.lays_uefi());
let emitted = toml::to_string(&plain).unwrap();
assert!(!emitted.contains("[target]"), "an undeclared group emits no row:\n{emitted}");
let mut bios = holgerish();
bios.target.firmware = Firmware::SeaBios;
assert_eq!(bios.installer_iso().format, IsoFormat::Bios);
let mut uefi = holgerish();
uefi.target.firmware = Firmware::Uefi;
assert_eq!(uefi.installer_iso().format, IsoFormat::Uefi);
assert!(!uefi.target.firmware.lays_bios());
assert_ne!(plain.content_id(), bios.content_id());
assert_ne!(plain.content_id(), uefi.content_id());
assert_ne!(bios.content_id(), uefi.content_id());
// The toml spelling a repo's declaration carries, read back through the
// same struct — and the row is emitted when it was chosen.
let read: InstallerBuild = toml::from_str(
"name = \"x\"\nvolume_id = \"X\"\n[target]\nfirmware = \"uefi\"\nconsoles = [\"vga\"]\n",
)
.unwrap();
assert_eq!(read.target.firmware, Firmware::Uefi);
assert_eq!(read.target.console_tokens(), vec!["console=tty0"]);
let emitted = toml::to_string(&read).unwrap();
assert!(emitted.contains("[target]") && emitted.contains("firmware = \"uefi\""), "{emitted}");
read.validate().unwrap();
// An unseeable boot is refused by the installer's own validate.
let mut blind = holgerish();
blind.target.consoles.clear();
let err = blind.validate().unwrap_err().to_string();
assert!(err.contains("nobody can see"), "{err}");
}
/// **`target.consoles` owns the `console=` order once declared, and is
/// inert while undeclared.** The UpCloud fact — no UART, VGA only — must
/// become a cmdline whose `/dev/console` is `tty0`, not a `ttyS0` that is
/// not there; and every medium that never declared the group keeps its
/// hand-spelled tokens byte for byte.
#[test]
fn declared_consoles_own_the_console_order_and_an_undeclared_group_is_inert() {
use crate::target::Console;
let mut cell = holgerish();
cell.boot_params = vec!["console=ttyS0".into(), "panic=10".into()];
// Undeclared: exactly the cell's own tokens (plus the appliance selector).
assert_eq!(
cell.effective_boot_params(),
vec!["console=ttyS0", "panic=10", "korp.appliance=lean"]
);
// Declared VGA only (UpCloud): the hand-spelled ttyS0 goes, tty0 leads.
cell.target.consoles = vec![Console::Vga];
assert_eq!(
cell.effective_boot_params(),
vec!["console=tty0", "panic=10", "korp.appliance=lean"]
);
// Declared serial-then-vga: the order is the declared order, tty0 last
// = /dev/console on the graphics console.
cell.target.consoles = vec![Console::Serial, Console::Vga];
assert_eq!(
cell.effective_boot_params()[..2],
["console=ttyS0,115200", "console=tty0"]
);
// The rendered ISO and the content id follow.
assert_eq!(cell.installer_iso().boot_params[..2], ["console=ttyS0,115200", "console=tty0"]);
assert_ne!(cell.content_id(), holgerish().content_id());
}
/// **`data.fs` reaches PID 1 as `korp.data.fs=xfs`, and only when
/// declared and not FAT** — an undeclared row and a declared `fat` both
/// bake nothing (one fact, one spelling: absent = FAT), the cell's own
/// token wins, and the content id moves with the declaration. Seen red:
/// the same cell with the group cleared carries no `korp.data.fs=`.
#[test]
fn data_fs_is_baked_as_a_korp_token_only_when_declared_and_not_fat() {
use crate::data::{Data, DataFs, DataIo};
let mut cell = InstallerBuild { name: "x".into(), ..Default::default() };
let plain_id = cell.content_id();
assert!(!cell.effective_boot_params().iter().any(|p| p.starts_with("korp.data.fs=")));
cell.data = Data { fs: DataFs::Fat, io: DataIo::Safe };
assert!(!cell.effective_boot_params().iter().any(|p| p.starts_with("korp.data.fs=")), "a declared fat bakes nothing");
cell.data = Data { fs: DataFs::Xfs, io: DataIo::Safe };
let params = cell.effective_boot_params();
assert!(params.contains(&"korp.data.fs=xfs".to_string()), "{params:?}");
assert_eq!(data_fs_boot_token(DataFs::Xfs), "korp.data.fs=xfs");
assert_ne!(cell.content_id(), plain_id, "an xfs /data is another medium");
// The declared row is emitted, a read-back row is the same struct.
let read: InstallerBuild = toml::from_str("name = \"x\"\nvolume_id = \"X\"\n[data]\nfs = \"xfs\"\n").unwrap();
assert_eq!(read.data.fs, DataFs::Xfs);
let emitted = toml::to_string(&read).unwrap();
assert!(emitted.contains("[data]") && emitted.contains("fs = \"xfs\""), "{emitted}");
let plain: InstallerBuild = toml::from_str("name = \"x\"\nvolume_id = \"X\"\n").unwrap();
assert!(!toml::to_string(&plain).unwrap().contains("[data]"));
// The cell's own spelling wins over the bake.
let mut spelled = cell.clone();
spelled.boot_params = vec!["korp.data.fs=fat".into()];
let p = spelled.effective_boot_params();
assert_eq!(p.iter().filter(|t| t.starts_with("korp.data.fs=")).count(), 1);
assert!(p.contains(&"korp.data.fs=fat".to_string()));
}
/// **`target.firmware` reaches PID 1 as `korp.target.firmware=`, and only
/// when the group is declared.** The installer PID 1 lays the legacy-BIOS
/// path on the installed disk off this token (`korp-installer::install`);
/// an undeclared medium bakes no token, so its cmdline — and the disk it
/// lays — stay byte-identical to before the group existed.
/// **`data.io` reaches PID 1 as `korp.data.io=uring`, and only when
/// declared and not `safe`** — beside the fs token, never instead of it;
/// the cell's own spelling wins; the id moves; an fs-only row is unchanged.
#[test]
fn data_io_is_baked_as_a_korp_token_only_when_declared_and_not_safe() {
use crate::data::{Data, DataFs, DataIo};
let mut cell = InstallerBuild::default();
cell.data = Data { fs: DataFs::Xfs, io: DataIo::Safe };
let fs_only_id = cell.content_id();
assert!(!cell.effective_boot_params().iter().any(|p| p.starts_with("korp.data.io=")), "safe bakes nothing");
cell.data = Data { fs: DataFs::Xfs, io: DataIo::Uring };
let params = cell.effective_boot_params();
assert!(params.contains(&"korp.data.io=uring".to_string()), "{params:?}");
assert!(params.contains(&"korp.data.fs=xfs".to_string()), "the fs token stays: {params:?}");
assert_eq!(data_io_boot_token(DataIo::Uring), "korp.data.io=uring");
assert_ne!(cell.content_id(), fs_only_id, "the arm is another medium");
let read: InstallerBuild = toml::from_str("name = \"x\"\nvolume_id = \"X\"\n[data]\nfs = \"xfs\"\nio = \"uring\"\n").unwrap();
assert_eq!(read.data.io, DataIo::Uring);
let emitted = toml::to_string(&read).unwrap();
assert!(emitted.contains("io = \"uring\""), "{emitted}");
let mut spelled = cell.clone();
spelled.boot_params = vec!["korp.data.io=safe".into()];
let p = spelled.effective_boot_params();
assert_eq!(p.iter().filter(|t| t.starts_with("korp.data.io=")).count(), 1);
assert!(p.contains(&"korp.data.io=safe".to_string()), "the cell's own spelling wins");
}
#[test]
fn target_firmware_is_baked_as_a_korp_token_only_when_declared() {
use crate::target::Firmware;
let plain = holgerish();
assert!(
!plain.effective_boot_params().iter().any(|p| p.starts_with(FIRMWARE_BOOT_PARAM)),
"undeclared: no token: {:?}",
plain.effective_boot_params()
);
for fw in [Firmware::Uefi, Firmware::SeaBios] {
let mut cell = holgerish();
cell.target.firmware = fw;
let params = cell.effective_boot_params();
assert!(params.contains(&format!("korp.target.firmware={}", fw.as_str())), "{fw:?}: {params:?}");
assert_eq!(params.iter().filter(|p| p.starts_with(FIRMWARE_BOOT_PARAM)).count(), 1);
assert_eq!(firmware_boot_token(fw), format!("korp.target.firmware={}", fw.as_str()));
}
// A declared `both` is what an absent token already means — the group
// declared for its consoles alone bakes no firmware token.
let mut consoles_only = holgerish();
consoles_only.target.consoles = vec![crate::target::Console::Vga];
assert!(
!consoles_only.effective_boot_params().iter().any(|p| p.starts_with(FIRMWARE_BOOT_PARAM)),
"{:?}",
consoles_only.effective_boot_params()
);
// A preset declares the fact too (UpCloud = seabios).
let mut preset = holgerish();
preset.target.preset = Some("upcloud".into());
assert!(preset.effective_boot_params().contains(&"korp.target.firmware=seabios".to_string()));
// A hand-spelled token is not doubled.
let mut spelled = holgerish();
spelled.target.firmware = Firmware::Uefi;
spelled.boot_params = vec!["korp.target.firmware=both".into()];
assert_eq!(spelled.effective_boot_params().iter().filter(|p| p.starts_with(FIRMWARE_BOOT_PARAM)).count(), 1);
}
/// **`--param reinstall=wipe` reaches the medium as `korp.reinstall=wipe`,
/// and ONLY when supplied.** The default medium bakes no reinstall token
/// (so a korp-installed target is upgraded and its /data kept by
/// construction); a declared, supplied `wipe` lands exactly once; the
/// declared spec is the shared one.
#[test]
fn reinstall_wipe_is_a_declared_param_that_lands_only_when_supplied() {
let mut cell = holgerish();
cell.params.push(reinstall_param_spec());
assert!(
!cell.effective_boot_params().iter().any(|p| p.starts_with(REINSTALL_BOOT_PARAM)),
"declared but not supplied: no token: {:?}",
cell.effective_boot_params()
);
let mut supplied = std::collections::BTreeMap::new();
supplied.insert("reinstall".to_string(), REINSTALL_WIPE.to_string());
cell.apply_cli_params(&supplied).unwrap();
let params = cell.effective_boot_params();
assert_eq!(params.iter().filter(|p| p.starts_with(REINSTALL_BOOT_PARAM)).count(), 1, "{params:?}");
assert!(params.contains(&"korp.reinstall=wipe".to_string()), "{params:?}");
assert_eq!(cell.applied_params.len(), 1);
// RED: the same word supplied to a medium that did NOT declare the
// param is refused by name — a policy nobody declared never lands.
let mut undeclared = holgerish();
let err = undeclared.apply_cli_params(&supplied).unwrap_err().to_string();
assert!(err.contains("not a declared parameter"), "{err}");
assert_eq!(reinstall_param_spec().boot_param, "korp.reinstall");
}
fn holgerish() -> InstallerBuild {
let mut b = InstallerBuild::default();
b.name = "holger-installer".into();
b.volume_id = "HOLGER".into();
b
}
/// **The mark reaches the rendered ISO, and it changes the content id.**
///
/// `installer_iso()` copies a fixed handful of fields and falls to
/// `..Default::default()` for the rest, so a new field is one forgotten line away
/// from being declarable and silently dropped — which is worse than not having it,
/// because the declaration then LOOKS honoured. This asserts both halves: the
/// rendered ISO carries the path, and two cells that differ only by their mark are
/// not the same artifact.
#[test]
fn the_boot_logo_travels_to_the_rendered_iso_and_into_the_content_id() {
let plain = holgerish();
let mut marked = holgerish();
marked.boot_logo = Some("assets/holger.webp".into());
assert_eq!(plain.installer_iso().boot_logo, None);
assert_eq!(marked.installer_iso().boot_logo.as_deref(), Some("assets/holger.webp"));
assert_ne!(
plain.content_id(),
marked.content_id(),
"two installers wearing different marks are two different artifacts",
);
// Resolved against the repo root, and relative exactly like the ISO cell's.
let root = Path::new("/repo");
assert_eq!(plain.boot_logo_path(root), None);
assert_eq!(
marked.boot_logo_path(root),
Some(PathBuf::from("/repo/assets/holger.webp")),
);
// A blank declaration is not a mark — it must not become "/repo".
let mut blank = holgerish();
blank.boot_logo = Some(" ".into());
assert_eq!(blank.boot_logo_path(root), None);
}
/// R1: the declared argv tail rides as ONE comma-joined korp.args token.
#[test]
fn declared_cmd_renders_as_one_korp_args_token() {
let mut b = holgerish();
b.cmd = vec!["start".into(), "--config".into(), "/data/holger.ron".into()];
let params = b.effective_boot_params();
assert!(
params.iter().any(|p| p == "korp.args=start,--config,/data/holger.ron"),
"the tail rides verbatim: {params:?}"
);
assert_eq!(
params.iter().filter(|p| p.starts_with("korp.args=")).count(),
1,
"exactly one korp.args token"
);
}
/// R2: elements that cannot survive the encoding are refused BY NAME at
/// declaration — a space would become a second KERNEL token silently.
#[test]
fn cmd_elements_that_cannot_ride_the_token_refuse_by_name() {
for (bad, why) in [
("/data/a b.ron", "whitespace"),
("a,b", "comma"),
("say\"hi\"", "quote"),
("", "empty"),
] {
let mut b = holgerish();
b.cmd = vec![bad.to_string()];
let err = b.validate().expect_err(why).to_string();
assert!(err.contains("cmd[0]"), "{why}: names the element: {err}");
}
}
/// R3: a cell that already spells korp.args= in boot_params keeps its own
/// (the korp.appliance idempotence rule, applied to the new token).
#[test]
fn a_cell_that_spelled_korp_args_itself_keeps_it() {
let mut b = holgerish();
b.boot_params = vec!["korp.args=serve".into()];
b.cmd = vec!["start".into()];
let params = b.effective_boot_params();
assert_eq!(
params.iter().filter(|p| p.starts_with("korp.args=")).count(),
1
);
assert!(params.iter().any(|p| p == "korp.args=serve"), "the cell's own wins: {params:?}");
}
/// The env map rides as korp.env.* tokens; a secret-smelling key and an
/// unencodable value refuse BY NAME; a cell's own token wins.
#[test]
fn declared_env_renders_refuses_secrets_and_respects_own_tokens() {
let mut b = holgerish();
b.env.insert("HOLGER_APPLIANCE".into(), "1".into());
let params = b.effective_boot_params();
assert!(params.iter().any(|p| p == "korp.env.HOLGER_APPLIANCE=1"), "{params:?}");
let mut secret = holgerish();
secret.env.insert("HUB_API_TOKEN".into(), "x".into());
let err = secret.validate().expect_err("secret key").to_string();
assert!(err.contains("HUB_API_TOKEN") && err.contains("secret"), "{err}");
let mut spaced = holgerish();
spaced.env.insert("A".into(), "b c".into());
assert!(spaced.validate().is_err(), "whitespace value refuses");
let mut own = holgerish();
own.boot_params = vec!["korp.env.HOLGER_APPLIANCE=0".into()];
own.env.insert("HOLGER_APPLIANCE".into(), "1".into());
let params = own.effective_boot_params();
assert_eq!(params.iter().filter(|p| p.starts_with("korp.env.HOLGER_APPLIANCE=")).count(), 1);
assert!(params.iter().any(|p| p == "korp.env.HOLGER_APPLIANCE=0"), "the cell's own wins");
}
/// The installer KIND is distinct — round-trips as "installer", is never
/// confused with `iso` in a record, and the cell's artifact_kind answers
/// Installer.
///
/// **And a variant MAY declare it.** This assertion is the 2026-08-15
/// vocabulary ruling, and it is the exact inverse of what this test asserted
/// before: `validate_kind` used to REFUSE `kind = "installer"` on a variant
/// ("not an atom kind — declare an [[build.installer]] cell instead"). Under the
/// ruling `kind` is a FIELD on a variant and `[[build.installer]]` is permanent
/// sugar for it, so both spellings are legal and land in the same unified view.
#[test]
fn the_installer_kind_is_distinct_and_a_variant_may_declare_it() {
use crate::variant::{ArtifactKind, BuildVariant, BuildVariantSet};
assert_eq!(ArtifactKind::from("installer".to_string()), ArtifactKind::Installer);
assert_eq!(ArtifactKind::Installer.as_str(), "installer");
assert_ne!(ArtifactKind::Installer, ArtifactKind::Iso, "the record can tell them apart");
assert_eq!(holgerish().artifact_kind(), ArtifactKind::Installer);
let set = BuildVariantSet::default();
let mut v = BuildVariant::default();
v.name = "site-installer".into();
v.kind = ArtifactKind::Installer;
set.validate_kind(&v)
.expect("a variant may declare kind = 'installer' (the ruling reverses the refusal)");
// Both spellings reach the same unified row, with the same kind.
let both = BuildVariantSet::from_toml_str(
r#"
[build]
[[build.variant]]
name = "field-spelling"
kind = "installer"
[[build.installer]]
name = "section-spelling"
installer_variant = "pid1"
target_disk_gib = 64
"#,
)
.expect("both spellings parse");
assert_eq!(both.declared("field-spelling").map(|d| d.kind), Some(ArtifactKind::Installer));
assert_eq!(
both.declared("section-spelling").map(|d| d.kind),
Some(ArtifactKind::Installer)
);
assert_eq!(both.declared_count(), 2, "one variant + one installer = two declarations");
}
/// Provenance: two ISOs that start the product differently can never
/// share a content id — and a pre-cmd cell keeps its id (the field only
/// enters the canonical bytes when declared).
#[test]
fn cmd_reaches_the_content_id_only_when_declared() {
let plain = holgerish();
let mut with_cmd = holgerish();
with_cmd.cmd = vec!["start".into()];
assert_ne!(plain.content_id(), with_cmd.content_id());
assert!(!String::from_utf8(plain.canonical_bytes()).unwrap().contains("#cmd:"));
}
fn korp() -> InstallerBuild {
InstallerBuild {
name: "korp-installer".into(),
installer_variant: "korp-installer-pid1".into(),
target_disk_gib: 64,
..Default::default()
}
}
#[test]
fn masters_a_uefi_installer_iso_chaining_the_pid1_variant() {
let iso = korp().installer_iso();
assert_eq!(iso.name, "korp-installer");
assert_eq!(iso.format, IsoFormat::Uefi);
assert_eq!(iso.from_variant.as_deref(), Some("korp-installer-pid1"));
assert_eq!(iso.staging, "iso-root");
// The masterer's argv carries an EFI boot image (a UEFI ISO).
assert!(iso.master_args("x.iso").iter().any(|a| a == "-e"));
assert_eq!(korp().variant_deps(), vec!["korp-installer-pid1"]);
}
#[test]
fn kvm_disk_args_attach_a_blank_target_and_optional_gpu() {
let a = korp().kvm_disk_args("/tmp/target.raw");
assert!(a.windows(2).any(|w| w == ["-drive", "file=/tmp/target.raw,if=virtio,format=raw"]));
assert!(!a.iter().any(|x| x == "-device"), "no GPU unless requested");
let mut g = korp();
g.gpu = true;
let ga = g.kvm_disk_args("/tmp/t.qcow2");
assert!(ga.iter().any(|x| x == "file=/tmp/t.qcow2,if=virtio,format=qcow2"), "qcow2 fmt detected");
assert!(ga.windows(2).any(|w| w == ["-device", "virtio-vga"]), "GPU attached on request");
}
#[test]
fn success_marker_and_disk_size_are_stable() {
assert_eq!(korp().success_marker(), "INSTALLER: INSTALL-OK");
assert_eq!(korp().target_disk_bytes(), 64 * 1024 * 1024 * 1024);
}
#[test]
fn validate_and_content_id_round_trip() {
let b = korp();
b.validate().expect("a well-formed installer validates");
let id = b.content_id();
assert_eq!(id.len(), 64);
let json = serde_json::to_string_pretty(&b).unwrap();
let back: InstallerBuild = serde_json::from_str(&json).unwrap();
assert_eq!(back, b, "InstallerBuild survives a JSON round-trip");
assert_eq!(back.content_id(), id, "content id stable across round-trip");
// A distinct disk size ⇒ a distinct id.
let mut other = korp();
other.target_disk_gib = 128;
assert_ne!(other.content_id(), id);
}
#[test]
fn empty_name_and_zero_disk_are_rejected() {
let mut b = korp();
b.name = String::new();
assert!(b.validate().is_err());
let mut b = korp();
b.target_disk_gib = 0;
assert!(b.validate().is_err());
}
#[test]
fn payload_kind_defaults_to_embedded_and_labels_s3() {
assert_eq!(PayloadKind::default(), PayloadKind::EmbeddedIso);
assert_eq!(PayloadKind::default().label(), "embedded");
let s3 = PayloadKind::S3 {
endpoint: "http://minio:9000".into(),
bucket: "korp".into(),
key: "app.znippy".into(),
};
assert_eq!(s3.label(), "s3://korp/app.znippy");
}
#[test]
fn lean_carries_no_backend_and_bakes_the_lean_selector() {
let b = korp();
assert!(!b.is_fat(), "default korp installer is LEAN");
assert_eq!(b.appliance_label(), "lean");
assert!(b.container_backend.is_none(), "LEAN links zero container 3pp");
let params = b.effective_boot_params();
assert!(
params.iter().any(|p| p == "korp.appliance=lean"),
"LEAN bakes korp.appliance=lean into the ISO cmdline: {params:?}"
);
assert!(
!params.iter().any(|p| p.starts_with("korp.container_backend=")),
"LEAN never names a container backend: {params:?}"
);
}
#[test]
fn each_fat_backend_bakes_its_selector_and_is_distinctly_content_addressed() {
// RED if the backend stops flowing into the ISO cmdline or the content id — i.e.
// if the whole matrix collapses back to one indistinguishable installer.
let lean_id = korp().content_id();
let mut ids = std::collections::BTreeSet::new();
ids.insert(lean_id.clone());
for backend in [
ContainerBackend::Youki,
ContainerBackend::Podman,
ContainerBackend::Containerd,
ContainerBackend::MicroVm,
] {
let mut b = korp();
b.container_backend = Some(backend);
assert!(b.is_fat(), "a backend makes it FAT");
assert_eq!(b.appliance_label(), format!("fat-{}", backend.label()));
let params = b.effective_boot_params();
assert!(
params.iter().any(|p| p == "korp.appliance=fat"),
"{}: FAT bakes korp.appliance=fat: {params:?}",
backend.label()
);
assert!(
params.iter().any(|p| p == &format!("korp.container_backend={}", backend.label())),
"{}: FAT names its backend on the cmdline: {params:?}",
backend.label()
);
let id = b.content_id();
assert_ne!(id, lean_id, "{}: a FAT install is NOT the lean install", backend.label());
assert!(ids.insert(id), "{}: each backend is a DISTINCT content id", backend.label());
}
assert_eq!(ids.len(), 5, "lean + 4 FAT backends = 5 distinct installers");
}
#[test]
fn container_backend_label_round_trips_and_knows_daemonless() {
for b in [
ContainerBackend::Youki,
ContainerBackend::Podman,
ContainerBackend::Containerd,
ContainerBackend::MicroVm,
] {
assert_eq!(ContainerBackend::from_label(b.label()), Some(b));
}
assert_eq!(ContainerBackend::from_label("lean"), None, "lean is the no-backend sentinel");
// The os-less-minimal property: only youki + microVM are daemonless.
assert!(ContainerBackend::Youki.is_daemonless());
assert!(ContainerBackend::MicroVm.is_daemonless());
assert!(!ContainerBackend::Podman.is_daemonless(), "podman socket re-adds a userland");
assert!(!ContainerBackend::Containerd.is_daemonless(), "containerd re-adds a daemon");
}
#[test]
fn declared_appliance_boot_param_is_not_double_stamped() {
// A cell that already declares korp.appliance= keeps its own — no duplicate.
let mut b = korp();
b.boot_params = vec!["console=ttyS0".into(), "korp.appliance=fat".into()];
b.container_backend = Some(ContainerBackend::Youki);
let params = b.effective_boot_params();
assert_eq!(
params.iter().filter(|p| p.starts_with("korp.appliance=")).count(),
1,
"the appliance selector is stamped exactly once: {params:?}"
);
}
#[test]
fn installer_from_toml() {
let toml = r#"
name = "korp-installer"
installer_variant = "korp-installer-pid1"
target_disk_gib = 32
gpu = true
"#;
let b: InstallerBuild = toml::from_str(toml).unwrap();
assert_eq!(b.name, "korp-installer");
assert_eq!(b.target_disk_gib, 32);
assert!(b.gpu);
assert_eq!(b.variant, "korp-appliance-lean", "defaulted variant");
assert_eq!(b.payload, PayloadKind::EmbeddedIso, "defaulted embedded payload");
}
/// **The donor is declarable, validated, and part of the artifact's identity.**
///
/// RED-FIRST 2026-08-26: written before `kernel_donor` existed. The seam it
/// closes was measured the same day: `dwarves buildthing gunnar --variant
/// gunnar-installer` composed a medium whose bzImage came from `/boot/vmlinuz`
/// — the build box's kernel, with no pin and no receipt — while tunnr's
/// declared, pinned `tunnr-kernel` sat built in `.kernel-out/` with nothing
/// pointing at it.
#[test]
fn a_kernel_donor_is_declarable_and_rides_the_content_id() {
let toml = r#"
name = "gunnar-installer"
variant = "gunnar-server"
[kernel_donor]
repo = "tunnr"
kernel = "tunnr-kernel"
"#;
let b: InstallerBuild = toml::from_str(toml).unwrap();
let d = b.kernel_donor.as_ref().expect("the declared donor is read, not dropped");
assert_eq!(d.repo, "tunnr");
assert_eq!(d.kernel, "tunnr-kernel");
b.validate().expect("a fully named donor validates");
// Identity: a medium that boots a CHOSEN kernel is not the medium that
// boots whatever the host had.
let mut plain = b.clone();
plain.kernel_donor = None;
assert_ne!(
b.content_id(),
plain.content_id(),
"the donor changes what boots, so it must change the content id"
);
// …and a donor-less cell keeps the id it always had (no reformat tax).
assert_eq!(plain.content_id(), plain.clone().content_id());
}
/// A half-named donor resolves nothing and is refused at declaration time,
/// naming the missing half — not at compose time as a bogus "no such checkout".
#[test]
fn a_half_named_kernel_donor_is_refused_by_name() {
let mut b = holgerish();
b.kernel_donor = Some(KernelDonor { repo: String::new(), kernel: "k".into() });
let e = format!("{:#}", b.validate().expect_err("empty repo half"));
assert!(e.contains("kernel_donor.repo"), "{e}");
b.kernel_donor = Some(KernelDonor { repo: "tunnr".into(), kernel: " ".into() });
let e = format!("{:#}", b.validate().expect_err("empty kernel half"));
assert!(e.contains("kernel_donor.kernel"), "{e}");
}
/// **A `.vhd` target disk is attached as `format=vpc`, not `raw`.**
///
/// The regression this closes: the format was sniffed from the extension
/// with a two-way `if`, so every medium the `if` did not name became `raw`.
/// A VHD attached that way shows the firmware a `conectix` footer where a
/// partition table should be, and OVMF finds nothing bootable — a green
/// writer and a dead boot.
///
/// RED-when-broken: put the old `if disk_path.ends_with(".qcow2")` back and
/// the vhd arm reads `format=raw`.
#[test]
fn a_target_disk_is_attached_with_the_format_its_medium_declares() {
let b = korp();
let fmt_of = |path: &str| {
let a = b.kvm_disk_args(path);
let i = a.iter().position(|x| x == "-drive").expect("a drive");
a[i + 1].clone()
};
assert!(fmt_of("/t/d.vhd").ends_with("format=vpc"), "{}", fmt_of("/t/d.vhd"));
assert!(fmt_of("/t/d.qcow2").ends_with("format=qcow2"), "{}", fmt_of("/t/d.qcow2"));
assert!(fmt_of("/t/d.raw").ends_with("format=raw"), "{}", fmt_of("/t/d.raw"));
assert!(fmt_of("/t/d.img").ends_with("format=raw"), "{}", fmt_of("/t/d.img"));
// A blank disk a harness just made has no format to detect, and `raw`
// is the honest default for it — the case this seam is usually handed.
assert!(fmt_of("/t/target").ends_with("format=raw"), "{}", fmt_of("/t/target"));
assert!(fmt_of("/t/d.VHD").ends_with("format=vpc"), "case-insensitive");
}
/// **A row that names its machine gets its cloud's container; one that
/// hand-spells the same fields does not.**
///
/// This is the whole of the v1.3.0 wrapper defect, in a test. The shape is
/// not the identity, and the reason is concrete rather than pedantic:
/// `ovh-public-cloud` and `gce-virtio` present the identical device set and
/// import different containers, so no reverse lookup from `buses`/`nic`
/// could ever answer.
#[test]
fn a_row_that_names_its_machine_yields_its_clouds_container() {
use crate::image_writer::CloudMedium;
use crate::target::presets;
use crate::target::Target;
// Named → derived.
let mut named = korp();
named.target = Target { preset: Some("azure-gen2".into()), ..Target::default() };
assert_eq!(named.cloud_medium(), Some(CloudMedium::Vhd), "Azure Gen2 imports a fixed VHD");
assert_eq!(named.cloud_medium_gap(), None);
let mut aws = korp();
aws.target = Target { preset: Some("aws-nitro".into()), ..Target::default() };
assert_eq!(aws.cloud_medium(), Some(CloudMedium::Raw), "Nitro imports raw via import-snapshot");
// The rows a reverse lookup could never separate. FOUR presets carry
// one device set and split two-and-two on the container, which is the
// whole argument for naming the machine rather than deriving it.
let same_devices = ["upcloud", "hetzner-cloud", "ovh-public-cloud", "scaleway-instance"]
.map(|n| presets::preset(n).unwrap());
for p in &same_devices[1..] {
assert_eq!(p.boot_disk.as_str(), same_devices[0].boot_disk.as_str(), "{} shares the bus", p.name);
assert_eq!(p.nic.as_str(), same_devices[0].nic.as_str(), "{} shares the NIC", p.name);
}
let containers: std::collections::BTreeSet<_> =
same_devices.iter().map(|p| CloudMedium::for_preset(p)).collect();
assert!(
containers.len() >= 2,
"one device set, {} container(s) — if this ever became 1 the shape COULD decide and this \
derivation could be relaxed: {containers:?}",
containers.len()
);
}
/// **An imported-disk medium that names no machine is refused BY NAME**,
/// rather than quietly producing only the `.iso` — which is what shipped.
#[test]
fn an_imported_disk_row_with_no_machine_named_says_why() {
use crate::target::Target;
let mut row = korp();
row.medium_boot = MediumBoot::Gpt;
row.target = Target::default(); // no preset named
let why = row.cloud_medium_gap().expect("a gpt medium with no machine cannot be contained");
assert!(why.contains("names no machine"), "{why}");
assert!(why.contains("target.preset"), "names the field to fix: {why}");
assert!(why.contains("ovh-public-cloud"), "gives the reason, not just the rule: {why}");
// An el-torito row is an ISO-attach machine and needs no container.
let mut iso = korp();
iso.medium_boot = MediumBoot::ElTorito;
iso.target = Target::default();
assert_eq!(iso.cloud_medium_gap(), None, "an ISO-attach row owes no import container");
// A machine that is not in the roster is a typo, and says so.
let mut typo = korp();
typo.target = Target { preset: Some("azure-gen3".into()), ..Target::default() };
let why = typo.cloud_medium_gap().expect("not a machine");
assert!(why.contains("not in the roster"), "{why}");
}
/// **A NAMED ISO-attach machine owes no import container** — the arm this
/// function's own doc comment and `korp-installer`'s composer both already
/// promised, and the code did not have.
///
/// RED until 2026-09-07: [`InstallerBuild::cloud_medium_gap`] answered
/// *"names machine `upcloud`, whose medium arrives as an ISO attached as an
/// IDE cdrom — no writer in `image_writer` produces that container"* for a
/// machine that TAKES an attached ISO. Its own doc said `None` means the row
/// "can (or that it is an ISO-attach machine and does not need one)", and
/// `isocompose.rs` said "a cell that names no machine, **or names an
/// ISO-attach one**, gets exactly what it got before". So naming the
/// machine would not have silenced the sentence the composer printed — it
/// would have swapped one gap for another, and the second one is not a gap
/// at all: `crate::iso_writer` already produced the container that platform
/// imports, and it is the `.iso` itself.
///
/// The negative controls are the point of the arm being keyed on
/// [`MediumBring::is_iso_attach`] and nothing broader: a machine whose
/// platform imports a disk still owes one, whether a writer exists
/// (`scaleway-instance` → qcow2, no gap) or no writer can exist
/// (`scaleway-elastic-metal` → bare metal, gap BY NAME).
#[test]
fn a_named_iso_attach_machine_owes_no_import_container() {
use crate::image_writer::CloudMedium;
use crate::target::presets;
use crate::target::Target;
// The two ISO-attach clouds in the roster, on a medium that also boots
// off an imported disk — the exact shape of gunnar-installer-metered.
for machine in ["upcloud", "hetzner-cloud"] {
assert!(presets::preset(machine).unwrap().medium.is_iso_attach());
let mut row = korp();
row.medium_boot = MediumBoot::Hybrid;
row.target = Target { preset: Some(machine.into()), ..Target::default() };
assert_eq!(row.cloud_medium(), None, "{machine}: an attached ISO is `iso_writer`'s artifact");
assert_eq!(
row.cloud_medium_gap(),
None,
"{machine} attaches the ISO this compose already wrote: there is nothing missing to report"
);
}
// An imported-disk machine whose writer EXISTS: a container, no gap.
let mut qcow = korp();
qcow.medium_boot = MediumBoot::Hybrid;
qcow.target = Target { preset: Some("scaleway-instance".into()), ..Target::default() };
assert_eq!(qcow.cloud_medium(), Some(CloudMedium::Qcow2));
assert_eq!(qcow.cloud_medium_gap(), None);
// And one that owes a container no writer can produce, still refused BY
// NAME — the v1.3.0 defect this whole seam was written for.
let mut metal = korp();
metal.medium_boot = MediumBoot::Hybrid;
metal.target = Target { preset: Some("scaleway-elastic-metal".into()), ..Target::default() };
let why = metal.cloud_medium_gap().expect("bare metal imports nothing this crate can write");
assert!(why.contains("scaleway-elastic-metal") && why.contains("no writer"), "{why}");
}
/// **Naming the machine is LOSSLESS for the rows gunnar ships.**
///
/// gunnar's `gunnar-installer-azure` hand-spells firmware/consoles/uarts/
/// buses/nic/nics/cpu, and every one of those values is what
/// `azure-gen2`'s own derivation produces. So the row is not choosing
/// something the roster disagrees with — it is repeating the roster in a
/// form that cannot be joined back to it. Swapping the seven fields for
/// `preset = "azure-gen2"` changes no byte of the shape and gains the
/// container.
#[test]
fn the_hand_spelled_azure_shape_is_exactly_the_presets_own() {
use crate::target::{Bus, Console, Cpu, Firmware, Nic, Target, UartPort};
let p = crate::target::presets::preset("azure-gen2").unwrap();
let derived = p.target().expect("azure-gen2 has a spellable target group");
// The shape gunnar's row spells out today, field for field.
let hand = Target {
preset: None,
firmware: Firmware::Uefi,
consoles: vec![Console::Vga, Console::Serial],
uarts: vec![UartPort::Com1],
buses: vec![Bus::HvStorvsc],
nic: Nic::HvNetvsc,
nics: 1,
cpu: Cpu::Generic,
};
assert_eq!(derived, hand, "the hand-spelled shape IS the preset's derivation");
}
}