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

nordisk/nornir

src/viz/test_boot.rs

//! **TEST-BOOT** — spin a *built* target up as a live nornir **job** from the
//! Build tab, in one of three modes, with a bidirectional job↔process lifecycle.
//!
//! Pressing a target's **test-boot** button creates a nornir job
//! ([`crate::jobs`]) and a live in-memory handle. The job tracks the boot's
//! status ([`BootStatus`]) and log, and the two directions of the lifecycle are
//! wired:
//!
//! - **kill the job → kill the boot**: cancelling a boot job SIGKILLs the child
//!   process-*group* ([`BootMode::Exe`]), stops the container
//!   ([`BootMode::Container`]), or calls the VM boot handle's `kill`
//!   ([`BootMode::Vm`]);
//! - **the boot dies → the job terminates**: if the process/container/VM exits on
//!   its own, the next [`TestBootState::poll`] flips the job to
//!   `BootedOk`/`Failed`/`Killed`.
//!
//! The three modes (see [`BootMode`]):
//! 1. [`Exe`](BootMode::Exe) — a plain cargo-variant binary: spawn the built exe
//!    as a child with an interactive console/PROMPT (stdin→child, stdout/stderr→UI
//!    log). Cheapest; no container/VM. **Implemented here** ([`ExeBootRunner`]).
//! 2. [`Container`](BootMode::Container) — a [`ContainerBuild`] image brought up
//!    over the **pure-Rust podman/Docker REST API** THROUGH draupnir's ONE OCI
//!    engine ([`DraupnirContainerBootRunner`], feature `test-boot-container-bollard`)
//!    — the zero-shell default — or, as a fallback when the API socket is absent,
//!    the podman-subprocess [`ContainerBootRunner`].
//! 3. [`Vm`](BootMode::Vm) — a [`VmBuild`] booted as a KVM guest via tunnr's boot
//!    primitive. nornir owns the [`BootSpec`] mapping + the [`VmBootRunner`] seam;
//!    the real boot lands once `tunnr_vm::boot_test` is committed (see
//!    [`StubVmBootRunner`]).
//!
//! The side-work is behind the [`BootRunner`] / [`BootProc`] seams so tests inject
//! a [`FakeBootRunner`] and drive the WHOLE lifecycle (spawn → running → death /
//! kill → terminal job) with no real process, container, or KVM in CI.

use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::{Arc, Mutex};

use nornir_build_thing::{ContainerBuild, VmBuild};

use crate::jobs::{JobHandle, JobSink, kind};

// ---------------------------------------------------------------------------
// Mode + status
// ---------------------------------------------------------------------------

/// Which of the three test-boot modes a built target uses. Selected purely from
/// the target's *type* ([`BootTarget::mode`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BootMode {
    /// A plain cargo-variant binary — spawn it as a child process + console.
    Exe,
    /// A `ContainerBuild` image — bring it up via podman/bollard.
    Container,
    /// A `VmBuild` — boot it as a KVM guest via tunnr.
    Vm,
}

impl BootMode {
    /// Stable label.
    pub fn as_str(&self) -> &'static str {
        match self {
            BootMode::Exe => "exe",
            BootMode::Container => "container",
            BootMode::Vm => "vm",
        }
    }

    /// The [`crate::jobs::kind`] a boot in this mode records.
    pub fn job_kind(&self) -> &'static str {
        match self {
            BootMode::Exe => kind::BOOT_EXE,
            BootMode::Container => kind::BOOT_CONTAINER,
            BootMode::Vm => kind::BOOT_VM,
        }
    }
}

/// Live status of a boot job. The lifecycle is
/// `Booting → Running → (BootedOk | Failed | Killed)`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BootStatus {
    /// Spawn issued; the target is coming up.
    Booting,
    /// The target is up and running (child alive / container up / VM booted).
    Running,
    /// The target booted and then exited cleanly (exit 0).
    BootedOk,
    /// The target failed to boot or exited non-zero. Carries a reason.
    Failed(String),
    /// The boot was killed (via the job) — or died by signal after a kill.
    Killed,
}

impl BootStatus {
    /// Whether the boot has reached a terminal state.
    pub fn is_terminal(&self) -> bool {
        matches!(
            self,
            BootStatus::BootedOk | BootStatus::Failed(_) | BootStatus::Killed
        )
    }

    /// Stable label for the UI / state_json.
    pub fn as_str(&self) -> &'static str {
        match self {
            BootStatus::Booting => "booting",
            BootStatus::Running => "running",
            BootStatus::BootedOk => "booted_ok",
            BootStatus::Failed(_) => "failed",
            BootStatus::Killed => "killed",
        }
    }
}

// ---------------------------------------------------------------------------
// Target + BootSpec (VmBuild → tunnr mapping)
// ---------------------------------------------------------------------------

/// A bootable target chosen from the build surface — the input to a test-boot.
/// Its *variant* fixes the [`BootMode`] (mode selection = target type → mode).
#[derive(Debug, Clone)]
pub enum BootTarget {
    /// A built cargo-variant binary: the exe path + args to run it with.
    Exe {
        /// The variant name (the job target label).
        name: String,
        /// The built executable to spawn.
        exe: PathBuf,
        /// Args passed to the exe.
        args: Vec<String>,
    },
    /// A container image target.
    Container(ContainerBuild),
    /// A VM target.
    Vm(VmBuild),
}

impl BootTarget {
    /// The human name of the target (the job's `target` label).
    pub fn name(&self) -> &str {
        match self {
            BootTarget::Exe { name, .. } => name,
            BootTarget::Container(c) => &c.name,
            BootTarget::Vm(v) => &v.name,
        }
    }

    /// **Mode selection** — the boot mode is fixed by the target's type.
    pub fn mode(&self) -> BootMode {
        match self {
            BootTarget::Exe { .. } => BootMode::Exe,
            BootTarget::Container(_) => BootMode::Container,
            BootTarget::Vm(_) => BootMode::Vm,
        }
    }
}

/// nornir's mapping of a [`VmBuild`] onto the boot primitive's spec — the shape we
/// hand to tunnr's boot primitive. Field-for-field it mirrors the `tunnr_vm`
/// `BootSpec` we will consume once `tunnr_vm::boot_test` lands (see
/// [`VmBootRunner`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BootSpec {
    /// The VM handle / guest name.
    pub name: String,
    /// Kernel image path (`-kernel`).
    pub kernel: String,
    /// Rootfs/initramfs path (`-initrd`), or empty for a disk boot.
    pub rootfs: String,
    /// Disk image path (`-drive`), or empty for a kernel+rootfs boot.
    pub disk: String,
    /// Guest memory (MiB).
    pub mem_mb: u32,
    /// Guest vCPU count.
    pub cores: u32,
    /// Kernel command line (`-append`).
    pub cmdline: String,
    /// Boot flavor (`qemu` / `microvm` / `appliance`).
    pub format: String,
    /// The materialized QEMU argv (the fallback boot recipe when tunnr is absent).
    pub qemu_args: Vec<String>,
}

impl BootSpec {
    /// Map a nornir [`VmBuild`] onto a [`BootSpec`]. This is the "nornir `VmBuild`
    /// → tunnr `BootSpec`" translation the Vm mode hands to the boot primitive.
    pub fn from_vm(vm: &VmBuild) -> Self {
        BootSpec {
            name: vm.name.clone(),
            kernel: vm.kernel.clone(),
            rootfs: vm.rootfs.clone(),
            disk: vm.disk.clone(),
            mem_mb: vm.mem_mb,
            cores: vm.cores,
            cmdline: vm.cmdline(),
            format: vm.format.as_str().to_string(),
            qemu_args: vm.qemu_args(),
        }
    }
}

// ---------------------------------------------------------------------------
// The runner / proc seams
// ---------------------------------------------------------------------------

/// A live boot's process handle — the in-memory side of a boot job. One per boot.
/// The bidirectional lifecycle rides on this trait: [`poll`](Self::poll) reports
/// death, [`kill`](Self::kill) tears the boot down.
pub trait BootProc: Send {
    /// The current status. MUST transition to a terminal state the moment the
    /// underlying process/container/VM dies on its own — that is the "boot dies →
    /// job terminates" direction.
    fn poll(&mut self) -> BootStatus;

