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

nordisk/Skidbladnir

src/orchestration.rs

//! RON ORCHESTRATION — the declarative provisioning layer on top of the imperative core.
//!
//! Skidbladnir already owns the *imperative* airgap core (pack · archive · fetch ·
//! unfold · install · start). This module adds the **second way to drive it**: a
//! declarative **RON** flow that an interpreter runs, dispatching each step to a
//! pluggable **steering module**. Design: `.nornir/ron-orchestration-design.md`.
//!
//! Two layers, one vocabulary:
//! - **RON-triggered (declarative):** a `.ron` describes a sequence of [`Step`](crate::orchestration::Step)s; the
//!   [`run_flow`](crate::orchestration::run_flow) interpreter executes it — data-driven, reusable, no new Rust per
//!   scenario. (Same RON-as-description shape as `.runa.ron`/jera.)
//! - **Custom (imperative):** call Skidbladnir's Rust API / a [`SteeringModule`](crate::orchestration::SteeringModule)
//!   directly for flows the RON vocabulary doesn't cover. Full power, but code.
//!
//! The seam is the [`SteeringModule`](crate::orchestration::SteeringModule) trait: each [`Step`](crate::orchestration::Step) names a module (native-first
//! registry — `KvmBoot`/`RedfishBoot`/`OciDeploy`/`Seed`/`InstallApp`/…); modules are
//! registered by name; the interpreter threads a [`RunContext`](crate::orchestration::RunContext) through the flow. A
//! `wasm` module-kind slots behind the SAME trait later (mikronova/rover), and znippy
//! packaging/verification of modules is the Phase-2 follow-up — this is Phase 1
//! (native, in-tree).
//!
//! ```
//! use skidbladnir::orchestration::{Flow, Registry, RunContext, run_flow};
//! let flow = Flow::from_ron(r##"[
//!     BootOS(backend: Kvm, os: "ubuntu-24.04"),
//!     Seed(config: "#cloud-config\n"),
//!     InstallApp(spec: "holger"),
//! ]"##).unwrap();
//! let registry = Registry::native_default();
//! // InstallApp writes its layout/unit files under `work_dir` (holger is in the
//! // built-in registry; with no bundle it renders the real service unit).
//! let mut ctx = RunContext { work_dir: Some(std::env::temp_dir().join("skidbladnir-doc-flow")), ..Default::default() };
//! let outcomes = run_flow(&flow, &registry, &mut ctx).unwrap();
//! assert_eq!(outcomes.len(), 3);
//! assert_eq!(ctx.trace[0].module_kind, "KvmBoot");
//! ```

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};

// ───────────────────────────── the vocabulary ─────────────────────────────

/// Which boot/provisioning backend a [`Step::BootOS`] targets. Each maps 1:1 to a
/// registered boot [`SteeringModule`] name (see [`Step::module_kind`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BootBackend {
    /// Existing physical server via its BMC → the `RedfishBoot` module.
    Redfish,
    /// Local KVM/qemu VM (test/convenience) → the `KvmBoot` module (real, wraps
    /// [`crate::unfold::kvm`]).
    Kvm,
    /// OCI/container deploy → the `OciDeploy` module.
    Oci,
    /// Cloud fleet via Terraform → the `TerraformApply` module.
    Terraform,
}

/// A single declarative provisioning step — the typed RON vocabulary over what
/// Skidbladnir/draupnir already own. A `.ron` flow is a `[ Step, Step, … ]` list.
///
/// RON form (enum struct variants): `BootOS(backend: Kvm, os: "ubuntu-24.04")`,
/// `Seed(config: "…")`, `InstallApp(spec: "korp")`,
/// `Configure(target: "talos", params: {"k": "v"})`, `Federate(registry: "nexus")`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Step {
    /// Boot/provision an OS on a backend so it's ready for install.
    BootOS { backend: BootBackend, os: String },
    /// Seed first-boot config (cloud-init NoCloud) onto the booted target.
    Seed { config: String },
    /// Install a registered application (reuses the [`crate::install`] lifecycle).
    InstallApp { spec: String },
    /// Configure a target subsystem with arbitrary params.
    Configure {
        target: String,
        params: BTreeMap<String, String>,
    },
    /// Federate the target with an upstream registry.
    Federate { registry: String },
    /// Migrate a product's config forward — replay its stamped schema chain
    /// (`stamped → head`) via the product's own `--migrate-config`. The refresh
    /// flow places this BETWEEN `InstallApp` (unfold the new bundle) and the
    /// `current` flip. See [`crate::migrate`].
    Migrate { product: String },
}

impl Step {
    /// The name of the [`SteeringModule`] this step dispatches to. The interpreter
    /// looks this up in the [`Registry`]; the native-first registry registers a
    /// module under each of these names.
    pub fn module_kind(&self) -> &'static str {
        match self {
            Step::BootOS { backend, .. } => match backend {
                BootBackend::Redfish => "RedfishBoot",
                BootBackend::Kvm => "KvmBoot",
                BootBackend::Oci => "OciDeploy",
                BootBackend::Terraform => "TerraformApply",
            },
            Step::Seed { .. } => "Seed",
            Step::InstallApp { .. } => "InstallApp",
            Step::Configure { .. } => "Configure",
            Step::Federate { .. } => "Federate",
            Step::Migrate { .. } => "Migrate",
        }
    }
}

/// A whole provisioning flow — the ordered [`Step`]s the interpreter runs. Parsed
/// from a `.ron` file (the design's `Scenario`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Flow {
    pub steps: Vec<Step>,
}

impl Flow {
    /// Parse a RON flow. Accepts BOTH the design's bare-list form
    /// (`[ BootOS(...), InstallApp(...) ]`) and an explicit wrapper
    /// (`Flow(steps: [ … ])`), so a `.ron` can be written either way.
    pub fn from_ron(src: &str) -> Result<Flow> {
        // Bare `Vec<Step>` first — the canonical, no-code scenario shape.
        match ron::from_str::<Vec<Step>>(src) {
            Ok(steps) => Ok(Flow { steps }),
            Err(bare_err) => ron::from_str::<Flow>(src).map_err(|wrap_err| {
                anyhow!(
                    "parse RON flow: not a `[Step, …]` list ({bare_err}) nor a \
                     `Flow(steps: […])` ({wrap_err})"
                )
            }),
        }
    }

