Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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}");
    }
}