    /// Kill the underlying process-group / container / VM — the "kill the job →
    /// kill the boot" direction. Idempotent.
    fn kill(&mut self);

    /// New log lines since the last drain (child stdout/stderr, serial console).
    fn drain_log(&mut self) -> Vec<String>;

    /// Send a line to the boot's stdin (the [`BootMode::Exe`] console PROMPT).
    /// Default no-op (containers/VMs have no interactive stdin here).
    fn send_stdin(&mut self, _line: &str) {}
}

/// The seam that spawns a boot for a [`BootTarget`]. The production dispatcher is
/// [`DispatchBootRunner`]; tests inject a [`FakeBootRunner`].
pub trait BootRunner: Send + Sync {
    /// Spawn the target; `Ok` carries the live [`BootProc`], `Err` a human reason
    /// (a spawn failure — the boot never started).
    fn spawn(&self, target: &BootTarget) -> Result<Box<dyn BootProc>, String>;
}

// ---------------------------------------------------------------------------
// Exe mode — spawn the built binary as a child + console (REAL).
// ---------------------------------------------------------------------------

/// A shared, append-only log buffer the reader threads push into and the UI drains.
type LogBuf = Arc<Mutex<Vec<String>>>;

/// A live child process (Exe mode): the child, its stdin, a shared log buffer fed
/// by reader threads, and the "killed by us" flag that distinguishes an operator
/// kill from a natural death.
struct ExeProc {
    child: Child,
    stdin: Option<ChildStdin>,
    pid: u32,
    log: LogBuf,
    killed: bool,
    /// An optional engine teardown command run on `kill` BEFORE the process-group
    /// SIGKILL (e.g. `podman kill <name>`) — so a Container boot stops the actual
    /// container, not just the attached client. Best-effort (errors ignored).
    stop_cmd: Option<Vec<String>>,
    /// Cached terminal status once reached (so `poll` is stable + cheap after).
    terminal: Option<BootStatus>,
}

impl ExeProc {
    /// Reap-safe status read. Checks `try_wait`; maps exit → terminal status.
    fn compute(&mut self) -> BootStatus {
        if let Some(t) = &self.terminal {
            return t.clone();
        }
        match self.child.try_wait() {
            Ok(Some(status)) => {
                let t = if self.killed {
                    BootStatus::Killed
                } else if status.success() {
                    BootStatus::BootedOk
                } else {
                    BootStatus::Failed(format!("exited {status}"))
                };
                self.terminal = Some(t.clone());
                t
            }
            Ok(None) => BootStatus::Running,
            Err(e) => {
                let t = BootStatus::Failed(format!("wait failed: {e}"));
                self.terminal = Some(t.clone());
                t
            }
        }
    }
}

impl BootProc for ExeProc {
    fn poll(&mut self) -> BootStatus {
        self.compute()
    }

    fn kill(&mut self) {
        if self.terminal.is_some() {
            return;
        }
        // Engine teardown first (stop the actual container, if any), best-effort.
        if let Some(cmd) = self.stop_cmd.take() {
            if let Some((prog, args)) = cmd.split_first() {
                let _ = Command::new(prog)
                    .args(args)
                    .stdout(Stdio::null())
                    .stderr(Stdio::null())
                    .status();
            }
        }
        // Drop stdin so the child sees EOF, then SIGKILL the whole process GROUP
        // (the child was spawned in its own group = its pid), so any grandchildren
        // die too — not just the direct child.
        self.stdin = None;
        unsafe {
            libc::kill(-(self.pid as i32), libc::SIGKILL);
        }
        // Reap so no zombie lingers; SIGKILL makes this prompt.
        let _ = self.child.wait();
        self.killed = true;
        self.terminal = Some(BootStatus::Killed);
    }

    fn drain_log(&mut self) -> Vec<String> {
        let mut g = self.log.lock().unwrap();
        std::mem::take(&mut *g)
    }

    fn send_stdin(&mut self, line: &str) {
        if let Some(stdin) = self.stdin.as_mut() {
            let _ = stdin.write_all(line.as_bytes());
            let _ = stdin.write_all(b"\n");
            let _ = stdin.flush();
        }
    }
}

/// The Exe boot runner: spawns the built binary as a child in its OWN process
/// group (so a job-kill SIGKILLs the whole group), pipes stdin for the console
/// PROMPT, and streams stdout/stderr into the job log via reader threads.
pub struct ExeBootRunner;

impl ExeBootRunner {
    /// Spawn `program` with `args`, returning the live [`ExeProc`]. `stop_cmd` is an
    /// optional engine teardown run on `kill` (used by the container path). Shared
    /// by the Exe and Container subprocess paths.
    fn spawn_process(
        program: &std::ffi::OsStr,
        args: &[String],
        stop_cmd: Option<Vec<String>>,
    ) -> Result<Box<dyn BootProc>, String> {
        use std::os::unix::process::CommandExt;

        let mut cmd = Command::new(program);
        cmd.args(args)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        // Own process group (pgid = child pid) so `kill(-pgid, SIGKILL)` reaps the
        // whole tree on a job-kill.
        cmd.process_group(0);

        let mut child = cmd
            .spawn()
            .map_err(|e| format!("spawn `{}`: {e}", program.to_string_lossy()))?;
        let pid = child.id();

        let log: LogBuf = Arc::new(Mutex::new(Vec::new()));
        if let Some(out) = child.stdout.take() {
            spawn_reader(out, "", Arc::clone(&log));
        }
        if let Some(err) = child.stderr.take() {
            spawn_reader(err, "err: ", Arc::clone(&log));
        }
        let stdin = child.stdin.take();

        Ok(Box::new(ExeProc {
            child,
            stdin,
            pid,
            log,
            killed: false,
            stop_cmd,
            terminal: None,
        }))
    }
}

impl BootRunner for ExeBootRunner {
    fn spawn(&self, target: &BootTarget) -> Result<Box<dyn BootProc>, String> {
        match target {
            BootTarget::Exe { exe, args, .. } => Self::spawn_process(exe.as_os_str(), args, None),
            other => Err(format!(
                "ExeBootRunner cannot boot a {} target",
                other.mode().as_str()
            )),
        }
    }
}

// ---------------------------------------------------------------------------
// Container mode — bring the built image up (podman) as a boot job.
// ---------------------------------------------------------------------------

/// The container engine used for a [`BootMode::Container`] boot (`podman` by
/// default; override with `NORNIR_CONTAINER_ENGINE`).
fn container_engine() -> String {
    std::env::var("NORNIR_CONTAINER_ENGINE").unwrap_or_else(|_| "podman".to_string())
}

/// The **fallback** Container boot runner: brings a built [`ContainerBuild`] image
/// UP as a live container by spawning the engine CLI, used ONLY when the zero-shell
/// [`DraupnirContainerBootRunner`] cannot reach the podman/Docker API socket (or the
/// `test-boot-container-bollard` feature is off).
///
/// Transport: this spawns the engine as an attached `<engine> run` subprocess
/// (`--name` set) so the same process-group + reader-thread machinery as
/// [`ExeBootRunner`] streams logs and reaps the client; `kill` first runs
/// `<engine> kill <name>` to stop the actual container, then SIGKILLs the group.
///
/// ZERO-SHELL: the constellation's charter forbids shelling out to `podman`. The
/// preferred transport is [`DraupnirContainerBootRunner`], which drives the
/// container lifecycle (create/start/logs/kill) through draupnir's ONE OCI engine
/// over the podman/Docker REST API socket — the same engine knut's `knut-spark-server`
/// `server` feature uses. This subprocess runner survives only as the graceful
/// fallback for a host with no API socket; the DEFAULT (feature on + socket present)
/// routes through draupnir.
pub struct ContainerBootRunner;

impl ContainerBootRunner {
    /// The `<engine> run` argv that brings `image` up under `container_name`.
    fn run_argv(image: &str, container_name: &str) -> Vec<String> {
        vec![
            "run".into(),
            "--rm".into(),
            "--name".into(),
            container_name.into(),
            image.into(),
        ]
    }
}

