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, ®istry, &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, ®istry, &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") ]"#, ®istry, &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, ®istry, &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, ®, &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")
);
}
}