    /// Parse a RON flow from a file on disk.
    pub fn from_ron_file(path: &Path) -> Result<Flow> {
        let src = std::fs::read_to_string(path)
            .with_context(|| format!("read RON flow {}", path.display()))?;
        Flow::from_ron(&src).with_context(|| format!("in {}", path.display()))
    }
}

// ───────────────────────────── the execution seam ─────────────────────────────

/// The mutable state threaded through a flow — steps read/write shared `vars`
/// (e.g. `booted_os`, `installed_app`), append to the dispatch `trace`, and share a
/// `work_dir` (the fs root the real install lifecycle writes under).
#[derive(Debug, Default)]
pub struct RunContext {
    /// Free-form key/value the modules pass forward (e.g. a boot step records the
    /// booted OS, an install step reads it).
    pub vars: BTreeMap<String, String>,
    /// One [`Dispatch`] appended per executed step — the audit trail of what the
    /// interpreter dispatched, in order.
    pub trace: Vec<Dispatch>,
    /// Working-dir root for modules that touch the filesystem (the real
    /// [`crate::install`] lifecycle writes its layout + unit files under here).
    pub work_dir: Option<PathBuf>,
}

/// One executed-step record in [`RunContext::trace`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Dispatch {
    /// The step's index in the flow.
    pub index: usize,
    /// The [`SteeringModule`] kind it dispatched to.
    pub module_kind: String,
    /// The module's one-line summary of what it did.
    pub summary: String,
}

/// What a [`SteeringModule::run`] returns — a fully-inspectable, data-only result so
/// a test asserts EXACTLY what the step did (the inject-assert law).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StepOutcome {
    /// The module that produced this.
    pub module_kind: String,
    /// Human one-liner.
    pub detail: String,
    /// Structured facts (argv, dir counts, rendered-unit length, …).
    pub data: BTreeMap<String, String>,
    /// `true` when a REAL subsystem ran (the KVM target/argv built from
    /// [`crate::unfold::kvm`], the [`crate::install`] lifecycle executed, the
    /// ServiceSpec rendered); `false` when the module is an honest mock/seam that
    /// only records intent (needs draupnir/hardware/a feature to be real).
    pub real: bool,
}

/// The pluggable-capability seam. A steering module wraps one capability (boot a
/// backend, seed config, install an app, …). Modules are registered by [`kind`]
/// name in a [`Registry`]; the [`run_flow`] interpreter dispatches each [`Step`] to
/// its module. Native (in-tree) now; a `wasm` module-kind slots behind the SAME
/// trait later.
///
/// [`kind`]: SteeringModule::kind
pub trait SteeringModule {
    /// The registry name this module answers to (must equal the [`Step::module_kind`]
    /// of the steps it handles).
    fn kind(&self) -> &'static str;
    /// Execute one step, threading `ctx`. `step` is guaranteed by the interpreter to
    /// be a step whose `module_kind()` == `self.kind()`.
    fn run(&self, step: &Step, ctx: &mut RunContext) -> Result<StepOutcome>;
}

/// Name → [`SteeringModule`] registry. The interpreter resolves each step's
/// [`Step::module_kind`] here.
#[derive(Default)]
pub struct Registry {
    modules: BTreeMap<String, Box<dyn SteeringModule>>,
}

impl Registry {
    /// An empty registry (register modules onto it, or start from
    /// [`Registry::native_default`]).
    pub fn new() -> Self {
        Registry {
            modules: BTreeMap::new(),
        }
    }

    /// Register a module under its own [`SteeringModule::kind`]. Chainable; a later
    /// registration of the same kind replaces the earlier (last-wins override).
    pub fn register(&mut self, module: Box<dyn SteeringModule>) -> &mut Self {
        self.modules.insert(module.kind().to_string(), module);
        self
    }

    /// The module registered for `kind`, if any.
    pub fn get(&self, kind: &str) -> Option<&dyn SteeringModule> {
        self.modules.get(kind).map(|b| b.as_ref())
    }

    /// The registered kinds (sorted).
    pub fn kinds(&self) -> Vec<&str> {
        self.modules.keys().map(|s| s.as_str()).collect()
    }

    /// The **native-first default registry**: a real [`modules::KvmBootModule`],
    /// honest-mock boots for the backends that need draupnir/hardware/a feature
    /// ([`modules::MockBootModule`] for Redfish/OCI/Terraform), a [`modules::SeedModule`],
    /// a real [`modules::InstallAppModule`] (systemd-system, no extra bundles — a
    /// caller with real product bundles builds its own), and honest-mock
    /// [`modules::ConfigureModule`] / [`modules::FederateModule`]. This is the batteries-included
    /// registry a plain `run_flow` uses; a caller swaps any module by re-`register`ing.
    pub fn native_default() -> Self {
        use modules::*;
        let mut r = Registry::new();
        r.register(Box::new(KvmBootModule::default()))
            .register(Box::new(MockBootModule::redfish()))
            .register(Box::new(MockBootModule::oci()))
            .register(Box::new(MockBootModule::terraform()))
            .register(Box::new(SeedModule))
            .register(Box::new(InstallAppModule::default()))
            .register(Box::new(ConfigureModule))
            .register(Box::new(FederateModule))
            // Plan-only by default (no runner) — a caller with real product bundles
            // + the new-gen binary attaches a `ProductBinRunner` to make it real.
            .register(Box::new(MigrateModule::default()));
        r
    }
}

// ───────────────────────────── the interpreter ─────────────────────────────

/// Run a whole [`Flow`]: for each [`Step`], resolve its [`Step::module_kind`] in the
/// `registry`, dispatch to that [`SteeringModule`], thread the `ctx` (recording a
/// [`Dispatch`] per step), and collect the [`StepOutcome`]s. An unregistered kind or
/// a module error stops the flow (naming the offending step index). This is the
/// declarative twin of the imperative core — RON drives infra setup → app install.
///
/// # An empty flow is a FAULT, not a success
///
/// A flow with zero steps is REFUSED (`empty flow`). It used to return `Ok(vec![])`,
/// which is the vacuous pass this constellation keeps paying for: a deploy that
/// reports success while applying nothing — a typo'd path, a `[]` left in a `.ron`,
/// or a filter that matched no steps all looked exactly like a clean run. A caller
/// that genuinely means "nothing to do" must say so at its own level rather than
/// route a no-op through the interpreter and read the green.
pub fn run_flow(
    flow: &Flow,
    registry: &Registry,
    ctx: &mut RunContext,
) -> Result<Vec<StepOutcome>> {
    // ONE step loop in this module. `run_flow` is `resume_flow` with nothing
    // remembered, so the resumable path can never diverge from the plain one.
    let mut journal = Journal::ephemeral();
    Ok(resume_flow(flow, registry, ctx, &mut journal)?.ran)
}