impl BootRunner for ContainerBootRunner {
    fn spawn(&self, target: &BootTarget) -> Result<Box<dyn BootProc>, String> {
        let BootTarget::Container(c) = target else {
            return Err(format!(
                "ContainerBootRunner cannot boot a {} target",
                target.mode().as_str()
            ));
        };
        let image = c.primary_tag().unwrap_or(c.name.as_str()).to_string();
        // A stable per-boot container name so `kill` can `<engine> kill <name>`.
        let container_name = format!("nornir-boot-{}-{}", c.name, uuid::Uuid::new_v4());
        let engine = container_engine();
        let args = ContainerBootRunner::run_argv(&image, &container_name);
        let stop_cmd = vec![engine.clone(), "kill".into(), container_name.clone()];
        ExeBootRunner::spawn_process(std::ffi::OsStr::new(&engine), &args, Some(stop_cmd))
    }
}

/// Stream one child pipe into the shared log, line by line, with a `prefix`.
fn spawn_reader<R: std::io::Read + Send + 'static>(reader: R, prefix: &'static str, log: LogBuf) {
    std::thread::spawn(move || {
        let buf = BufReader::new(reader);
        for line in buf.lines() {
            match line {
                Ok(l) => log.lock().unwrap().push(format!("{prefix}{l}")),
                Err(_) => break,
            }
        }
    });
}

// ---------------------------------------------------------------------------
// Container mode — ZERO-SHELL, routed THROUGH draupnir's ONE OCI engine.
// ---------------------------------------------------------------------------

/// Map a nornir [`ContainerBuild`] onto a `draupnir::BootSpec` (the OCI container
/// path). The image (`primary_tag`, else the name), a fresh per-boot name
/// (`nornir-boot-<name>-<uuid>` so a kill addresses the exact container), and the
/// run overrides (cmd / env / published ports) all carry across, so a boot through
/// draupnir brings up the same container nornir's old in-tree engine did.
fn container_build_to_draupnir(c: &ContainerBuild) -> draupnir::BootSpec {
    let image = c.primary_tag().unwrap_or(c.name.as_str()).to_string();
    let name = format!("nornir-boot-{}-{}", c.name, uuid::Uuid::new_v4());
    let mut d = draupnir::BootSpec::container(name, image).with_cmd(c.cmd.clone());
    d.ports = c.expose.clone();
    d.env = c.env.clone();
    d
}

/// The **zero-shell** Container boot runner that provisions THROUGH **draupnir** —
/// the shared boot library that owns the single bollard/podman REST engine for the
/// whole constellation. It maps a built [`ContainerBuild`] onto a `draupnir::BootSpec`
/// ([`container_build_to_draupnir`]), boots it via `draupnir::Boot`, and adapts the
/// returned `draupnir::Machine` into a [`BootProc`] over draupnir's
/// [`ContainerControl`](draupnir::container::ContainerControl) seam. There is **no
/// second engine and no `std::process::Command`** on this path — that is the
/// difference from the [`ContainerBootRunner`] subprocess fallback.
///
/// Generic over the draupnir backend `B` so a test injects a mock and proves the
/// nornir→draupnir routing with no daemon (draupnir's `Boot`/`ContainerControl` are
/// always compiled); the production `B` is [`draupnir::container::ContainerBoot`]
/// (the live engine, feature `test-boot-container-bollard = ["draupnir/backend-oci"]`).
#[derive(Default)]
pub struct DraupnirContainerBootRunner<B = draupnir::container::ContainerBoot> {
    backend: B,
}

impl<B> DraupnirContainerBootRunner<B>
where
    B: draupnir::Boot + draupnir::container::ContainerControl + Clone + Send + Sync + 'static,
{
    /// A runner over an explicit draupnir backend — the test seam (a mock backend
    /// drives the whole lifecycle with no daemon).
    pub fn with_backend(backend: B) -> Self {
        DraupnirContainerBootRunner { backend }
    }
}

impl<B> BootRunner for DraupnirContainerBootRunner<B>
where
    B: draupnir::Boot + draupnir::container::ContainerControl + Clone + Send + Sync + 'static,
{
    fn spawn(&self, target: &BootTarget) -> Result<Box<dyn BootProc>, String> {
        let BootTarget::Container(c) = target else {
            return Err(format!(
                "DraupnirContainerBootRunner cannot boot a {} target",
                target.mode().as_str()
            ));
        };
        let machine = self
            .backend
            .boot(&container_build_to_draupnir(c))
            .map_err(|e| format!("draupnir container boot: {e}"))?;
        Ok(Box::new(DraupnirContainerProc {
            backend: self.backend.clone(),
            machine,
            killed: false,
            terminal: None,
        }))
    }
}

/// Adapts a draupnir container `draupnir::Machine` to nornir's [`BootProc`], driving
/// its lifecycle over draupnir's [`ContainerControl`](draupnir::container::ContainerControl)
/// seam. `poll` maps draupnir's exit-code-aware
/// [`ContainerState`](draupnir::container::ContainerState) onto [`BootStatus`]
/// exactly as the old in-tree engine did (clean exit → `BootedOk`, crash →
/// `Failed(code)`, gone → `Failed`/`Killed`).
struct DraupnirContainerProc<B: draupnir::container::ContainerControl> {
    backend: B,
    machine: draupnir::Machine,
    /// Set once we asked the engine to stop it (distinguishes kill from natural death).
    killed: bool,
    /// Cached terminal status once reached (so `poll` is stable + cheap after).
    terminal: Option<BootStatus>,
}

impl<B: draupnir::container::ContainerControl + Send> BootProc for DraupnirContainerProc<B> {
    fn poll(&mut self) -> BootStatus {
        use draupnir::container::ContainerState;
        if let Some(t) = &self.terminal {
            return t.clone();
        }
        match self.backend.container_state(&self.machine) {
            ContainerState::Running => BootStatus::Running,
            ContainerState::Exited(code) => {
                let t = if self.killed {
                    BootStatus::Killed
                } else if code == 0 {
                    BootStatus::BootedOk
                } else {
                    BootStatus::Failed(format!("container exited {code}"))
                };
                self.terminal = Some(t.clone());
                t
            }
            ContainerState::Gone => {
                let t = if self.killed {
                    BootStatus::Killed
                } else {
                    BootStatus::Failed("container gone".into())
                };
                self.terminal = Some(t.clone());
                t
            }
        }
    }

    fn kill(&mut self) {
        if self.terminal.is_some() {
            return;
        }
        self.backend.stop(&self.machine);
        self.killed = true;
        self.terminal = Some(BootStatus::Killed);
    }

    fn drain_log(&mut self) -> Vec<String> {
        self.backend.drain_logs(&self.machine)
    }
}

/// True iff the podman/Docker API socket path exists (honours `DOCKER_HOST`, else
/// the rootless user socket) — a cheap pre-check so the container runner can fall
/// back to the subprocess path when there is no socket, without linking bollard.
#[cfg(feature = "test-boot-container-bollard")]
fn podman_socket_present() -> bool {
    let url = std::env::var("DOCKER_HOST").unwrap_or_else(|_| {
        let xdg = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".into());
        format!("unix://{xdg}/podman/podman.sock")
    });
    let path = url.strip_prefix("unix://").unwrap_or(&url);
    std::path::Path::new(path).exists()
}

/// Pick the container [`BootRunner`] for the running build: the zero-shell
/// [`DraupnirContainerBootRunner`] (routed through draupnir's ONE OCI engine) when
/// `test-boot-container-bollard` is on AND the podman API socket is present, else the
/// subprocess [`ContainerBootRunner`] fallback.
fn default_container_runner() -> Box<dyn BootRunner> {
    #[cfg(feature = "test-boot-container-bollard")]
    {
        if podman_socket_present() {
            return Box::new(DraupnirContainerBootRunner::<
                draupnir::container::ContainerBoot,
            >::default());
        }
        eprintln!(
            "test-boot: podman API socket unavailable; falling back to the podman subprocess runner"
        );
    }
    Box::new(ContainerBootRunner)
}

// ---------------------------------------------------------------------------
// Vm mode — nornir owns the seam; the real boot lands via tunnr.
// ---------------------------------------------------------------------------

