installtest/src/iso.rs
//! The **ISO** arm: the OS-less appliance, booted off the media the ⚒ Build
//! Thing produced, and held to the same assertions as every other install
//! target.
//!
//! ```text
//! cargo test -p dwarves --features appliance-iso --test gunnar_appliance_iso \
//! -- --ignored --nocapture # builds .iso-out/gunnar-appliance-iso.iso
//! cargo xtask install-test iso # this arm, against that ISO
//! ```
//!
//! # This arm does not build an ISO, and that is the point
//!
//! `gunnar/.nornir/build-variants.toml` declares `gunnar-appliance-iso` as a
//! `kind = "iso"` cell whose payload is `gunnar-server` and whose boot chain is
//! korp's, unchanged. The file also records, at length, what happened the last
//! time something in this repository mastered its own ISO beside that
//! declaration: `cargo xtask iso` rolled its own cpio, its own FAT16 and its own
//! ISO-9660, it worked, and it was a twin of a boot chain edda already ships,
//! with no root of trust in it. Four Containerfiles and an `xtask images` driver
//! were deleted for the same reason.
//!
//! So this arm **consumes** the declared artefact. There is exactly one builder
//! of a gunnar appliance ISO and it is the forge; what was missing was not
//! another builder but a proof that reaches past "it booted and served three
//! objects".
//!
//! # What it adds over the forge's own e2e
//!
//! `dwarves/crates/dwarves/tests/gunnar_appliance_iso.rs` proves the forge cell
//! builds, boots under OVMF, and serves one commit. Everything below is the
//! shared half of this crate, written once, pointed at that ISO, so the appliance is
//! held to what a container and a KVM guest are held to:
//!
//! * [`Probe::push_then_clone`] — the 15-object fixture, two branches, the
//! object-graph comparison with its non-vacuity floor, `fsck --full --strict`
//! on what came back, and the host-key pin that says WHICH server answered;
//! * [`Probe::git_matrix`] — an annotated tag the server must be able to peel, a
//! branch created and deleted, a non-fast-forward push that must be REFUSED, a
//! `--force` that must go through, and a fetch that must really negotiate
//! (`rounds` read out of the appliance's own serial console);
//! * the **version stamp**, off the SSH banner. There is no shell in this guest,
//! so `gunnar --version` cannot be run in it; since `008d74b` the banner
//! carries the build reference instead (`gunnar_cli::ssh_server_id`), and this
//! arm refuses an appliance whose banner does not name this checkout's HEAD.
//! * the **power cut**, and what it costs. See [`restart`].
//!
//! # The subject here is a COMMIT, not a binary
//!
//! The container and KVM arms install the very binary [`Subject`] built — one
//! artefact, two install targets, so a difference between those two arms cannot
//! be a difference between two builds. This arm cannot do that and does not
//! pretend to: what it boots is the forge's `gunnar-server` variant, a different
//! artefact from a different build profile, packed inside the ISO. So the
//! `Subject` is used for the one thing that DOES carry across — the commit — and
//! two independent checks tie the medium to this checkout:
//!
//! * the ISO must postdate the newest `.rs` under `crates/`, decided by the same
//! `container::binary_staleness_secs` that guards a benchmark's binary (one
//! writer for the rule);
//! * the appliance's SSH banner must name this checkout's `HEAD`.
//!
//! The first alone would accept an ISO mastered a minute ago from another
//! branch; the second alone would accept one whose payload predates every source
//! change since. Neither is redundant.
//!
//! # Skidbladnir, draupnir, and what boots this
//!
//! The appliance has systemd **off** — gunnar is PID 1 — so there is no unit
//! inside the guest for Skidbladnir to own, and `service install` has nothing to
//! install into. What is managed here is the **guest**, and the constellation's
//! seam for that is **draupnir**, which drives tunnr's `boot_test` one layer
//! down.
//!
//! Until 2026-09-05 this arm called `tunnr_vm::boot_test` itself, for the reason
//! [`crate::kvm`]'s header records and measures: draupnir's KVM backend dropped
//! the host→guest port forward, and a forwarded port is the entire mechanism by
//! which a host `git` reaches the appliance. draupnir `60ffb06` closed that —
//! `ports`/`port_maps` are honoured on `Backend::Kvm` and fold onto tunnr's
//! `NetConfig::User { hostfwd }` — so the ISO's single forward is now
//! `BootSpec::iso_boot(..).with_port_map(host, 2222)` and there is one KVM road
//! in this crate instead of two.
//!
//! **The medium here is the payload, not an attachment.** `iso_boot` makes the
//! ISO the `ImageSource`, which tunnr itself renders `-cdrom … -boot d`;
//! draupnir adds only the boot order, deliberately, so there is never a second
//! `-cdrom` (a duplicate-drive error, not a merge). `MediumAttach::Both` — the
//! virtio face added in `519afcf` for a kernel with no ATA — belongs to the
//! *install* shape, where an ISO rides *alongside* a target disk. This
//! appliance boots the medium and installs nothing, and its firmware finds the
//! El Torito image on the IDE tray exactly as it does on metal.
//!
//! **The power cut is `Lifecycle::power_off`, deliberately.** That is the
//! immediate SIGKILL — no ACPI event, no flush — which is the whole subject of
//! [`restart`]. Its opposite, `KvmBoot::shutdown(grace)`, is what [`crate::kvm`]
//! uses and why: see that header's 2026-08-16 measurement. Two teardowns, two
//! calls, neither reached by accident.
//!
//! **One consequence of going through draupnir, stated because it is a real
//! edge:** `power_off` (and `shutdown`) *removes* the machine from `KvmBoot`'s
//! registry, so `KvmBoot::serial_log` answers `None` afterwards — a dead guest's
//! console cannot be read back through draupnir the way a cloned
//! `tunnr_vm::BootHandle` used to allow. Nothing here needs it: the live console
//! is read while the guest is up, and the mirror thread below leaves the last
//! capture on disk for a human. A future step that wants a post-mortem console
//! must take the file, not the handle.
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use draupnir::kvm::KvmBoot;
use draupnir::{BootSpec, Lifecycle, Machine, PowerState};
use crate::subject::Subject;
use crate::{ArmResult, Exchange, Fault, GitOp, Probe, Stages};
/// The artefact the `[[build.iso]]` cell writes, relative to the checkout.
pub const ISO_PATH: &str = ".iso-out/gunnar-appliance-iso.iso";
/// **The one command that masters a gunnar appliance ISO**, written here once
/// and quoted by every refusal that needs it.
///
/// It was in the "there is no ISO" message and not in the "the ISO is stale"
/// one, and the stale branch is the one a sweep actually hits: `.iso-out/` is
/// checked in nobody's build and every edit under `crates/` ages whatever is
/// there. A refusal that says *rebuild the cell* without naming the cell makes
/// the reader go and find this file.
///
/// # It must be runnable AS TYPED, and until 2026-08-14 it was not
///
/// What stood here was `cargo test -p dwarves --features appliance-iso --test
/// gunnar_appliance_iso -- --ignored --nocapture`. That cell is real — it lives
/// at `dwarves/crates/dwarves/tests/gunnar_appliance_iso.rs` — but `dwarves` is
/// **another repository**, so pasting the line where the refusal is read (a
/// gunnar checkout, or one of its worktrees) answers `error: package ID
/// specification 'dwarves' did not match any packages`. A refusal whose command
/// fails is a refusal the reader stops believing.
///
/// So the headline is the forge's own headless door, which resolves the
/// checkout itself and runs from anywhere: `/usr/local/bin/dwarves` is
/// installed, `gunnar` is looked up as `$NORDISK_ROOT/gunnar` then `../gunnar`,
/// and `.nornir/build-variants.toml` declares `gunnar-appliance-iso` under the
/// `MVP` suite (`ORIGINAL` until 2026-10-30). [`MASTER_RECIPE_BOOTED`] keeps the cargo cell for the case
/// that wants it, with the directory it needs stated.
pub const MASTER_RECIPE: &str = "dwarves buildthing gunnar --suite MVP --atom gunnar-appliance-iso";
/// The other door: the forge cell that masters the ISO **and then boots it**
/// under QEMU/OVMF and pushes and clones over it.
///
/// Slower and stricter than [`MASTER_RECIPE`] — it is the test that proves the
/// medium serves, not merely that it composed — and it must be run from the
/// dwarves checkout, which is why the `-C` is part of the string rather than an
/// instruction the reader has to infer.
pub const MASTER_RECIPE_BOOTED: &str =
"cd ~/git/dwarves && cargo test -p dwarves --features appliance-iso \
--test gunnar_appliance_iso -- --ignored --nocapture";
/// The environment variable that points this arm at an ISO somewhere else.
///
/// Named in the refusals because of **worktrees**: `.iso-out/` is not checked in
/// and is written into whichever checkout mastered it, so every secondary
/// worktree reports "no appliance ISO" while a perfectly good one sits in the
/// main checkout. Re-mastering per worktree is minutes of QEMU for a medium
/// that is already there.
pub const ISO_ENV: &str = "GUNNAR_INSTALLTEST_ISO";
/// Where the forge's bootstrap leaves the private key the appliance authorises.
pub const KEY_PATH: &str = ".iso-out/appliance-key/id_ed25519";
/// The repository the appliance ships, created by the bootstrap that arms the
/// cell.
pub const REPO: &str = "demo/hello";
/// gunnar's SSH port inside the appliance (its own default).
const GUEST_SSH_PORT: u16 = 2222;
/// The faults this arm can really inject.
///
/// [`Fault::EmptyRepo`] is **not** here and is not silently skipped either. It
/// needs a second, deliberately empty repository on the server, and the
/// appliance's initial state is baked into the ISO by the forge's bootstrap,
/// which creates only [`REPO`]. Running it anyway would produce a red for the
/// reason "that repository does not exist", which is a fake red: it would pass
/// the `must be Err` demand while proving nothing about the `MIN_OBJECTS` floor
/// it exists to exercise. The floor is exercised on the container and KVM arms,
/// against the same shared code.
pub const RED: &[Fault] = &[Fault::WrongPort, Fault::MissingBinary, Fault::KilledService];
/// The ISO the forge built.
pub fn iso_path() -> PathBuf {
std::env::var(ISO_ENV)
.map(PathBuf::from)
.unwrap_or_else(|_| crate::workspace_root().join(ISO_PATH))
}
/// **The private key that goes with THIS ISO** — beside it, not beside the
/// checkout.
///
/// The forge's bootstrap writes the ISO and the key it authorises in one run,
/// into one `.iso-out/`. Reading the key from [`crate::workspace_root`]
/// regardless of where the ISO came from made [`ISO_ENV`] a half-override: it
/// moved the medium and left the credential behind, so pointing a worktree at
/// the main checkout's ISO — the one thing that variable is for — failed on
/// "the client key it authorises is not there", which reads like a broken build
/// rather than like a path.
///
/// A key from a different build cannot authenticate against this ISO anyway, so
/// deriving it from the ISO's own directory is not merely more convenient, it is
/// the only pairing that can be right. `root` is the fallback for an ISO whose
/// parent is not an `.iso-out/` — the default location, where the two are
/// already siblings and this resolves to exactly what it always did.
fn key_path(iso: &Path, root: &Path) -> PathBuf {
let beside = iso.parent().map(|dir| dir.join("appliance-key/id_ed25519"));
match beside {
Some(k) if k.is_file() => k,
_ => root.join(KEY_PATH),
}
}
/// **The ISO arm.**
pub fn run(subject: &Subject, work: &Path, fault: Fault, keep: bool) -> ArmResult {
let mut ready = None;
let mut stages = Stages::default();
let outcome = run_inner(subject, work, fault, keep, &mut ready, &mut stages);
ArmResult {
arm: "iso (⚒ Build Thing · tunnr · QEMU/OVMF/KVM · no OS)",
fault,
outcome,
ready,
stages: stages.0,
demands_red: false,
}
}
fn run_inner(
subject: &Subject,
work: &Path,
fault: Fault,
keep: bool,
ready_out: &mut Option<Duration>,
stages: &mut Stages,
) -> Result<Exchange, String> {
if fault == Fault::EmptyRepo {
return Err(
"the ISO arm does not inject empty-repo; the appliance's baked state has no empty \
repository and a red for `no such repository` would prove nothing about the \
MIN_OBJECTS floor. See `iso::RED`."
.to_string(),
);
}
let root = crate::workspace_root();
let iso = iso_path();
let key = key_path(&iso, &root);
// `MissingBinary` on this arm is a missing MEDIUM: an ISO path that is not
// there. It proves the same thing the container arm's broken ENTRYPOINT
// proves — that the readiness wait reports the failure instead of burning
// its whole budget and shrugging.
let iso = if fault == Fault::MissingBinary {
work.join("there-is-no-such.iso")
} else {
iso
};
if fault != Fault::MissingBinary {
if !iso.is_file() {
return Err(format!(
"no appliance ISO at {}. It is built by the declared ⚒ Build Thing cell, not by \
this crate:\n {MASTER_RECIPE}\nor, to master it AND boot it:\n \
{MASTER_RECIPE_BOOTED}\n(Either run also writes the client key this arm \
authenticates with.)\n\
If you are in a WORKTREE this is expected and re-mastering is the wrong \
answer: `.iso-out/` is written into whichever checkout built it and is in no \
`git` tree, so point at the one that exists instead —\n \
{ISO_ENV}=/path/to/.iso-out/gunnar-appliance-iso.iso\n\
(the key this arm authenticates with is taken from `appliance-key/` BESIDE that \
ISO, so name the one the forge wrote rather than a copy).",
iso.display()
));
}
// The same decision `locate_binary_for_measurement` makes about a
// binary, applied to the medium: an ISO older than the newest `.rs`
// under `crates/` is a different appliance from the one this tree
// describes, and every number below would be a true measurement of it.
// One writer for the rule — `container::binary_staleness_secs`.
let delta = crate::container::binary_staleness_secs(&iso, &root).ok_or_else(|| {
format!(
"could not read a source mtime under {}/crates, so nothing says whether {} is \
the appliance this tree describes.",
root.display(),
iso.display()
)
})?;
if delta < 0 && std::env::var(crate::container::ALLOW_STALE).as_deref() != Ok("1") {
return Err(format!(
"{} was mastered {} minute(s) BEFORE the newest source under {}/crates, so \
booting it would measure an appliance this tree no longer describes.\n\
Master it again — this crate will not do it for you, and the reason is in \
this module's header: there is ONE builder of a gunnar appliance ISO and it \
is the forge cell:\n {MASTER_RECIPE}\nor, to master it AND boot it:\n \
{MASTER_RECIPE_BOOTED}\nor set {}=1 to state on the record \
that the subject is deliberately not this tree.\n\
Note for a SWEEP: this refusal fires for any ISO older than the newest \
`.rs`, so on a tree that is being edited it is expected rather than \
surprising, and it is still a refusal — a row timed off a ten-hour-old \
medium is a true number about the wrong appliance and nothing on the row \
would say so.",
iso.display(),
(-delta) / 60,
root.display(),
crate::container::ALLOW_STALE
));
}
if !key.is_file() {
return Err(format!(
"the appliance ISO is there and the client key it authorises is not ({}). The \
forge's bootstrap writes both; a key from a different build cannot \
authenticate.",
key.display()
));
}
}
let caps = tunnr_vm::host_capabilities();
let blockers = caps.blockers();
if !blockers.is_empty() {
return Err(format!("this host cannot boot a KVM guest: {blockers:?}"));
}
let _ = std::fs::remove_dir_all(work);
std::fs::create_dir_all(work).map_err(|e| format!("{}: {e}", work.display()))?;
// ── boot ────────────────────────────────────────────────────────────────
//
// `Fault::WrongPort` needs no special port here: `push_then_clone` takes its
// own unbound port for that fault, so this appliance comes up normally and
// the client is the thing pointed somewhere else — which is what makes the
// fault a statement about the harness talking to the instance it booted.
let git_port = crate::free_port()?;
let guest = stages.time("boot", || {
Appliance::boot(&iso, git_port, work, "boot", Duration::from_secs(180))
})?;
*ready_out = Some(guest.ready);
stages.record("boot_to_listening", guest.ready);
// ── the version stamp, over the wire ────────────────────────────────────
//
// There is no shell in this guest. The banner is what it says about itself.
assert_banner_names(&guest.banner, &subject.head)?;
// ── the exchange, and the whole git matrix ──────────────────────────────
let probe = Probe {
port: guest.port,
key: key.clone(),
work: work.to_path_buf(),
user: "git".to_string(),
};
// The appliance's log is its SERIAL CONSOLE, read live. It has no filesystem
// to write a log to and no shell to redirect one with, and a file mirrored on
// a timer is a log that is behind — which is exactly how this arm first
// reported `rounds=0` for a fetch that had really negotiated, so the mirror
// on disk is for a human and this reader goes to the source.
//
// Read through draupnir's registry, which means it answers only while the
// guest is alive (see the module header). Every use below is on the live
// guest: `git_matrix` runs on the arm where the appliance is still serving,
// and the `KilledService` fault returns from `push_then_clone` before it.
let (log_kvm, log_machine) = (guest.kvm.clone(), guest.machine.clone());
let console =
crate::ServerLog::live(move || log_kvm.serial_log(&log_machine).unwrap_or_default());
let guest = if fault == Fault::KilledService {
drop(guest);
None
} else {
Some(guest)
};
let exchange = probe.push_then_clone(REPO, fault)?;
stages.0.push(("push", exchange.push_ms));
stages.0.push(("clone", exchange.clone_ms));
let ops = stages.time("git_matrix", || probe.git_matrix(REPO, &console))?;
report_matrix(&ops);
// ── the power cut, and what the appliance keeps ─────────────────────────
if !keep {
// The cut itself, before anything else runs: `Appliance`'s `Drop` is a
// SIGKILL to QEMU with the server up, no ACPI event and no chance to
// flush. Booting the replacement while the first was still serving would
// have measured two appliances rather than one across a power cycle,
// which is a different and much weaker statement.
drop(guest);
stages.time("restart", || restart(&iso, work, &probe, &exchange))?;
} else if let Some(guest) = guest {
eprintln!(
" --keep: the appliance is still up — \
GIT_SSH_COMMAND='{}' git clone ssh://git@127.0.0.1:{}/{REPO}.git",
probe.ssh_command(),
guest.port
);
std::mem::forget(guest);
}
Ok(exchange)
}
/// The banner must name the commit this checkout is at.
///
/// RED-when-broken: build the cell from another commit and the banner still
/// starts `SSH-2.0-gunnar_0.1.0`, which is what it said for every build before
/// `008d74b` and is why this check reads the comments field rather than the
/// version.
fn assert_banner_names(banner: &str, head: &str) -> Result<(), String> {
if !banner.starts_with("SSH-2.0-gunnar") {
return Err(format!(
"the listener answered {banner:?}, which is not gunnar's own SSH server. Something \
else on this box is holding that port."
));
}
if !banner.contains(head) {
return Err(format!(
"the appliance's banner is {banner:?} and does not name this checkout's HEAD \
({head}). Either the ISO was mastered from another commit, or it was built \
before `gunnar_cli::ssh_server_id` put the build reference on the banner — in \
which case rebuild the cell, because an appliance with no shell has nothing else \
to say which build it is."
));
}
eprintln!(" banner: {banner} — names HEAD {head}");
Ok(())
}
fn report_matrix(ops: &[GitOp]) {
for op in ops {
eprintln!(" git {:<18} {} ({} ms)", op.name, op.detail, op.ms);
}
}
/// **The power cut.** SIGKILL to QEMU with the appliance serving, then boot the
/// same medium again.
///
/// What this proves, and — just as important — what it measures rather than
/// hides:
///
/// * the media boots a second time and serves again, from the same ISO;
/// * and the repositories pushed before the cut are **GONE**. That is not a bug
/// found here, it is the shape of the declared cell: `[[build.iso]]` grafts
/// the appliance's initial state into the *initramfs* (`staging =
/// ".iso-out/appliance-root"`, and the declaration says why: "removable media
/// has no installed data partition"). An initramfs is RAM. A live ISO
/// appliance is therefore **ephemeral by construction**, and the honest
/// statement is the measured one: the object graph after the cut is the baked
/// one, not the pushed one.
///
/// Asserting it this way round matters. "It serves again" alone is satisfied by
/// an appliance that lost everything, and the earlier prior art recorded exactly
/// that number — *"serving again 3.7 s after SIGKILL"* — without saying what it
/// was serving. If a future ISO gains a persistent data partition, this check
/// fires, and that is the right thing for it to do.
fn restart(iso: &Path, work: &Path, probe: &Probe, before: &Exchange) -> Result<(), String> {
let port = crate::free_port()?;
let again = Appliance::boot(iso, port, work, "restart", Duration::from_secs(180))?;
eprintln!(
" power cut: it booted again off the same ISO and was listening in {} ms",
again.ready.as_millis()
);
// ── 1. what does it hold now? ───────────────────────────────────────────
//
// `ls-remote` is the applied output: the ref advertisement, produced from
// the store the appliance actually has. It EXITING ZERO is itself an
// assertion — an unknown repository and an unauthorised key are both errors,
// so a successful listing proves the baked namespace, the baked repository
// and the baked authorised key all came back with the medium.
let restarted = Probe {
port: again.port,
key: probe.key.clone(),
work: work.join("after-restart"),
user: probe.user.clone(),
};
let _ = std::fs::remove_dir_all(&restarted.work);
std::fs::create_dir_all(&restarted.work).map_err(|e| format!("{}: {e}", work.display()))?;
let ssh = restarted.ssh_command();
let url = restarted.url(REPO, again.port);
let refs = crate::git(&restarted.work, &ssh, &["ls-remote", &url]).map_err(|e| {
format!(
"the appliance came back up and {REPO} is gone with it — it does not answer \
ls-remote at all: {e}. That is a lost IMAGE, not a lost push, and it is worse."
)
})?;
let tips: Vec<&str> = refs.lines().filter(|l| !l.trim().is_empty()).collect();
if tips.iter().any(|l| l.starts_with(&before.head)) {
return Err(format!(
"refs/heads/main is still at {} after a power cut. This ISO's data root is the \
initramfs, i.e. RAM, so nothing pushed to it can survive — a graph that DID \
survive means the appliance gained a persistent store and this arm's account of \
it is out of date. Rewrite `iso::restart`, do not relax it.",
&before.head[..12]
));
}
eprintln!(
" power cut: {REPO} is back, empty as it was baked ({} ref(s)); the {} objects \
pushed before the cut are GONE — the data root is the initramfs, which is RAM",
tips.len(),
before.objects
);
// ── 2. and it is not just UP, it still WORKS ────────────────────────────
//
// "It answered ls-remote" is a listener and a store open for reading. The
// claim being made is that this medium boots a working git server every
// time, so the second boot is held to the same exchange as the first: the
// whole fixture pushed, cloned back, object graphs compared, fsck'd, and the
// host key pinned. A boot that comes up read-only, or with a store it cannot
// write, passes step 1 and fails here.
let after = restarted.push_then_clone(REPO, Fault::None)?;
if after.objects != before.objects {
return Err(format!(
"the boot after the power cut served a {}-object exchange where the first served \
{}. Same fixture, same medium: the two boots do not agree.",
after.objects, before.objects
));
}
eprintln!(
" power cut: and it serves again — {} objects pushed and cloned back, answered by {}",
after.objects, after.host_key
);
drop(again);
Ok(())
}
// ── the guest ───────────────────────────────────────────────────────────────
/// A booted appliance: the forwarded port, the banner it sent, and a live mirror
/// of its serial console on disk.
struct Appliance {
/// The backend that booted it, and the machine it booted. `KvmBoot` is a
/// cheap `Arc` clone whose registry maps the machine back to the QEMU
/// process group, so the console and the power cut both go through it.
kvm: KvmBoot,
machine: Machine,
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
port: u16,
banner: String,
ready: Duration,
/// The serial console, mirrored to a file **for a human**. The assertions
/// read the console live off [`Appliance::handle`]; this copy is what is left
/// on disk to read after a failure.
#[allow(dead_code)]
console: PathBuf,
}
impl Appliance {
fn boot(
iso: &Path,
host_port: u16,
work: &Path,
name: &str,
budget: Duration,
) -> Result<Appliance, String> {
// `iso_boot` makes the ISO the payload: draupnir hands tunnr an EMPTY
// kernel path, so OVMF firmware-boots it exactly as it would on metal,
// and tunnr's own `is_iso` arm renders `-cdrom … -boot d`. draupnir adds
// the boot order and NOT a second `-cdrom`, which would be a QEMU
// duplicate-drive error rather than a merge.
//
// The single forward is the whole reason a host `git` can reach the
// appliance at all; `draupnir::boot` validates it (a forward with no NIC
// is refused by name) before any QEMU is launched.
let mut spec =
BootSpec::iso_boot(format!("gunnar-appliance-{name}"), iso.to_string_lossy())
.with_port_map(host_port, GUEST_SSH_PORT)
.with_boot_timeout(budget + Duration::from_secs(120));
spec.mem_mb = 2048;
spec.cores = 2;
// No COW guard is set here and none is wanted: an ISO payload is a
// read-only `-cdrom`, so there is no base disk for one to protect.
// (`ImageSource::Iso` maps to `base_snapshot = false` in draupnir's
// adapter, which is what this arm always asked for.)
let kvm = KvmBoot::new();
let machine = draupnir::boot(&spec, &kvm).map_err(|e| format!("draupnir kvm boot: {e}"))?;
let serial = {
let (k, m) = (kvm.clone(), machine.clone());
move || k.serial_log(&m).unwrap_or_default()
};
// Mirror the console to a file while the guest runs. `git_matrix` takes a
// PATH and re-reads it between steps, and the only log this appliance has
// is its serial line.
let console = work.join(format!("{name}-console.log"));
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
{
let serial = serial.clone();
let path = console.clone();
let stop = stop.clone();
std::thread::spawn(move || {
while !stop.load(std::sync::atomic::Ordering::Relaxed) {
let _ = std::fs::write(&path, serial());
std::thread::sleep(Duration::from_millis(150));
}
let _ = std::fs::write(&path, serial());
});
}
let started = Instant::now();
let deadline = started + budget;
let mut last;
let banner = loop {
match kvm.status(&machine) {
Ok(PowerState::On) => {}
other => {
stop.store(true, std::sync::atomic::Ordering::Relaxed);
return Err(format!(
"the appliance died before it served: {other:?}{}\n── serial ──\n{}",
kvm.exit_reason(&machine)
.map(|r| format!(" ({r})"))
.unwrap_or_default(),
tail(&serial(), 40)
));
}
}
match banner_of(host_port) {
Ok(b) => break b,
Err(e) => last = e,
}
if Instant::now() >= deadline {
stop.store(true, std::sync::atomic::Ordering::Relaxed);
return Err(format!(
"no SSH banner on 127.0.0.1:{host_port} within {budget:?}; last: {last}\n\
── serial ──\n{}",
tail(&serial(), 40)
));
}
std::thread::sleep(Duration::from_millis(200));
};
let ready = started.elapsed();
// The boot CHAIN, not just the endpoint. A listener reached some other
// way would pass the banner check and fail here.
let log = serial();
for marker in ["KORP-BOOT: HANDOFF -> kernel", "start-app root-of-trust"] {
if !log.contains(marker) {
stop.store(true, std::sync::atomic::Ordering::Relaxed);
return Err(format!(
"the appliance answered SSH but its console never showed {marker:?}, so it \
did not come up through the korp first-stage + root-of-trust chain.\n\
── serial ──\n{}",
tail(&log, 60)
));
}
}
Ok(Appliance {
kvm,
machine,
stop,
port: host_port,
banner,
ready,
console,
})
}
}
impl Drop for Appliance {
/// SIGKILL, deliberately: this is a **power cut**, not a shutdown. The guest
/// gets no ACPI event and no chance to flush, which is the only interesting
/// way for an appliance to go away. `Lifecycle::power_off` is draupnir's
/// immediate kill — the opposite number of the `KvmBoot::shutdown(grace)`
/// that [`crate::kvm`] uses, and the choice between them is the whole
/// difference between "the appliance was cut off" and "the appliance was
/// asked to stop".
fn drop(&mut self) {
self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
let _ = self.kvm.power_off(&self.machine);
}
}
/// Open a connection and read the peer's SSH identification string.
///
/// A TCP connect is **not** readiness: QEMU's user-mode `hostfwd` accepts on the
/// host side the instant the VM starts, before the firmware has run. Only an SSH
/// implementation produces `SSH-2.0-…`.
fn banner_of(port: u16) -> Result<String, String> {
use std::io::Read as _;
use std::net::{Shutdown, SocketAddr, TcpStream};
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
let mut s = TcpStream::connect_timeout(&addr, Duration::from_millis(500))
.map_err(|e| format!("connect: {e}"))?;
s.set_read_timeout(Some(Duration::from_millis(1500)))
.map_err(|e| format!("set_read_timeout: {e}"))?;
let mut buf = [0u8; 256];
let n = s.read(&mut buf).map_err(|e| format!("read: {e}"))?;
let _ = s.shutdown(Shutdown::Both);
if n == 0 {
return Err("the peer accepted and sent nothing — that is QEMU's forwarder".into());
}
Ok(String::from_utf8_lossy(&buf[..n]).trim_end().to_string())
}
fn tail(text: &str, n: usize) -> String {
let lines: Vec<&str> = text.lines().collect();
lines[lines.len().saturating_sub(n)..].join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
/// The banner check must be able to say NO — in both directions, because a
/// function that refused everything would satisfy the negative alone.
#[test]
fn the_banner_check_names_the_commit_and_refuses_one_that_does_not() {
assert!(assert_banner_names("SSH-2.0-gunnar_0.1.0 v1.0-4-g008d74b", "008d74b").is_ok());
// The exact string every appliance sent before `008d74b`: right server,
// no identity. It must NOT pass.
let err = assert_banner_names("SSH-2.0-gunnar_0.1.0", "008d74b")
.expect_err("a banner with no build reference names no commit");
assert!(err.contains("008d74b"), "{err}");
// A different build.
assert!(assert_banner_names("SSH-2.0-gunnar_0.1.0 v1.0-3-g433a659", "008d74b").is_err());
// Something else answering the port entirely.
let err = assert_banner_names("SSH-2.0-OpenSSH_9.9p2 Debian-1", "008d74b")
.expect_err("openssh is not gunnar");
assert!(err.contains("not gunnar's own SSH server"), "{err}");
}
}