/// Resume a [`Flow`], skipping steps a [`Journal`] already records as done.
///
/// This is the **link-drop** path, and it is what makes a deployment over an
/// unreliable connection safe to simply run again. `run_flow` keeps no record: a
/// failure at step 3 of 6 means a re-run boots, seeds and installs from the top.
/// With a journal on disk, the completed steps are skipped and the flow picks up
/// where it stopped.
///
/// Offline by construction — the journal is a local RON file, so nothing here needs
/// a control plane to be reachable, which is the whole point.
///
/// # A changed flow is refused, not silently resumed
///
/// Each journal entry records a fingerprint of the step it completed. If the flow
/// has changed at an index the journal already covers, resuming would apply a mix of
/// two different deployments and report success. That bails with a named error
/// instead.
///
/// # Errors
///
/// An empty flow, an unregistered module kind, a step that fails, a journal that
/// cannot be written, or a flow that changed under the journal.
pub fn resume_flow(
    flow: &Flow,
    registry: &Registry,
    ctx: &mut RunContext,
    journal: &mut Journal,
) -> Result<ResumeReport> {
    if flow.steps.is_empty() {
        bail!(
            "empty flow: 0 steps to run — refusing to report success for a run that \
             applies nothing. A `[Step, …]` list that parsed to `[]` is almost always \
             a wrong path or an unpopulated `.ron`, not an intentional no-op."
        );
    }
    let mut report = ResumeReport::default();
    for (i, step) in flow.steps.iter().enumerate() {
        let kind = step.module_kind();
        let fingerprint = step_fingerprint(step)?;

        // Already done in an earlier attempt? Skip it — but only if it is the SAME
        // step. A different one at this index means the flow was edited between
        // attempts, and resuming would silently apply half of each.
        if let Some(done) = journal.entry(i) {
            if done.fingerprint == fingerprint {
                report.skipped.push(i);
                continue;
            }
            bail!(
                "step {i} ({kind}) changed since the journal recorded it \
                 (journalled `{}` as {}, now {}) — refusing to resume a flow that is \
                 no longer the flow that was interrupted. Start a fresh journal to \
                 run this flow from the top.",
                done.module_kind,
                &done.fingerprint[..12],
                &fingerprint[..12]
            );
        }

        let module = registry.get(kind).ok_or_else(|| {
            anyhow!("step {i}: no SteeringModule registered for kind `{kind}` (step {step:?})")
        })?;
        let outcome = module
            .run(step, ctx)
            .with_context(|| format!("step {i} ({kind})"))?;
        ctx.trace.push(Dispatch {
            index: i,
            module_kind: kind.to_string(),
            summary: outcome.detail.clone(),
        });
        // Record only AFTER the step returned Ok. A step journalled before it ran
        // would be skipped forever on the next attempt having never happened —
        // the one ordering mistake that turns a resume into silent data loss.
        journal.record(JournalEntry {
            index: i,
            module_kind: kind.to_string(),
            fingerprint,
            detail: outcome.detail.clone(),
        })?;
        report.ran.push(outcome);
    }
    crate::surface_status!(
        "skidbladnir/orchestration",
        "run_flow",
        true,
        "ran {} of {} RON steps ({} resumed): {}",
        report.ran.len(),
        flow.steps.len(),
        report.skipped.len(),
        report
            .ran
            .iter()
            .map(|o| o.module_kind.as_str())
            .collect::<Vec<_>>()
            .join(" → ")
    );
    Ok(report)
}

// ───────────────────────────── the resume journal ─────────────────────────────

/// What a [`resume_flow`] attempt did: the steps it ran, and the ones it skipped.
///
/// Kept as data rather than a count so a caller can report "resumed 3, ran 3" and a
/// test can assert exactly which steps were skipped.
#[derive(Debug, Default)]
pub struct ResumeReport {
    /// Outcomes for the steps this attempt actually executed, in order.
    pub ran: Vec<StepOutcome>,
    /// Indices skipped because the journal already recorded them as done.
    pub skipped: Vec<usize>,
}

impl ResumeReport {
    /// Did this attempt execute nothing because everything was already done?
    pub fn fully_resumed(&self) -> bool {
        self.ran.is_empty() && !self.skipped.is_empty()
    }
}

/// One completed step, as recorded on disk.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JournalEntry {
    /// The step's index in the flow.
    pub index: usize,
    /// The module that completed it.
    pub module_kind: String,
    /// [`step_fingerprint`] of the step, so a changed flow cannot be resumed onto.
    pub fingerprint: String,
    /// The module's one-line summary, for a human reading the journal.
    pub detail: String,
}

/// An append-only, on-disk record of which flow steps have completed.
///
/// The file is RON — the same description language the flow itself is written in, so
/// an interrupted deployment can be inspected with an editor and no tooling. It is
/// rewritten in full after each step and synced, because a journal that loses its
/// last entry to a page cache claims a completed step never happened.
///
/// [`Journal::ephemeral`] is the in-memory form `run_flow` uses, so the plain and
/// resumable paths share one loop.
#[derive(Debug, Default)]
pub struct Journal {
    path: Option<PathBuf>,
    entries: Vec<JournalEntry>,
}

impl Journal {
    /// A journal that remembers nothing and writes nowhere.
    pub fn ephemeral() -> Self {
        Journal::default()
    }