/// The VM boot seam nornir owns until tunnr's boot primitive is committed.
///
/// TODO(tunnr): replace [`StubVmBootRunner`] with a thin adapter over the tunnr
/// API once the tunnr agent lands it. The exact API nornir needs:
///
/// ```ignore
/// // in crate `tunnr_vm`:
/// pub struct BootSpec { name, kernel, rootfs, disk, mem_mb, cores, cmdline, format }
/// pub fn boot_test(spec: BootSpec) -> Result<BootHandle, String>;
/// impl BootHandle {
///     fn status(&mut self) -> BootStatus;   // Booting/Running/BootedOk/Failed/Killed
///     fn kill(&mut self);                    // tear the guest down
///     fn drain_serial(&mut self) -> Vec<String>;
/// }
/// ```
///
/// nornir maps [`VmBuild`] → [`BootSpec`] ([`BootSpec::from_vm`]) and then
/// [`BootSpec`] → `tunnr_vm::BootSpec` field-for-field.
pub trait VmBootRunner: Send + Sync {
    /// Boot the VM described by `spec`, returning the live [`BootProc`].
    fn boot(&self, spec: &BootSpec) -> Result<Box<dyn BootProc>, String>;
}

/// The default VM runner when the real tunnr wire is not compiled in
/// (`--features test-boot-vm` off): it maps the spec but reports a clear, honest
/// failure rather than pretending to boot.
pub struct StubVmBootRunner;

impl VmBootRunner for StubVmBootRunner {
    fn boot(&self, spec: &BootSpec) -> Result<Box<dyn BootProc>, String> {
        Err(format!(
            "vm boot '{}' not wired in this build: rebuild with `--features test-boot-vm` to \
             drive tunnr_vm::boot_test(BootSpec) -> BootHandle (spec has {} qemu args)",
            spec.name,
            spec.qemu_args.len()
        ))
    }
}

/// The REAL VM runner (feature `test-boot-vm`): maps nornir's [`BootSpec`] onto
/// tunnr's `tunnr_vm::BootSpec`, calls `tunnr_vm::boot_test`, and adapts the
/// returned `BootHandle` into a [`BootProc`] — so the job lifecycle drives the
/// KVM: job-kill → `BootHandle::kill()`; `poll` → `BootHandle::poll_status()`
/// (which probes the QEMU pid, so an externally-dead VM flips the job too).
#[cfg(feature = "test-boot-vm")]
pub struct TunnrVmBootRunner;

#[cfg(feature = "test-boot-vm")]
impl VmBootRunner for TunnrVmBootRunner {
    fn boot(&self, spec: &BootSpec) -> Result<Box<dyn BootProc>, String> {
        use std::path::PathBuf;
        use std::time::Duration;

        // nornir BootSpec → tunnr BootSpec. tunnr takes a single boot image
        // (`rootfs_or_disk`): prefer the rootfs (initramfs), else the disk.
        let image = if !spec.rootfs.is_empty() {
            &spec.rootfs
        } else {
            &spec.disk
        };
        let tunnr_spec = tunnr_vm::BootSpec {
            kernel: PathBuf::from(&spec.kernel),
            rootfs_or_disk: PathBuf::from(image),
            mem_mb: spec.mem_mb,
            cores: spec.cores,
            timeout: Duration::from_secs(
                std::env::var("NORNIR_TEST_BOOT_VM_TIMEOUT")
                    .ok()
                    .and_then(|s| s.parse().ok())
                    .unwrap_or(120),
            ),
            headless: true,
            extra_qemu_args: Vec::new(),
            success_marker: tunnr_vm::DEFAULT_SUCCESS_MARKER.to_string(),
            kernel_cmdline: if spec.cmdline.is_empty() {
                "quiet".into()
            } else {
                spec.cmdline.clone()
            },
        };
        let handle =
            tunnr_vm::boot_test(tunnr_spec).map_err(|e| format!("tunnr boot_test: {e}"))?;
        Ok(Box::new(TunnrProc {
            handle,
            last_log_len: 0,
        }))
    }
}

/// Adapts a tunnr `BootHandle` to nornir's [`BootProc`] (feature `test-boot-vm`).
#[cfg(feature = "test-boot-vm")]
struct TunnrProc {
    handle: tunnr_vm::BootHandle,
    /// Byte length of the serial log already drained (tunnr returns the full log).
    last_log_len: usize,
}

#[cfg(feature = "test-boot-vm")]
impl BootProc for TunnrProc {
    fn poll(&mut self) -> BootStatus {
        match self.handle.poll_status() {
            tunnr_vm::BootStatus::Booting => BootStatus::Booting,
            tunnr_vm::BootStatus::BootedOk => BootStatus::BootedOk,
            tunnr_vm::BootStatus::Failed(r) => BootStatus::Failed(r),
            tunnr_vm::BootStatus::Killed => BootStatus::Killed,
            tunnr_vm::BootStatus::TimedOut => BootStatus::Failed("timed out".to_string()),
        }
    }
    fn kill(&mut self) {
        self.handle.kill();
    }
    fn drain_log(&mut self) -> Vec<String> {
        let full = self.handle.boot_log();
        if full.len() <= self.last_log_len {
            return Vec::new();
        }
        let new = full[self.last_log_len..].to_string();
        self.last_log_len = full.len();
        new.lines().map(|l| l.to_string()).collect()
    }
}

// ---------------------------------------------------------------------------
// Dispatch runner — routes a BootTarget to its mode's runner.
// ---------------------------------------------------------------------------

/// The production [`BootRunner`]: routes each [`BootTarget`] to the runner for its
/// mode — Exe → [`ExeBootRunner`], Container → [`ContainerBootRunner`], Vm → the
/// [`VmBootRunner`] seam.
pub struct DispatchBootRunner {
    exe: ExeBootRunner,
    container: Box<dyn BootRunner>,
    vm: Box<dyn VmBootRunner>,
}

impl Default for DispatchBootRunner {
    fn default() -> Self {
        // Feature `test-boot-vm` swaps the honest stub for the real tunnr KVM wire.
        #[cfg(feature = "test-boot-vm")]
        let vm: Box<dyn VmBootRunner> = Box::new(TunnrVmBootRunner);
        #[cfg(not(feature = "test-boot-vm"))]
        let vm: Box<dyn VmBootRunner> = Box::new(StubVmBootRunner);
        // Container path: zero-shell draupnir OCI engine (feature on + socket present) else
        // the podman-subprocess fallback.
        DispatchBootRunner {
            exe: ExeBootRunner,
            container: default_container_runner(),
            vm,
        }
    }
}

impl BootRunner for DispatchBootRunner {
    fn spawn(&self, target: &BootTarget) -> Result<Box<dyn BootProc>, String> {
        match target {
            BootTarget::Exe { .. } => self.exe.spawn(target),
            BootTarget::Container(_) => self.container.spawn(target),
            BootTarget::Vm(vm) => self.vm.boot(&BootSpec::from_vm(vm)),
        }
    }
}

// ---------------------------------------------------------------------------
// The boot-job manager — job list integration + bidirectional lifecycle.
// ---------------------------------------------------------------------------

/// One live boot job: the durable job record's id + the observable status/log, and
/// the live [`BootProc`] handle + the [`JobHandle`] that writes the ledger row.
pub struct BootJob {
    /// The nornir job id (the ledger key).
    pub job_id: String,
    /// The target name (variant / image / vm).
    pub target: String,
    /// The boot mode.
    pub mode: BootMode,
    /// Live status (mirrors the ledger row + the last `poll`).
    pub status: BootStatus,
    /// The boot log (child stdout/stderr / serial), newest last.
    pub log: Vec<String>,
    /// The live process handle (None once terminal + reaped).
    proc: Option<Box<dyn BootProc>>,
    /// The ledger handle; `take`n and finished/failed on the terminal transition.
    handle: Option<JobHandle>,
}

impl BootJob {
    /// The observable JSON for state_json / a robot assert.
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "job_id": self.job_id,
            "target": self.target,
            "mode": self.mode.as_str(),
            "status": self.status.as_str(),
            "terminal": self.status.is_terminal(),
            "log_lines": self.log.len(),
        })
    }
}

/// Manages the Build tab's live test-boot jobs: presses a boot (creating a nornir
/// job), polls each boot's lifecycle both directions, and kills on demand.
pub struct TestBootState {
    runner: Arc<dyn BootRunner>,
    sink: JobSink,
    workspace: String,
    jobs: Vec<BootJob>,
}

impl TestBootState {
    /// A manager wired to the real [`DispatchBootRunner`] and the given ledger
    /// `sink` (use [`JobSink::noop`] when no warehouse is configured).
    pub fn new(sink: JobSink, workspace: impl Into<String>) -> Self {
        Self::with_runner(Arc::new(DispatchBootRunner::default()), sink, workspace)
    }

