<!-- nornir:class: design -->
# Skidbladnir custom objects — the custom-code call-out seam
> **Class:** `design` · Source of truth: [`src/orchestration.rs`](../src/orchestration.rs)
> + [`src/service.rs`](../src/service.rs) + [`src/install.rs`](../src/install.rs).
> Verified against nornir `78deafc`. Companion to [`ron-schema.md`](ron-schema.md) (the
> RON object model) and [`ron-orchestration-design.md`](ron-orchestration-design.md) (the
> original design capture).
A RON `Step` is a **name plus data**. It doesn't contain code — it names a **custom
object** (a `SteeringModule`) that carries the Rust code which actually does the work.
This is the pluggable-backend, "not-limited-to-systemd" seam: the RON stays declarative,
and the imperative capability behind each step is swappable.
## The seam — the `SteeringModule` trait
```rust
pub trait SteeringModule { // orchestration.rs:186
/// The registry name this module answers to (== the Step's module_kind()).
fn kind(&self) -> &'static str;
/// Execute one step, threading the shared RunContext.
fn run(&self, step: &Step, ctx: &mut RunContext) -> Result<StepOutcome>;
}
```
Two methods:
- **`kind()`** — the string the module registers under. It MUST equal the
[`Step::module_kind()`](../src/orchestration.rs) (`orchestration.rs:85`) of every step
it handles.
- **`run()`** — the custom code. It receives the step's data and a mutable
[`RunContext`](../src/orchestration.rs) (`orchestration.rs:139`), does the work
(provision infra, seed, install…), reads/writes shared `ctx.vars`, and returns a
[`StepOutcome`](../src/orchestration.rs) (`orchestration.rs:165`) — plain data plus a
`real: bool` flag stating whether a real subsystem ran or it was an honest mock.
## How a RON node names and invokes custom code
The dispatch is a three-hop lookup, all inside
[`run_flow`](../src/orchestration.rs) (`orchestration.rs:255`) — **this is the single
dispatch point**:
```
Step --Step::module_kind()--> "KvmBoot" --Registry::get()--> &dyn SteeringModule --.run(step, ctx)--> StepOutcome
(orchestration.rs:85) (orchestration.rs:217) (orchestration.rs:262)
```
1. For each step, `run_flow` calls `step.module_kind()` to get the module name.
2. It looks that name up in the [`Registry`](../src/orchestration.rs)
(`orchestration.rs:198`) via `Registry::get()`.
3. It calls `module.run(step, ctx)`, pushes a `Dispatch` row onto `ctx.trace`, and
collects the `StepOutcome`.
An unregistered kind is a **step-indexed error**, not a panic
(`orchestration.rs:259` — verified by `unregistered_kind_errors_with_step_index`,
`orchestration.rs:805`).
### The registry — name → module
[`Registry`](../src/orchestration.rs) is a `BTreeMap<String, Box<dyn SteeringModule>>`.
Key methods: `register()` (`:211`, chainable, last-wins override), `get()` (`:217`),
`kinds()` (`:222`), and [`native_default()`](../src/orchestration.rs) (`:233`) — the
batteries-included registry. A caller **overrides any capability by re-registering** the
same kind (this is how a test injects a real bundle — see the worked example below).
## Two-phase lifecycle: INFRA → APP
A flow reads top-to-bottom as **first provision the infrastructure, then launch the
application**:
- **Phase 1 — INFRA:** `BootOS` (boot/provision an OS), `Seed` (first-boot config),
`Configure` (subsystem params). These get the target ready.
- **Phase 2 — APP:** `InstallApp` installs and readies the application onto the target
using Skidbladnir's existing install lifecycle.
The interpreter enforces order (steps run in list order) and threads `ctx.vars` forward,
so a boot step records `booted_os` and a later step can read it. This is the same engine
the design says garm reuses for its installable server and korp/dwarves use for systemd
install — the RON drives the infra, then the app.
### The native module registry (`native_default`)
The batteries-included registry (`orchestration.rs:233`) wires:
| Kind | Module | Phase | Status |
|---|---|---|---|
| `KvmBoot` | `KvmBootModule` (`:317`) | infra | **REAL** — real `KvmTarget` + qemu argv; spawn gated on `boot` |
| `RedfishBoot` | `MockBootModule::redfish()` (`:390`) | infra | mock — needs `redfish` + a BMC |
| `OciDeploy` | `MockBootModule::oci()` (`:394`) | infra | mock — needs draupnir OCI engine |
| `TerraformApply` | `MockBootModule::terraform()` (`:398`) | infra | mock — needs `terraform` + binary |
| `Seed` | `SeedModule` (`:432`) | infra | mock — needs draupnir/seed (CIDATA image) |
| `Configure` | `ConfigureModule` (`:613`) | infra | mock — needs the target driver |
| `InstallApp` | `InstallAppModule` (`:474`) | **app** | **REAL** — reuses `install::install_product` |
| `Federate` | `FederateModule` (`:646`) | — | mock — needs the `federation` feature |
**Honest-mock policy:** a mock records intent into `ctx` and returns `real: false` with a
`needs` note naming exactly what would make it real. It never fakes a green. Only
`KvmBoot` and `InstallApp` run real subsystems today.
### The APP phase is real — how `InstallApp` reuses the core
[`InstallAppModule::run`](../src/orchestration.rs) (`orchestration.rs:517`) is the real
call-out that launches the application. It:
1. Resolves the app's [`product::ComponentSpec`](../src/product.rs) — from the module's
`extra_specs` (apps not in the built-in registry, e.g. `korp`) or
[`product::lookup`](../src/product.rs) (`product.rs:761`, built-ins like `holger`).
2. Builds a [`layout::Layout`](../src/layout.rs) under `ctx.work_dir`.
3. **With a registered bundle** → runs the FULL
[`install::install_product`](../src/install.rs) (`install.rs:93`) lifecycle:
provision the software/data/log tree, unfold the znippy bundle, mint identity, render
+ write the unit files, return the ordered enable/start
[verbs](../src/install.rs) (`install.rs:85`). Returns `real: true`.
4. **Without a bundle** → still renders the real
[`service::ServiceSpec`](../src/service.rs) via
[`service::renderer_for`](../src/service.rs) (`service.rs:610`) and reports it.
`real: true` (real render ran), mode `render_only`.
## Backends beyond systemd — where the "not-limited-to-systemd" plugs in
There are **two** pluggable-backend axes, both real traits:
1. **Infra/boot backend** — [`BootBackend`](../src/orchestration.rs) selects the boot
`SteeringModule` (KVM / Redfish / OCI / Terraform). Swap by registering a different
module under `KvmBoot`/`RedfishBoot`/… .
2. **Service/init backend** — the app's service manager is
[`service::Backend`](../src/service.rs) (`service.rs:48`), an **11-variant** enum
resolved by the [`ServiceRenderer`](../src/service.rs) trait (`service.rs:603`) via
[`renderer_for`](../src/service.rs) (`service.rs:610`). **All eleven render today** —
no honest `None` remains:
`SystemdSystem` · `SystemdUser` · `OpenRc` · `Runit` · `S6Rc` · `Dinit` · `SysV` ·
`Oci` · `Quadlet` · `WindowsService` · `Kubernetes`.
`InstallAppModule.backend` (default `SystemdSystem`) picks which one — so an
`InstallApp` step can target podman (`Oci`/`Quadlet`), a VM's other init, or k8s, not
just systemd. This is the concrete "not-limited-to-systemd" pluggable backend: the
`ServiceSpec` is neutral, and each backend has its own renderer.
(The `Kubernetes` renderer is a sync manifest emitter; the async applier that drives
the manifests over the k8s API lands behind the `kubernetes` feature — NOT YET the
default path.)
## Worked custom-object example
Registering a custom capability is: implement the trait, register it under a kind, run
the flow. Here a caller supplies a real spec + bundle for `korp` (not a built-in) by
re-registering the `InstallApp` module — the exact pattern the
`ron_drives_infra_then_installs_the_app` test uses (`orchestration.rs:727`):
```rust
use skidbladnir::orchestration::{Flow, Registry, RunContext, run_flow, modules};
use skidbladnir::product::ComponentSpec;
// 1. Describe korp (not in the built-in product registry) + point at its real bundle.
let korp = ComponentSpec::new("korp", "linux-x86_64/korp", "_korp")
.with_arg("serve").with_port(9000);
let install = modules::InstallAppModule::default()
.with_spec(korp.clone()) // resolved before product::lookup
.with_bundle("korp", &bundle); // enables the FULL install lifecycle
// 2. Start from the native registry, then OVERRIDE the InstallApp module.
let mut registry = Registry::native_default();
registry.register(Box::new(install)); // last-wins: replaces the default InstallApp
// 3. Declarative flow: infra (KVM boot + seed) → app (install korp).
let flow = Flow::from_ron(r##"[
BootOS(backend: Kvm, os: "ubuntu-24.04"),
Seed(config: "#cloud-config\npackages: [curl]\n"),
InstallApp(spec: "korp"),
]"##).unwrap();
// 4. Run it. work_dir is the fs root the real install lifecycle writes under.
let mut ctx = RunContext { work_dir: Some(work_dir), ..Default::default() };
let outcomes = run_flow(&flow, ®istry, &mut ctx).unwrap();
// outcomes[0].real == true (real qemu argv), outcomes[2] installed korp for real:
// software/data/log tree, minted identity, enable+start verbs.
```
To write a **wholly new** capability, implement `SteeringModule` for your type and give it
a `kind()` that matches a `Step`'s `module_kind()` (today that means overriding one of the
existing kinds — the `Step` enum is closed, so a brand-new step *name* needs a new `Step`
variant in Rust first).
## Verified against implementation — gaps flagged
Checked against `src/orchestration.rs`, `src/service.rs`, `src/install.rs`,
`src/product.rs` at nornir `78deafc`:
- **NOT YET IMPLEMENTED — znippy-packaged modules / "pluck from znippy":** the design says
modules are packaged in znippy and plucked at runtime. The code has **no** znippy module
packaging; the registry is native, in-tree, compiled-in. This is the design's Phase 2.
- **NOT YET IMPLEMENTED — WASM module-kind:** the design's language-agnostic / hot-load /
capability-gated WASM modules (the mikronova/rover generalization) do not exist. The
`SteeringModule` trait is the seam they'd slot behind — but only native modules exist.
- **NOT YET IMPLEMENTED — CLI runner:** flows run only through the library API
(`run_flow`/`run_ron`); there is no `skidbladnir run <flow.ron>` and nothing reads
`examples/korp-provision.ron` at runtime.
- **`Step` is a closed enum:** a genuinely new step *name* requires a new `Step` variant in
Rust. Custom code today swaps the *implementation* behind an existing kind, not the
vocabulary.
- **Honest mocks:** `Seed`, `Configure`, `Federate`, and the non-KVM boots return
`real: false`. Only `KvmBoot` (qemu argv build; VM spawn still gated on `boot` + qemu on
PATH) and `InstallApp` run real subsystems.