    /// Open the journal at `path`, reading any entries a previous attempt left.
    ///
    /// A missing file is an empty journal — the first attempt at this deployment.
    ///
    /// # Errors
    ///
    /// If the file exists but cannot be read or is not a valid journal. It is
    /// deliberately NOT treated as empty: silently starting from the top is how a
    /// resume turns into a re-run of steps that already happened.
    pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
        let path = path.into();
        let entries = match std::fs::read_to_string(&path) {
            Ok(src) => ron::from_str::<Vec<JournalEntry>>(&src).with_context(|| {
                format!(
                    "read resume journal {} — refusing to treat an unreadable journal \
                     as 'nothing done yet'",
                    path.display()
                )
            })?,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
            Err(e) => {
                return Err(e).with_context(|| format!("open resume journal {}", path.display()));
            }
        };
        Ok(Journal {
            path: Some(path),
            entries,
        })
    }

    /// The recorded entry for step `index`, if that step has completed.
    pub fn entry(&self, index: usize) -> Option<&JournalEntry> {
        self.entries.iter().find(|e| e.index == index)
    }

    /// Every recorded entry, in the order they completed.
    pub fn entries(&self) -> &[JournalEntry] {
        &self.entries
    }

    /// Record a completed step and flush it to disk.
    ///
    /// # Errors
    ///
    /// If the journal file cannot be written. This is reported rather than ignored:
    /// a step that ran but could not be journalled will run again on the next
    /// attempt, and the caller is entitled to know that before the link drops.
    pub fn record(&mut self, entry: JournalEntry) -> Result<()> {
        self.entries.push(entry);
        let Some(path) = self.path.clone() else {
            return Ok(());
        };
        let src = ron::ser::to_string_pretty(&self.entries, ron::ser::PrettyConfig::default())
            .context("serialise resume journal")?;
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("create journal dir {}", parent.display()))?;
        }
        std::fs::write(&path, src.as_bytes())
            .with_context(|| format!("write resume journal {}", path.display()))?;
        // Durability: the next attempt reads this file to decide what NOT to redo.
        let f = std::fs::File::open(&path)
            .with_context(|| format!("reopen journal to sync {}", path.display()))?;
        f.sync_all()
            .with_context(|| format!("sync resume journal {}", path.display()))?;
        Ok(())
    }
}

/// A content fingerprint of one [`Step`] — its RON form, hashed.
///
/// Identity comes from what the step *says*, not from its position, so an edited
/// step is a different step even at the same index. That is what
/// [`resume_flow`] compares to refuse resuming a flow that changed.
///
/// # Errors
///
/// If the step cannot be serialised to RON.
pub fn step_fingerprint(step: &Step) -> Result<String> {
    let ron = ron::to_string(step).context("serialise step for its fingerprint")?;
    Ok(nornir_hash::sha256_hex(ron.as_bytes()))
}

/// Convenience: parse a RON flow and run it against `registry` + `ctx` in one call.
pub fn run_ron(src: &str, registry: &Registry, ctx: &mut RunContext) -> Result<Vec<StepOutcome>> {
    let flow = Flow::from_ron(src)?;
    run_flow(&flow, registry, ctx)
}

// ───────────────────────────── the native modules ─────────────────────────────

/// The native (in-tree, Phase-1) steering modules — each wraps Skidbladnir's
/// existing imperative core, or is a clearly-marked honest mock where the real call
/// needs draupnir/hardware/a feature.
pub mod modules {
    use super::*;
    use crate::service::Backend;