    /// A manager with an injected [`BootRunner`] — the test seam (a
    /// [`FakeBootRunner`] drives the whole lifecycle with no real process).
    pub fn with_runner(
        runner: Arc<dyn BootRunner>,
        sink: JobSink,
        workspace: impl Into<String>,
    ) -> Self {
        TestBootState {
            runner,
            sink,
            workspace: workspace.into(),
            jobs: Vec::new(),
        }
    }

    /// The live boot jobs (newest last).
    pub fn jobs(&self) -> &[BootJob] {
        &self.jobs
    }

    /// A clone of the injected [`BootRunner`] `Arc` — lets a caller re-wrap the
    /// manager around a new sink while preserving the (possibly faked) runner.
    pub fn runner_arc(&self) -> Arc<dyn BootRunner> {
        Arc::clone(&self.runner)
    }

    /// Whether a non-terminal boot job exists (drives the repaint request).
    pub fn any_running(&self) -> bool {
        self.jobs.iter().any(|j| !j.status.is_terminal())
    }

    /// The observable JSON — the boot jobs, for the Build tab's state_json.
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "boot_jobs": self.jobs.iter().map(|j| j.state_json()).collect::<Vec<_>>(),
            "running": self.jobs.iter().filter(|j| !j.status.is_terminal()).count(),
        })
    }

    /// **Press test-boot.** Spawn `target` through the [`BootRunner`], open a
    /// `running` nornir job (kind = the mode's boot kind), and register the live
    /// boot job. On a spawn failure the job is opened and immediately failed (an
    /// honest terminal row), so a failed boot is still visible in the ledger.
    /// Returns the new job id.
    pub fn boot(&mut self, target: BootTarget) -> String {
        let mode = target.mode();
        let name = target.name().to_string();
        let handle = JobHandle::start(
            self.sink.clone(),
            mode.job_kind(),
            &name,
            &self.workspace,
            serde_json::json!({ "mode": mode.as_str() }),
        );
        let job_id = handle.job_id().to_string();

        match self.runner.spawn(&target) {
            Ok(proc) => {
                // EMIT-DOCTRINE: a launched boot is a REAL functional signal (green).
                nornir_testmatrix::functional_status(
                    "viz-test-boot",
                    &format!("boot_{}_{name}", mode.as_str()),
                    true,
                    &format!("test-boot '{name}' ({}) started", mode.as_str()),
                );
                self.jobs.push(BootJob {
                    job_id: job_id.clone(),
                    target: name,
                    mode,
                    status: BootStatus::Booting,
                    log: Vec::new(),
                    proc: Some(proc),
                    handle: Some(handle),
                });
            }
            Err(reason) => {
                nornir_testmatrix::functional_status(
                    "viz-test-boot",
                    &format!("boot_{}_{name}", mode.as_str()),
                    false,
                    &format!(
                        "test-boot '{name}' ({}) FAILED to start: {reason}",
                        mode.as_str()
                    ),
                );
                handle.fail(&anyhow::anyhow!("{reason}"));
                self.jobs.push(BootJob {
                    job_id: job_id.clone(),
                    target: name,
                    mode,
                    status: BootStatus::Failed(reason),
                    log: Vec::new(),
                    proc: None,
                    handle: None,
                });
            }
        }
        job_id
    }

    /// **Poll every boot both directions.** Drain each boot's log, read its status;
    /// when a boot has DIED on its own (status went terminal), finish/fail the
    /// nornir job — the "boot dies → job terminates" direction.
    pub fn poll(&mut self) {
        for job in &mut self.jobs {
            if job.status.is_terminal() {
                continue;
            }
            let Some(proc) = job.proc.as_mut() else {
                continue;
            };
            let new_lines = proc.drain_log();
            if !new_lines.is_empty() {
                job.log.extend(new_lines);
            }
            let status = proc.poll();
            if status != job.status {
                job.status = status.clone();
            }
            if status.is_terminal() {
                Self::terminate_job(job, &status);
            }
        }
    }

    /// **Kill a boot job by id** — the "kill the job → kill the boot" direction.
    /// SIGKILLs the process-group / stops the container / kills the VM, flips the
    /// status to `Killed`, and writes the terminal ledger row. Returns whether a
    /// live job matched.
    pub fn kill(&mut self, job_id: &str) -> bool {
        let Some(job) = self.jobs.iter_mut().find(|j| j.job_id == job_id) else {
            return false;
        };
        if job.status.is_terminal() {
            return false;
        }
        if let Some(proc) = job.proc.as_mut() {
            proc.kill();
            let drained = proc.drain_log();
            job.log.extend(drained);
        }
        job.status = BootStatus::Killed;
        Self::terminate_job(job, &BootStatus::Killed);
        true
    }

    /// Send a line to an Exe boot's console (the PROMPT).
    pub fn send_stdin(&mut self, job_id: &str, line: &str) {
        if let Some(job) = self.jobs.iter_mut().find(|j| j.job_id == job_id) {
            if let Some(proc) = job.proc.as_mut() {
                proc.send_stdin(line);
                job.log.push(format!("> {line}"));
            }
        }
    }

    /// Write the terminal ledger row for a boot that has reached `status`, and drop
    /// the live handle. Idempotent-safe: only runs when a `JobHandle` is still held.
    fn terminate_job(job: &mut BootJob, status: &BootStatus) {
        let Some(handle) = job.handle.take() else {
            return;
        };
        let detail = serde_json::json!({
            "mode": job.mode.as_str(),
            "status": status.as_str(),
            "log_tail": job.log.iter().rev().take(20).rev().cloned().collect::<Vec<_>>(),
        });
        match status {
            BootStatus::BootedOk => handle.finish(detail, ""),
            BootStatus::Killed => handle.fail_with_detail(detail),
            BootStatus::Failed(reason) => {
                let mut d = detail;
                d["reason"] = serde_json::json!(reason);
                handle.fail_with_detail(d);
            }
            // Non-terminal — never called here, but keep the handle if so.
            _ => job.handle = Some(handle),
        }
        job.proc = None;
        // EMIT-DOCTRINE: the boot reached a terminal state — a functional row.
        let ok = matches!(status, BootStatus::BootedOk);
        nornir_testmatrix::functional_status(
            "viz-test-boot",
            &format!("boot_{}_{}_terminal", job.mode.as_str(), job.target),
            ok,
            &format!("test-boot '{}' → {}", job.target, status.as_str()),
        );
    }
}

// ---------------------------------------------------------------------------
// Test seam — a fake runner that drives the whole lifecycle with no real boot.
// ---------------------------------------------------------------------------

/// A scriptable fake [`BootProc`] for tests: its status is driven by a shared cell
/// (a test flips it to simulate the boot dying), and [`kill`](BootProc::kill)
/// records that it was asked to die.
#[derive(Clone)]
pub struct FakeProc {
    /// The status the fake reports on `poll` (a test mutates it to simulate death).
    pub status: Arc<Mutex<BootStatus>>,
    /// Set true when `kill` was called (asserts the kill reached the boot).
    pub killed: Arc<Mutex<bool>>,
    /// Pending log lines the fake emits on the next `drain_log`.
    pub pending_log: Arc<Mutex<Vec<String>>>,
}

impl FakeProc {
    /// A fake that starts `Running`.
    pub fn running() -> Self {
        FakeProc {
            status: Arc::new(Mutex::new(BootStatus::Running)),
            killed: Arc::new(Mutex::new(false)),
            pending_log: Arc::new(Mutex::new(Vec::new())),
        }
    }
}

impl BootProc for FakeProc {
    fn poll(&mut self) -> BootStatus {
        self.status.lock().unwrap().clone()
    }
    fn kill(&mut self) {
        *self.killed.lock().unwrap() = true;
        *self.status.lock().unwrap() = BootStatus::Killed;
    }
    fn drain_log(&mut self) -> Vec<String> {
        std::mem::take(&mut *self.pending_log.lock().unwrap())
    }
    fn send_stdin(&mut self, line: &str) {
        self.pending_log
            .lock()
            .unwrap()
            .push(format!("echo: {line}"));
    }
}