    fn bootos<'a>(step: &'a Step, expect: &str) -> Result<(&'a BootBackend, &'a str)> {
        match step {
            Step::BootOS { backend, os } => Ok((backend, os.as_str())),
            other => bail!("{expect} module got a non-BootOS step: {other:?}"),
        }
    }

    /// **REAL** — `KvmBoot`. Wraps the existing [`crate::unfold::kvm::KvmTarget`]: it
    /// constructs the real target and builds its **qemu argv** (the tested artifact)
    /// and probes `/dev/kvm` — all real code, no hardware needed. The actual VM
    /// *spawn* is gated on `boot` (and qemu on PATH) via [`crate::unfold::Unfold`];
    /// left off in tests, the argv-build is the real, asserted output.
    ///
    /// To be *fully* real (boot a guest) it needs qemu present + a real `disk` image;
    /// that is the only hardware/tooling gap.
    pub struct KvmBootModule {
        pub memory_mb: u32,
        pub cpus: u32,
        /// Disk image to boot; empty → `<os>.qcow2` placeholder (argv only).
        pub disk: String,
        /// When `true` AND qemu is present, actually spawn the VM (blocking).
        pub boot: bool,
    }

    impl Default for KvmBootModule {
        fn default() -> Self {
            KvmBootModule {
                memory_mb: 2048,
                cpus: 2,
                disk: String::new(),
                boot: false,
            }
        }
    }

    impl SteeringModule for KvmBootModule {
        fn kind(&self) -> &'static str {
            "KvmBoot"
        }

        fn run(&self, step: &Step, ctx: &mut RunContext) -> Result<StepOutcome> {
            let (_backend, os) = bootos(step, "KvmBoot")?;
            let disk = if self.disk.is_empty() {
                format!("{os}.qcow2")
            } else {
                self.disk.clone()
            };
            // REAL: construct the existing-core KvmTarget + its tested qemu argv.
            let mut target = crate::unfold::kvm::KvmTarget::new(format!("vm-{os}"), disk);
            target.memory_mb = self.memory_mb;
            target.cpus = self.cpus;
            let argv = target.qemu_argv();
            let accel = crate::unfold::kvm::KvmTarget::kvm_available();

            let mut spawned = false;
            if self.boot {
                use crate::unfold::Unfold;
                target
                    .unfold()
                    .with_context(|| format!("spawn KVM guest for os `{os}`"))?;
                spawned = true;
            }

            ctx.vars.insert("booted_os".into(), os.to_string());
            ctx.vars.insert("boot_backend".into(), "kvm".into());

            let mut data = BTreeMap::new();
            data.insert("os".into(), os.to_string());
            data.insert("qemu_argc".into(), argv.len().to_string());
            data.insert("qemu_argv".into(), argv.join(" "));
            data.insert("kvm_accel".into(), accel.to_string());
            data.insert("spawned".into(), spawned.to_string());

            Ok(StepOutcome {
                module_kind: self.kind().into(),
                detail: format!(
                    "KvmBoot: real KvmTarget for os `{os}` ({} qemu args, kvm_accel={accel}, spawned={spawned})",
                    argv.len()
                ),
                data,
                real: true,
            })
        }
    }

    /// **MOCK (honest seam)** — a boot module for a backend whose real drive needs
    /// draupnir/hardware/a feature (Redfish BMC, OCI runtime, the terraform binary).
    /// It records the intent into the [`RunContext`] and returns `real: false` with a
    /// note on what it needs to be real. Swap it for the native driver once wired.
    pub struct MockBootModule {
        kind: &'static str,
        needs: &'static str,
    }

    impl MockBootModule {
        /// `RedfishBoot` mock — real needs the `redfish` feature + a reachable BMC
        /// (or draupnir's Redfish drive).
        pub fn redfish() -> Self {
            MockBootModule {
                kind: "RedfishBoot",
                needs: "the `redfish` feature + a reachable BMC",
            }
        }
        /// `OciDeploy` mock — real needs draupnir's OCI engine (bollard) wired in.
        pub fn oci() -> Self {
            MockBootModule {
                kind: "OciDeploy",
                needs: "draupnir's OCI engine (feature `pack-oci`/`msi-oci`)",
            }
        }
        /// `TerraformApply` mock — real needs the `terraform` feature + the terraform binary.
        pub fn terraform() -> Self {
            MockBootModule {
                kind: "TerraformApply",
                needs: "the `terraform` feature + the terraform binary",
            }
        }
    }

    impl SteeringModule for MockBootModule {
        fn kind(&self) -> &'static str {
            self.kind
        }

        fn run(&self, step: &Step, ctx: &mut RunContext) -> Result<StepOutcome> {
            let (backend, os) = bootos(step, self.kind)?;
            ctx.vars.insert("booted_os".into(), os.to_string());
            let mut data = BTreeMap::new();
            data.insert("os".into(), os.to_string());
            data.insert("backend".into(), format!("{backend:?}"));
            data.insert("needs".into(), self.needs.into());
            Ok(StepOutcome {
                module_kind: self.kind.into(),
                detail: format!(
                    "{}: MOCK boot of os `{os}` on {backend:?} — real needs {}",
                    self.kind, self.needs
                ),
                data,
                real: false,
            })
        }
    }

    /// **MOCK (honest seam)** — `Seed`. NoCloud (cloud-init) seed authoring is owned
    /// by `draupnir::seed::build_seed_image` (behind the `kvm` feature); without it
    /// this validates + records the config and returns `real: false`. Real needs
    /// `draupnir/seed` to author the CIDATA FAT image.
    #[derive(Default)]
    pub struct SeedModule;

    impl SteeringModule for SeedModule {
        fn kind(&self) -> &'static str {
            "Seed"
        }

        fn run(&self, step: &Step, ctx: &mut RunContext) -> Result<StepOutcome> {
            let config = match step {
                Step::Seed { config } => config,
                other => bail!("Seed module got a non-Seed step: {other:?}"),
            };
            if config.trim().is_empty() {
                bail!("Seed: empty cloud-init config");
            }
            ctx.vars
                .insert("seed_bytes".into(), config.len().to_string());
            let mut data = BTreeMap::new();
            data.insert("config_bytes".into(), config.len().to_string());
            data.insert(
                "cloud_config".into(),
                config.trim_start().starts_with("#cloud-config").to_string(),
            );
            Ok(StepOutcome {
                module_kind: "Seed".into(),
                detail: format!(
                    "Seed: recorded {}-byte NoCloud config — real authoring needs draupnir/seed (CIDATA image)",
                    config.len()
                ),
                data,
                real: false,
            })
        }
    }

    /// **REAL** — `InstallApp`. Reuses Skidbladnir's existing install lifecycle: it
    /// resolves the product's [`crate::product::ComponentSpec`] (from the built-in
    /// [`crate::product::lookup`] registry, or the module's `extra_specs` for apps
    /// not yet in the registry, e.g. `korp`) and, given a real product bundle,
    /// executes the FULL [`crate::install::install_product`] lifecycle into a
    /// [`crate::layout::Layout`] under `ctx.work_dir` — provisioning the per-product
    /// software/data/log tree, unfolding the bundle, minting identity, rendering the
    /// service unit, and returning the ordered enable/start verbs. Without a bundle
    /// it still RENDERS the neutral [`crate::service::ServiceSpec`] via the real
    /// [`crate::service::renderer_for`] tree (the ServiceSpec-reuse path) and reports
    /// it — `real: true` either way, since real core ran.
    pub struct InstallAppModule {
        /// The service backend to install onto (default systemd-system).
        pub backend: Backend,
        /// Airgap generation for the [`crate::layout::Layout`].
        pub version: String,
        /// Product name → a real product bundle (a `.tar.zst`/znippy archive). When
        /// present for the step's spec, the FULL install lifecycle runs.
        pub bundles: BTreeMap<String, PathBuf>,
        /// Specs for apps not in the built-in registry (e.g. `korp`), merged OVER
        /// [`crate::product::lookup`].
        pub extra_specs: BTreeMap<String, crate::product::ComponentSpec>,
    }

    impl Default for InstallAppModule {
        fn default() -> Self {
            InstallAppModule {
                backend: Backend::SystemdSystem,
                version: "00000000".into(),
                bundles: BTreeMap::new(),
                extra_specs: BTreeMap::new(),
            }
        }
    }

    impl InstallAppModule {
        /// Register a real product bundle for `product` (enables the full install path).
        pub fn with_bundle(
            mut self,
            product: impl Into<String>,
            bundle: impl Into<PathBuf>,
        ) -> Self {
            self.bundles.insert(product.into(), bundle.into());
            self
        }
        /// Register a [`crate::product::ComponentSpec`] for an app not in the built-in
        /// registry (resolved before `product::lookup`).
        pub fn with_spec(mut self, spec: crate::product::ComponentSpec) -> Self {
            self.extra_specs.insert(spec.name.clone(), spec);
            self
        }
    }

    impl SteeringModule for InstallAppModule {
        fn kind(&self) -> &'static str {
            "InstallApp"
        }

        fn run(&self, step: &Step, ctx: &mut RunContext) -> Result<StepOutcome> {
            let name = match step {
                Step::InstallApp { spec } => spec,
                other => bail!("InstallApp module got a non-InstallApp step: {other:?}"),
            };
            // Resolve the spec: module-local specs first (e.g. korp), then the registry.
            let spec = self
                .extra_specs
                .get(name)
                .cloned()
                .or_else(|| crate::product::lookup(name))
                .ok_or_else(|| {
                    anyhow!("InstallApp: unknown product `{name}` (not in module specs nor product::registry)")
                })?;

            let work = ctx
                .work_dir
                .clone()
                .ok_or_else(|| anyhow!("InstallApp `{name}`: RunContext.work_dir must be set"))?;
            let layout = crate::layout::Layout {
                data_dir: work.join("data"),
                log_dir: work.join("log"),
                software_dir: work.join("software"),
                version: self.version.clone(),
            };

            let mut data = BTreeMap::new();
            data.insert("app".into(), name.clone());
            data.insert("backend".into(), self.backend.slug().into());

            if let Some(bundle) = self.bundles.get(name) {
                // FULL real lifecycle: provision tree · unfold bundle · mint identity ·
                // render + write unit files · return enable/start verbs.
                let root = work.join("fsroot");
                let out = crate::install::install_product(
                    &spec,
                    &layout,
                    &crate::install::UpgradeSource::bundle(bundle)?,
                    &crate::archive::TarZstd::new(),
                    self.backend,
                    &root,
                )
                .with_context(|| format!("install_product `{name}`"))?;

                ctx.vars.insert("installed_app".into(), name.clone());
                data.insert("mode".into(), "install_product".into());
                data.insert("dirs".into(), out.dirs.len().to_string());
                data.insert("files_written".into(), out.files_written.len().to_string());
                data.insert("verbs".into(), out.verbs.len().to_string());
                data.insert("identity".into(), out.identity.is_some().to_string());
                data.insert("op".into(), format!("{:?}", out.op));

                Ok(StepOutcome {
                    module_kind: "InstallApp".into(),
                    detail: format!(
                        "InstallApp: REAL install of `{name}` via {} — {} dirs, {} unit files, {} verbs, identity minted",
                        self.backend.slug(),
                        out.dirs.len(),
                        out.files_written.len(),
                        out.verbs.len()
                    ),
                    data,
                    real: true,
                })
            } else {
                // No bundle: still exercise the REAL ServiceSpec render path (reuse).
                let svc = spec.to_service_spec(&layout, self.backend);
                let renderer = crate::service::renderer_for(self.backend).ok_or_else(|| {
                    anyhow!(
                        "InstallApp `{name}`: no ServiceRenderer for {:?}",
                        self.backend
                    )
                })?;
                let rendered = renderer.render(&svc)?;
                let unit_bytes: usize = rendered.files.iter().map(|f| f.contents.len()).sum();

                ctx.vars.insert("rendered_app".into(), name.clone());
                data.insert("mode".into(), "render_only".into());
                data.insert("rendered_files".into(), rendered.files.len().to_string());
                data.insert("unit_bytes".into(), unit_bytes.to_string());

                Ok(StepOutcome {
                    module_kind: "InstallApp".into(),
                    detail: format!(
                        "InstallApp: rendered `{name}` service unit via {} ({} files, {unit_bytes} B) — no bundle registered, install lifecycle skipped",
                        self.backend.slug(),
                        rendered.files.len()
                    ),
                    data,
                    real: true,
                })
            }
        }
    }

    /// **MOCK (honest seam)** — `Configure`. Records the target + params into the
    /// context. Real needs the target-specific driver (a Talos machineconfig push, a
    /// systemd drop-in, …) — those live in [`crate::unfold`]/[`crate::talos_apid`]
    /// behind features. `real: false`.
    pub struct ConfigureModule;

    impl SteeringModule for ConfigureModule {
        fn kind(&self) -> &'static str {
            "Configure"
        }

        fn run(&self, step: &Step, ctx: &mut RunContext) -> Result<StepOutcome> {
            let (target, params) = match step {
                Step::Configure { target, params } => (target, params),
                other => bail!("Configure module got a non-Configure step: {other:?}"),
            };
            for (k, v) in params {
                ctx.vars.insert(format!("cfg.{target}.{k}"), v.clone());
            }
            let mut data = BTreeMap::new();
            data.insert("target".into(), target.clone());
            data.insert("params".into(), params.len().to_string());
            Ok(StepOutcome {
                module_kind: "Configure".into(),
                detail: format!(
                    "Configure: MOCK — recorded {} param(s) for target `{target}` (real needs the target driver)",
                    params.len()
                ),
                data,
                real: false,
            })
        }
    }

    /// **MOCK (honest seam)** — `Federate`. Records the upstream registry. Real needs
    /// the `federation` feature (the [`crate::federation`] `UpstreamRegistry` clients).
    /// `real: false`.
    pub struct FederateModule;

    impl SteeringModule for FederateModule {
        fn kind(&self) -> &'static str {
            "Federate"
        }

        fn run(&self, step: &Step, ctx: &mut RunContext) -> Result<StepOutcome> {
            let registry = match step {
                Step::Federate { registry } => registry,
                other => bail!("Federate module got a non-Federate step: {other:?}"),
            };
            ctx.vars
                .insert("federated_registry".into(), registry.clone());
            let mut data = BTreeMap::new();
            data.insert("registry".into(), registry.clone());
            Ok(StepOutcome {
                module_kind: "Federate".into(),
                detail: format!(
                    "Federate: MOCK — recorded upstream `{registry}` (real needs the `federation` feature)"
                ),
                data,
                real: false,
            })
        }
    }

    /// `Migrate` — the RON seam over [`crate::migrate::replay_migrations`]. It reads the
    /// product's schema stamp beside `ctx.work_dir/data/<product>`, diffs it against the
    /// product's declared `chain`, and replays the `stamped → head` hops.
    ///
    /// - With an injected [`crate::migrate::MigrateRunner`] (production: a
    ///   [`crate::migrate::ProductBinRunner`] on the new generation's binary) it runs
    ///   REAL hops — `real: true`.
    /// - Without a runner it is an honest seam: it records the PLANNED hops (via
    ///   [`crate::migrate::migration_steps`]) without executing them — `real: false`.
    ///
    /// A product with no `chain` entry is a no-op (nothing to migrate yet).
    #[derive(Default)]
    pub struct MigrateModule {
        /// Product name → its ordered schema chain (oldest → head).
        pub chains: BTreeMap<String, Vec<String>>,
        /// Performs each hop. `None` ⇒ plan-only honest seam.
        pub runner: Option<Box<dyn crate::migrate::MigrateRunner>>,
    }

    impl MigrateModule {
        /// Declare a product's ordered schema chain.
        pub fn with_chain(mut self, product: impl Into<String>, chain: Vec<String>) -> Self {
            self.chains.insert(product.into(), chain);
            self
        }
        /// Attach the real hop runner (makes the module `real: true`).
        pub fn with_runner(mut self, runner: Box<dyn crate::migrate::MigrateRunner>) -> Self {
            self.runner = Some(runner);
            self
        }
    }

    impl SteeringModule for MigrateModule {
        fn kind(&self) -> &'static str {
            "Migrate"
        }

        fn run(&self, step: &Step, ctx: &mut RunContext) -> Result<StepOutcome> {
            let product = match step {
                Step::Migrate { product } => product,
                other => bail!("Migrate module got a non-Migrate step: {other:?}"),
            };
            let chain = self.chains.get(product).cloned().unwrap_or_default();
            let work = ctx
                .work_dir
                .clone()
                .ok_or_else(|| anyhow!("Migrate `{product}`: RunContext.work_dir must be set"))?;
            let data_dir = work.join("data").join(product);

            let mut data = BTreeMap::new();
            data.insert("product".into(), product.clone());
            data.insert("chain_len".into(), chain.len().to_string());

            if chain.is_empty() {
                return Ok(StepOutcome {
                    module_kind: "Migrate".into(),
                    detail: format!(
                        "Migrate: `{product}` declares no schema chain — nothing to migrate"
                    ),
                    data,
                    real: false,
                });
            }

            if let Some(runner) = &self.runner {
                let rep =
                    crate::migrate::replay_migrations(product, &data_dir, &chain, runner.as_ref())
                        .with_context(|| format!("Migrate `{product}`"))?;
                ctx.vars
                    .insert(format!("migrated.{product}"), rep.now_stamped.clone());
                data.insert("hops".into(), rep.replayed.len().to_string());
                data.insert("now_stamped".into(), rep.now_stamped.clone());
                data.insert("noop".into(), rep.was_noop.to_string());
                Ok(StepOutcome {
                    module_kind: "Migrate".into(),
                    detail: format!(
                        "Migrate: REAL — `{product}` replayed {} hop(s) → stamped `{}`{}",
                        rep.replayed.len(),
                        rep.now_stamped,
                        if rep.was_noop { " (no-op)" } else { "" }
                    ),
                    data,
                    real: true,
                })
            } else {
                // Plan-only: read the stamp (if any) and compute the hops we WOULD run.
                let stamped = crate::migrate::read_stamp(&data_dir)?.map(|s| s.schema_version);
                let planned = match &stamped {
                    Some(s) => crate::migrate::migration_steps(s, &chain)?,
                    None => Vec::new(),
                };
                data.insert("planned_hops".into(), planned.len().to_string());
                data.insert(
                    "stamped".into(),
                    stamped.clone().unwrap_or_else(|| "<none>".into()),
                );
                Ok(StepOutcome {
                    module_kind: "Migrate".into(),
                    detail: format!(
                        "Migrate: seam — `{product}` would replay {} hop(s) from `{}` (no runner attached)",
                        planned.len(),
                        stamped.as_deref().unwrap_or("<none>")
                    ),
                    data,
                    real: false,
                })
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::archive::{Archive, TarZstd};
    use crate::product::ComponentSpec;

    /// A tiny real product bundle (TarZstd) — mirrors `install.rs`'s `product_bundle`.
    fn product_bundle(spec: &ComponentSpec, version: &str) -> (tempfile::TempDir, PathBuf) {
        let dir = tempfile::tempdir().unwrap();
        let staging = dir.path().join("staging");
        let bin = staging.join(&spec.bundle_binary);
        std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
        std::fs::write(&bin, b"#!/fake app binary\n").unwrap();
        std::fs::write(staging.join("README"), b"app product tree\n").unwrap();
        let bundle = dir.path().join("app.tar.zst");
        TarZstd::new().pack(&staging, version, &bundle).unwrap();
        (dir, bundle)
    }

    /// LAW 1 (inject-assert): the design's example RON parses into the typed Step
    /// vocabulary — bare-list form.
    #[test]
    fn ron_flow_parses_the_design_example() {
        let flow = Flow::from_ron(
            r##"[
                BootOS(backend: Kvm, os: "ubuntu-24.04"),
                Seed(config: "#cloud-config\npackages: [curl]\n"),
                InstallApp(spec: "korp"),
            ]"##,
        )
        .unwrap();
        assert_eq!(flow.steps.len(), 3);
        assert!(
            matches!(&flow.steps[0], Step::BootOS { backend: BootBackend::Kvm, os } if os == "ubuntu-24.04")
        );
        assert!(matches!(&flow.steps[1], Step::Seed { .. }));
        assert!(matches!(&flow.steps[2], Step::InstallApp { spec } if spec == "korp"));
        // Steps route to the native-first module names.
        assert_eq!(flow.steps[0].module_kind(), "KvmBoot");
        assert_eq!(flow.steps[2].module_kind(), "InstallApp");
    }

    /// The wrapper form `Flow(steps: [...])` parses too, and Flow round-trips serde.
    #[test]
    fn ron_wrapper_form_and_roundtrip() {
        let flow = Flow::from_ron(r#"Flow(steps: [ Federate(registry: "nexus") ])"#).unwrap();
        assert_eq!(flow.steps.len(), 1);
        let json = serde_json::to_string(&flow).unwrap();
        let back: Flow = serde_json::from_str(&json).unwrap();
        assert_eq!(back, flow);
    }

    /// THE worked end-to-end: a RON flow drives infra setup (KVM boot + seed) and
    /// finally installs the application — dispatched to the right modules, with the
    /// app-install step reaching the REAL install lifecycle. RON → infra → app.
    #[test]
    fn ron_drives_infra_then_installs_the_app() {
        // korp isn't in the built-in registry — supply its ComponentSpec + a real bundle.
        let korp = ComponentSpec::new("korp", "linux-x86_64/korp", "_korp")
            .with_arg("serve")
            .with_port(9000);
        let (_guard, bundle) = product_bundle(&korp, "20260728");

        let install = modules::InstallAppModule::default()
            .with_spec(korp.clone())
            .with_bundle("korp", &bundle);

        // Native registry, but swap in the install module carrying korp's spec+bundle.
        let mut registry = Registry::native_default();
        registry.register(Box::new(install));

        let flow = Flow::from_ron(
            r##"[
                BootOS(backend: Kvm, os: "ubuntu-24.04"),
                Seed(config: "#cloud-config\npackages: [curl]\n"),
                InstallApp(spec: "korp"),
            ]"##,
        )
        .unwrap();

        let work = tempfile::tempdir().unwrap();
        let mut ctx = RunContext {
            work_dir: Some(work.path().to_path_buf()),
            ..Default::default()
        };

        // Need the install module's version to match the layout it builds; default
        // module uses "00000000" — fine, install_product uses layout.version for the
        // software generation, independent of the bundle's VERSION.
        let outcomes = run_flow(&flow, &registry, &mut ctx).unwrap();

        // ── every step dispatched to the RIGHT module, in order ──
        assert_eq!(outcomes.len(), 3);
        assert_eq!(
            ctx.trace
                .iter()
                .map(|d| d.module_kind.as_str())
                .collect::<Vec<_>>(),
            vec!["KvmBoot", "Seed", "InstallApp"]
        );

        // ── infra: KVM boot is REAL (built a real qemu argv), seed recorded ──
        assert!(outcomes[0].real, "KvmBoot ran the real KvmTarget");
        assert!(outcomes[0].data["qemu_argc"].parse::<usize>().unwrap() > 0);
        assert_eq!(ctx.vars["booted_os"], "ubuntu-24.04");
        assert_eq!(ctx.vars["boot_backend"], "kvm");
        assert!(
            !outcomes[1].real,
            "Seed is an honest mock (needs draupnir/seed)"
        );

        // ── the APP-INSTALL step reached the REAL install lifecycle ──
        let app = &outcomes[2];
        assert_eq!(app.module_kind, "InstallApp");
        assert!(
            app.real,
            "InstallApp ran the real install_product lifecycle"
        );
        assert_eq!(app.data["mode"], "install_product");
        assert_eq!(app.data["op"], "Install");
        assert_eq!(app.data["identity"], "true");
        assert!(
            app.data["verbs"].parse::<usize>().unwrap() >= 2,
            "enable+start verbs"
        );
        assert_eq!(ctx.vars["installed_app"], "korp");

        // ── and the real filesystem effects landed: korp's software/data/log tree ──
        let sw = work
            .path()
            .join("software")
            .join("current")
            .join("korp")
            .join(&korp.bundle_binary);
        assert!(
            sw.exists(),
            "korp binary unfolded through current/: {}",
            sw.display()
        );
        assert!(
            work.path()
                .join("data")
                .join("korp")
                .join("identity.toml")
                .is_file(),
            "identity minted"
        );
    }

    /// The render-only path (no bundle) still runs the REAL ServiceSpec renderer.
    #[test]
    fn install_app_without_bundle_renders_the_real_service_unit() {
        let install = modules::InstallAppModule::default(); // no bundles, no extra specs
        let mut registry = Registry::new();
        registry.register(Box::new(install));

        let work = tempfile::tempdir().unwrap();
        let mut ctx = RunContext {
            work_dir: Some(work.path().to_path_buf()),
            ..Default::default()
        };
        // holger IS in the built-in registry.
        let outcomes = run_ron(r#"[ InstallApp(spec: "holger") ]"#, &registry, &mut ctx).unwrap();
        assert_eq!(outcomes[0].data["mode"], "render_only");
        assert!(outcomes[0].data["unit_bytes"].parse::<usize>().unwrap() > 0);
        assert!(outcomes[0].real);
    }

    /// An unregistered module kind is a clear, step-indexed error (not a panic).
    #[test]
    fn unregistered_kind_errors_with_step_index() {
        let registry = Registry::new(); // empty
        let flow = Flow::from_ron(r#"[ BootOS(backend: Redfish, os: "talos") ]"#).unwrap();
        let mut ctx = RunContext::default();
        let err = run_flow(&flow, &registry, &mut ctx)
            .unwrap_err()
            .to_string();
        assert!(
            err.contains("RedfishBoot"),
            "names the missing module: {err}"
        );
        assert!(err.contains("step 0"), "names the step index: {err}");
    }

    /// The native default registry carries every step's module kind.
    #[test]
    fn native_default_registry_covers_the_vocabulary() {
        let r = Registry::native_default();
        for kind in [
            "KvmBoot",
            "RedfishBoot",
            "OciDeploy",
            "TerraformApply",
            "Seed",
            "InstallApp",
            "Configure",
            "Federate",
            "Migrate",
        ] {
            assert!(r.get(kind).is_some(), "native registry missing `{kind}`");
        }
    }

    /// A RON flow can drive `Migrate` as a step: install → migrate → (flip elsewhere).
    /// With a real runner attached the module replays the product's chain for real and
    /// stamps the head — the SteeringModule seam the refresh flow uses.
    #[test]
    fn ron_migrate_step_replays_via_module() {
        use modules::MigrateModule;
        let work = tempfile::tempdir().unwrap();
        // A product whose data is stamped at an old schema.
        let data = work.path().join("data").join("korp");
        crate::migrate::write_stamp(&data, &crate::migrate::MigrationStamp::now("korp", "v0"))
            .unwrap();

        let flow = Flow::from_ron(r#"[ Migrate(product: "korp") ]"#).unwrap();
        assert_eq!(flow.steps[0].module_kind(), "Migrate");

        let runner = crate::migrate::RecordingRunner::new();
        // Build a registry whose Migrate module knows korp's chain + a real runner.
        let mut reg = Registry::native_default();
        reg.register(Box::new(
            MigrateModule::default()
                .with_chain("korp", vec!["v0".into(), "v1".into(), "v2".into()])
                .with_runner(Box::new(crate::migrate::RecordingRunner::new())),
        ));
        // (the registry owns its runner; assert via the outcome data below)
        let _ = &runner;

        let mut ctx = RunContext {
            work_dir: Some(work.path().to_path_buf()),
            ..Default::default()
        };
        let outcomes = run_flow(&flow, &reg, &mut ctx).unwrap();
        assert_eq!(outcomes.len(), 1);
        assert!(outcomes[0].real, "with a runner the Migrate module is real");
        assert_eq!(outcomes[0].data.get("hops").map(String::as_str), Some("2"));
        assert_eq!(
            outcomes[0].data.get("now_stamped").map(String::as_str),
            Some("v2")
        );
        assert_eq!(
            ctx.vars.get("migrated.korp").map(String::as_str),
            Some("v2")
        );
    }
}