/// A fake [`BootRunner`] that hands back a preset [`FakeProc`] per spawn — the test
/// keeps a clone so it can flip the status (simulate death) or read `killed`.
pub struct FakeBootRunner {
    /// The proc handed to the NEXT spawn (a test presets it, then clones to drive).
    pub next: Mutex<Option<FakeProc>>,
    /// If set, spawn fails with this reason (test the failed-boot job path).
    pub fail: Mutex<Option<String>>,
}

impl FakeBootRunner {
    /// A runner whose next spawn yields `proc`.
    pub fn yielding(proc: FakeProc) -> Self {
        FakeBootRunner {
            next: Mutex::new(Some(proc)),
            fail: Mutex::new(None),
        }
    }
}

impl BootRunner for FakeBootRunner {
    fn spawn(&self, _target: &BootTarget) -> Result<Box<dyn BootProc>, String> {
        if let Some(reason) = self.fail.lock().unwrap().clone() {
            return Err(reason);
        }
        let proc = self
            .next
            .lock()
            .unwrap()
            .clone()
            .unwrap_or_else(FakeProc::running);
        Ok(Box::new(proc))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::jobs::{JobRecord, JobSink};

    /// A capturing sink: records every ledger write so a test can assert the job's
    /// running→terminal transitions landed.
    fn capturing_sink() -> (JobSink, Arc<Mutex<Vec<JobRecord>>>) {
        let cap: Arc<Mutex<Vec<JobRecord>>> = Arc::new(Mutex::new(Vec::new()));
        let c2 = Arc::clone(&cap);
        let sink = JobSink::remote(move |rec: &JobRecord| c2.lock().unwrap().push(rec.clone()));
        (sink, cap)
    }

    fn exe_target() -> BootTarget {
        BootTarget::Exe {
            name: "full".into(),
            exe: PathBuf::from("/bin/true"),
            args: vec![],
        }
    }

    #[test]
    fn mode_selection_maps_target_type_to_mode() {
        assert_eq!(exe_target().mode(), BootMode::Exe);
        assert_eq!(BootMode::Exe.job_kind(), kind::BOOT_EXE);

        let c = BootTarget::Container(ContainerBuild {
            name: "img".into(),
            ..Default::default()
        });
        assert_eq!(c.mode(), BootMode::Container);
        assert_eq!(BootMode::Container.job_kind(), kind::BOOT_CONTAINER);

        let v = BootTarget::Vm(VmBuild {
            name: "vm".into(),
            ..Default::default()
        });
        assert_eq!(v.mode(), BootMode::Vm);
        assert_eq!(BootMode::Vm.job_kind(), kind::BOOT_VM);
    }

    #[test]
    fn vm_boot_creates_a_boot_vm_job_via_fake_runner() {
        // Fake runner: the whole Vm lifecycle without QEMU/KVM. Proves the mode
        // wires a boot_vm job + bidirectional kill, independent of tunnr.
        let (sink, cap) = capturing_sink();
        let proc = FakeProc::running();
        let runner = Arc::new(FakeBootRunner::yielding(proc.clone()));
        let mut st = TestBootState::with_runner(runner, sink, "ws");

        let target = BootTarget::Vm(VmBuild {
            name: "holger-appliance".into(),
            kernel: "/boot/vmlinuz".into(),
            rootfs: "rootfs.cpio.gz".into(),
            ..Default::default()
        });
        assert_eq!(target.mode(), BootMode::Vm);
        let id = st.boot(target);
        st.poll();
        assert_eq!(st.jobs()[0].mode, BootMode::Vm);
        assert_eq!(st.jobs()[0].status, BootStatus::Running);

        // The VM dies on its own → job terminates (the "KVM dies → job" edge).
        *proc.status.lock().unwrap() = BootStatus::Failed("qemu vanished".into());
        st.poll();
        assert!(matches!(st.jobs()[0].status, BootStatus::Failed(_)));
        let recs = cap.lock().unwrap();
        assert_eq!(recs[0].kind, kind::BOOT_VM);
        let last = recs.iter().rev().find(|r| r.job_id == id).unwrap();
        assert_eq!(last.status, crate::jobs::status::FAILED);
    }

    /// REAL KVM boot via tunnr — heavy, needs QEMU+OVMF+KVM+a kernel/rootfs, so it
    /// is `#[ignore]`d and only meaningful under `--features test-boot-vm`. Mirrors
    /// tunnr's own ignored `boot_matrix` heavy path. Point it at real images with
    /// `TUNNR_KERNEL` / `TUNNR_INITRD`-style paths in a `VmBuild` and run:
    /// `cargo test --features test-boot-vm real_vm_boot -- --ignored --nocapture`.
    #[cfg(feature = "test-boot-vm")]
    #[test]
    #[ignore = "real KVM boot: needs QEMU+OVMF+KVM and a kernel/rootfs"]
    fn real_vm_boot_via_tunnr_reaches_terminal() {
        let kernel = std::env::var("NORNIR_VM_KERNEL").expect("set NORNIR_VM_KERNEL");
        let rootfs = std::env::var("NORNIR_VM_ROOTFS").expect("set NORNIR_VM_ROOTFS");
        let mut st = TestBootState::new(JobSink::noop(), "ws");
        let id = st.boot(BootTarget::Vm(VmBuild {
            name: "real".into(),
            kernel,
            rootfs,
            mem_mb: 512,
            cores: 2,
            ..Default::default()
        }));
        // Give it a moment, then kill — proves the bidirectional KVM teardown.
        std::thread::sleep(std::time::Duration::from_secs(3));
        st.poll();
        assert!(st.kill(&id), "kill the real VM");
        assert_eq!(st.jobs()[0].status, BootStatus::Killed);
    }

    #[test]
    fn vmbuild_maps_to_bootspec_field_for_field() {
        let vm = VmBuild {
            name: "holger-appliance".into(),
            kernel: "/boot/vmlinuz".into(),
            rootfs: "rootfs.cpio.gz".into(),
            mem_mb: 1024,
            cores: 4,
            boot_params: vec!["console=ttyS0".into()],
            ..Default::default()
        };
        let spec = BootSpec::from_vm(&vm);
        assert_eq!(spec.name, "holger-appliance");
        assert_eq!(spec.kernel, "/boot/vmlinuz");
        assert_eq!(spec.rootfs, "rootfs.cpio.gz");
        assert_eq!(spec.mem_mb, 1024);
        assert_eq!(spec.cores, 4);
        assert_eq!(spec.cmdline, "console=ttyS0");
        assert_eq!(spec.format, "qemu");
        assert_eq!(spec.qemu_args, vm.qemu_args());
    }

    #[test]
    fn container_run_argv_brings_image_up_named() {
        let argv = ContainerBootRunner::run_argv("holger:0.1", "nornir-boot-x");
        assert_eq!(
            argv,
            vec!["run", "--rm", "--name", "nornir-boot-x", "holger:0.1"]
        );
    }

    #[test]
    fn container_boot_creates_a_boot_container_job() {
        // Fake runner: no real podman. Proves the Container mode wires a job with the
        // boot_container kind + bidirectional kill, exactly like Exe.
        let (sink, cap) = capturing_sink();
        let proc = FakeProc::running();
        let runner = Arc::new(FakeBootRunner::yielding(proc.clone()));
        let mut st = TestBootState::with_runner(runner, sink, "ws");

        let target = BootTarget::Container(ContainerBuild {
            name: "holger-appliance".into(),
            base: "debian".into(),
            tags: vec!["holger-appliance:0.1".into()],
            ..Default::default()
        });
        assert_eq!(target.mode(), BootMode::Container);
        let id = st.boot(target);
        st.poll();
        assert_eq!(st.jobs()[0].status, BootStatus::Running);
        assert_eq!(st.jobs()[0].mode, BootMode::Container);

        assert!(st.kill(&id));
        assert!(*proc.killed.lock().unwrap());
        let recs = cap.lock().unwrap();
        assert_eq!(recs[0].kind, kind::BOOT_CONTAINER);
    }

    // ── zero-shell container runner: a MOCK draupnir backend (no daemon, no
    //    feature) exercises the whole boot → logs → state → kill wiring that the
    //    real draupnir::container::ContainerBoot drives — the ONE engine. ─────────

    use std::collections::BTreeMap;

    #[derive(Default)]
    struct MockDraupnirInner {
        booted: Vec<draupnir::BootSpec>,
        stopped: Vec<String>,
        state: Option<draupnir::container::ContainerState>,
        logs: Vec<String>,
        fail_boot: Option<String>,
    }

    /// A fake draupnir OCI backend: records the specs it was handed to boot + the
    /// machines it was asked to stop, and reports a scriptable container state /
    /// logs — so the nornir→draupnir wiring is driven with no real podman. It
    /// exercises only draupnir's always-compiled `Boot` + `ContainerControl` traits.
    #[derive(Clone, Default)]
    struct MockDraupnirBackend {
        inner: Arc<Mutex<MockDraupnirInner>>,
    }
    impl MockDraupnirBackend {
        fn set_state(&self, s: draupnir::container::ContainerState) {
            self.inner.lock().unwrap().state = Some(s);
        }
        fn set_logs(&self, lines: &[&str]) {
            self.inner.lock().unwrap().logs = lines.iter().map(|s| s.to_string()).collect();
        }
        fn booted(&self) -> Vec<draupnir::BootSpec> {
            self.inner.lock().unwrap().booted.clone()
        }
        fn stopped(&self) -> Vec<String> {
            self.inner.lock().unwrap().stopped.clone()
        }
    }
    impl draupnir::Boot for MockDraupnirBackend {
        fn boot(&self, spec: &draupnir::BootSpec) -> draupnir::Result<draupnir::Machine> {
            let mut g = self.inner.lock().unwrap();
            if let Some(reason) = g.fail_boot.clone() {
                return Err(draupnir::Error::Backend(reason));
            }
            g.booted.push(spec.clone());
            Ok(draupnir::Machine::started(
                format!("draupnir-{}", spec.name),
                spec,
            ))
        }
    }
    impl draupnir::container::ContainerControl for MockDraupnirBackend {
        fn container_state(&self, _m: &draupnir::Machine) -> draupnir::container::ContainerState {
            self.inner
                .lock()
                .unwrap()
                .state
                .clone()
                .unwrap_or(draupnir::container::ContainerState::Running)
        }
        fn drain_logs(&self, _m: &draupnir::Machine) -> Vec<String> {
            std::mem::take(&mut self.inner.lock().unwrap().logs)
        }
        fn stop(&self, m: &draupnir::Machine) {
            self.inner.lock().unwrap().stopped.push(m.id.clone());
        }
    }

    fn container_target() -> BootTarget {
        BootTarget::Container(ContainerBuild {
            name: "holger-appliance".into(),
            base: "debian".into(),
            tags: vec!["holger-appliance:0.1".into()],
            ..Default::default()
        })
    }

    #[test]
    fn draupnir_runner_boots_streams_logs_and_kills() {
        // The mock backend drives the whole lifecycle with no daemon: spawn → boot
        // through draupnir (image + a nornir-boot-* name) → Running → streamed logs
        // → kill → draupnir stop. This is the zero-shell path's own wiring proof.
        let backend = MockDraupnirBackend::default();
        backend.set_logs(&["holger up", "listening on :8080"]);
        let runner = DraupnirContainerBootRunner::with_backend(backend.clone());

        let mut proc = runner.spawn(&container_target()).expect("draupnir spawn");
        // The boot was routed to draupnir with the primary tag as image + a per-boot name.
        let booted = backend.booted();
        assert_eq!(booted.len(), 1, "one boot routed through draupnir");
        assert_eq!(
            booted[0].image,
            draupnir::ImageSource::OciImage("holger-appliance:0.1".into())
        );
        assert!(
            booted[0].name.starts_with("nornir-boot-holger-appliance-"),
            "per-boot name: {}",
            booted[0].name
        );

        assert_eq!(
            proc.poll(),
            BootStatus::Running,
            "container is up (draupnir state)"
        );
        assert_eq!(
            proc.drain_log(),
            vec!["holger up", "listening on :8080"],
            "draupnir-streamed logs drained"
        );

        proc.kill();
        assert_eq!(proc.poll(), BootStatus::Killed, "kill is terminal");
        assert_eq!(backend.stopped().len(), 1, "kill routed to draupnir stop");
        // Idempotent: a second kill is a no-op (no extra stop).
        proc.kill();
        assert_eq!(backend.stopped().len(), 1, "kill is idempotent");
    }

    #[test]
    fn draupnir_runner_maps_container_state_to_terminal_status() {
        let make = || {
            let backend = MockDraupnirBackend::default();
            let runner = DraupnirContainerBootRunner::with_backend(backend.clone());
            (backend, runner)
        };

        // Exit 0 → BootedOk.
        let (backend, runner) = make();
        backend.set_state(draupnir::container::ContainerState::Exited(0));
        let mut p = runner.spawn(&container_target()).unwrap();
        assert_eq!(p.poll(), BootStatus::BootedOk);

        // Non-zero exit → Failed with the code.
        let (backend, runner) = make();
        backend.set_state(draupnir::container::ContainerState::Exited(137));
        let mut p = runner.spawn(&container_target()).unwrap();
        assert!(matches!(p.poll(), BootStatus::Failed(r) if r.contains("137")));

        // Gone (removed out from under us) → Failed.
        let (backend, runner) = make();
        backend.set_state(draupnir::container::ContainerState::Gone);
        let mut p = runner.spawn(&container_target()).unwrap();
        assert!(matches!(p.poll(), BootStatus::Failed(_)));
    }

    #[test]
    fn draupnir_runner_drives_the_boot_job_lifecycle_via_the_state_manager() {
        // The DraupnirContainerBootRunner is a drop-in BootRunner: press-boot → job,
        // the container dies on its own → job terminates, all over the zero-shell seam.
        let (sink, cap) = capturing_sink();
        let backend = MockDraupnirBackend::default();
        let runner = Arc::new(DraupnirContainerBootRunner::with_backend(backend.clone()));
        let mut st = TestBootState::with_runner(runner, sink, "ws");

        let id = st.boot(container_target());
        st.poll();
        assert_eq!(st.jobs()[0].mode, BootMode::Container);
        assert_eq!(st.jobs()[0].status, BootStatus::Running);

        // Container exits cleanly on its own → the boot job terminates (done).
        backend.set_state(draupnir::container::ContainerState::Exited(0));
        st.poll();
        assert_eq!(st.jobs()[0].status, BootStatus::BootedOk);
        let recs = cap.lock().unwrap();
        assert_eq!(recs[0].kind, kind::BOOT_CONTAINER);
        let last = recs.iter().rev().find(|r| r.job_id == id).unwrap();
        assert_eq!(last.status, crate::jobs::status::DONE);
    }

    #[test]
    fn draupnir_runner_boot_failure_is_reported() {
        let backend = MockDraupnirBackend::default();
        backend.inner.lock().unwrap().fail_boot = Some("no such image".into());
        let runner = DraupnirContainerBootRunner::with_backend(backend);
        let err = runner
            .spawn(&container_target())
            .err()
            .expect("boot failure surfaces");
        assert!(err.contains("no such image"), "reason carried: {err}");
    }

    #[test]
    fn draupnir_runner_rejects_non_container_target() {
        let runner = DraupnirContainerBootRunner::with_backend(MockDraupnirBackend::default());
        let err = runner
            .spawn(&exe_target())
            .err()
            .expect("exe is not a container");
        assert!(err.contains("cannot boot a exe"), "{err}");
    }

    #[test]
    fn draupnir_runner_forwards_cmd_env_and_ports_from_the_descriptor() {
        // A ContainerBuild that overrides cmd/env and publishes a port: the runner
        // must map it onto a draupnir::BootSpec and hand that to draupnir's boot.
        let backend = MockDraupnirBackend::default();
        let runner = DraupnirContainerBootRunner::with_backend(backend.clone());
        let target = BootTarget::Container(ContainerBuild {
            name: "holger-appliance".into(),
            base: "debian".into(),
            tags: vec!["holger-appliance:0.1".into()],
            cmd: vec!["/usr/local/bin/holger".into(), "--served".into()],
            env: BTreeMap::from([("RUST_LOG".to_string(), "info".to_string())]),
            expose: vec![8080],
            ..Default::default()
        });
        let _ = runner.spawn(&target).expect("draupnir spawn");

        let booted = backend.booted();
        assert_eq!(booted.len(), 1, "one boot carried the mapped spec");
        let spec = &booted[0];
        assert_eq!(
            spec.image,
            draupnir::ImageSource::OciImage("holger-appliance:0.1".into())
        );
        assert_eq!(
            spec.cmd,
            vec!["/usr/local/bin/holger", "--served"],
            "cmd override forwarded"
        );
        assert_eq!(
            spec.env.get("RUST_LOG").map(String::as_str),
            Some("info"),
            "env forwarded"
        );
        assert_eq!(spec.ports, vec![8080], "published port forwarded");
    }

    #[test]
    fn container_build_with_no_overrides_maps_to_a_bare_draupnir_spec() {
        // No overrides → image + name only (the image's own defaults are used).
        let d = container_build_to_draupnir(&ContainerBuild {
            name: "x".into(),
            base: "scratch".into(),
            tags: vec!["x:1".into()],
            ..Default::default()
        });
        assert_eq!(d.kind, draupnir::Kind::Container);
        assert_eq!(d.image, draupnir::ImageSource::OciImage("x:1".into()));
        assert!(
            d.name.starts_with("nornir-boot-x-"),
            "per-boot name: {}",
            d.name
        );
        assert!(d.cmd.is_empty() && d.env.is_empty() && d.ports.is_empty());
        d.validate().expect("mapped container spec is valid");
    }

    /// REAL daemon path (feature `test-boot-container-bollard = ["draupnir/backend-oci"]`):
    /// boot a tiny image through draupnir's ONE OCI engine and kill it — proving the
    /// create/start/logs/kill wiring against a live engine. `#[ignore]`d (needs a
    /// running `podman.socket`): run with
    /// `cargo test --features test-boot-container-bollard real_draupnir -- --ignored --nocapture`.
    #[cfg(feature = "test-boot-container-bollard")]
    #[test]
    #[ignore = "real podman API: needs a running podman.socket + network pull"]
    fn real_draupnir_container_boot_reaches_terminal() {
        let runner = DraupnirContainerBootRunner::<draupnir::container::ContainerBoot>::default();
        let mut st = TestBootState::with_runner(Arc::new(runner), JobSink::noop(), "ws");
        // A long-lived image so the container is up when we kill it. draupnir boots
        // the image's own entrypoint/cmd (parity with the subprocess runner), so
        // point NORNIR_TEST_BOOT_IMAGE at an image that stays running.
        let image = std::env::var("NORNIR_TEST_BOOT_IMAGE")
            .unwrap_or_else(|_| "docker.io/library/nginx:alpine".to_string());
        let id = st.boot(BootTarget::Container(ContainerBuild {
            name: "nornir-real-boot".into(),
            base: image.clone(),
            tags: vec![image],
            ..Default::default()
        }));
        std::thread::sleep(std::time::Duration::from_secs(3));
        st.poll();
        // Kill if still up (long-lived image), then confirm a terminal state either
        // way (a short-lived image would already have exited on its own).
        let _ = st.kill(&id);
        st.poll();
        assert!(
            st.jobs()[0].status.is_terminal(),
            "real container reaches a terminal state"
        );
    }

    #[test]
    fn stub_vm_runner_reports_honest_not_wired() {
        let vm = VmBuild {
            name: "vm".into(),
            kernel: "/k".into(),
            rootfs: "r".into(),
            ..Default::default()
        };
        let err = match StubVmBootRunner.boot(&BootSpec::from_vm(&vm)) {
            Ok(_) => panic!("stub must not pretend to boot"),
            Err(e) => e,
        };
        assert!(
            err.contains("tunnr_vm::boot_test"),
            "names the exact API: {err}"
        );
    }

    #[test]
    fn press_boot_creates_a_running_job() {
        let (sink, cap) = capturing_sink();
        let runner = Arc::new(FakeBootRunner::yielding(FakeProc::running()));
        let mut st = TestBootState::with_runner(runner, sink, "ws");

        let id = st.boot(exe_target());
        assert_eq!(st.jobs().len(), 1);
        assert_eq!(st.jobs()[0].job_id, id);
        assert_eq!(st.jobs()[0].mode, BootMode::Exe);
        // Booting until first poll.
        assert_eq!(st.jobs()[0].status, BootStatus::Booting);
        assert!(st.any_running());

        // The ledger got a `running` boot_exe row.
        let recs = cap.lock().unwrap();
        assert_eq!(recs.len(), 1);
        assert_eq!(recs[0].kind, kind::BOOT_EXE);
        assert_eq!(recs[0].target, "full");
        assert_eq!(recs[0].status, crate::jobs::status::RUNNING);
    }

    #[test]
    fn poll_tracks_boot_status_running_then_death_terminates_job() {
        let (sink, cap) = capturing_sink();
        let proc = FakeProc::running();
        let runner = Arc::new(FakeBootRunner::yielding(proc.clone()));
        let mut st = TestBootState::with_runner(runner, sink, "ws");

        let id = st.boot(exe_target());
        st.poll();
        assert_eq!(
            st.jobs()[0].status,
            BootStatus::Running,
            "job tracks Running"
        );

        // The boot DIES on its own (fake flips to BootedOk) → job terminates.
        *proc.status.lock().unwrap() = BootStatus::BootedOk;
        st.poll();
        assert_eq!(st.jobs()[0].status, BootStatus::BootedOk);
        assert!(!st.any_running());

        // The ledger saw running → done for this job.
        let recs = cap.lock().unwrap();
        let last = recs.iter().rev().find(|r| r.job_id == id).unwrap();
        assert_eq!(
            last.status,
            crate::jobs::status::DONE,
            "natural exit 0 → done"
        );
    }

    #[test]
    fn killing_the_job_kills_the_boot_and_marks_killed() {
        let (sink, cap) = capturing_sink();
        let proc = FakeProc::running();
        let runner = Arc::new(FakeBootRunner::yielding(proc.clone()));
        let mut st = TestBootState::with_runner(runner, sink, "ws");

        let id = st.boot(exe_target());
        st.poll();
        assert_eq!(st.jobs()[0].status, BootStatus::Running);

        // Kill the JOB → the boot is killed (bidirectional).
        assert!(st.kill(&id));
        assert!(*proc.killed.lock().unwrap(), "kill reached the boot proc");
        assert_eq!(st.jobs()[0].status, BootStatus::Killed);
        assert!(!st.any_running());

        // The ledger row is terminal (failed carries the killed detail).
        let recs = cap.lock().unwrap();
        let last = recs.iter().rev().find(|r| r.job_id == id).unwrap();
        assert!(crate::jobs::status::is_terminal(&last.status));
        assert!(
            last.detail_json.contains("killed"),
            "killed detail carried: {}",
            last.detail_json
        );

        // Killing an already-terminal job is a no-op.
        assert!(!st.kill(&id));
    }

    #[test]
    fn spawn_failure_opens_and_immediately_fails_the_job() {
        let (sink, cap) = capturing_sink();
        let runner = FakeBootRunner::yielding(FakeProc::running());
        *runner.fail.lock().unwrap() = Some("no such exe".into());
        let mut st = TestBootState::with_runner(Arc::new(runner), sink, "ws");

        let id = st.boot(exe_target());
        assert!(matches!(st.jobs()[0].status, BootStatus::Failed(_)));
        let recs = cap.lock().unwrap();
        let last = recs.iter().rev().find(|r| r.job_id == id).unwrap();
        assert_eq!(last.status, crate::jobs::status::FAILED);
    }

    /// REAL Exe boot (no fake): spawn a short-lived binary, watch it reach a
    /// terminal state, and prove the process-group kill path compiles + runs. Uses
    /// `/bin/sh -c` so we control lifetime; gated to unix.
    #[test]
    fn real_exe_boot_spawns_and_reaches_terminal() {
        let runner = Arc::new(DispatchBootRunner::default());
        let mut st = TestBootState::with_runner(runner, JobSink::noop(), "ws");
        // A sleeper we will KILL — proves the bidirectional process-group kill.
        let target = BootTarget::Exe {
            name: "sleeper".into(),
            exe: PathBuf::from("/bin/sh"),
            args: vec!["-c".into(), "sleep 30".into()],
        };
        let id = st.boot(target);
        st.poll();
        assert_eq!(
            st.jobs()[0].status,
            BootStatus::Running,
            "sleeper is running"
        );
        assert!(st.kill(&id), "kill the running sleeper");
        assert_eq!(st.jobs()[0].status, BootStatus::Killed);
    }
}