Skidbladnir custom objects — the custom-code call-out seam
Class:
design· Source of truth:src/orchestration.rs
src/service.rs+src/install.rs. Verified against nornir78deafc. Companion toron-schema.md(the RON object model) andron-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
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 theStep::module_kind()(orchestration.rs:85) of every step it handles.run()— the custom code. It receives the step's data and a mutableRunContext(orchestration.rs:139), does the work (provision infra, seed, install…), reads/writes sharedctx.vars, and returns aStepOutcome(orchestration.rs:165) — plain data plus areal: boolflag 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 (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)
- For each step,
run_flowcallsstep.module_kind()to get the module name. - It looks that name up in the
Registry(orchestration.rs:198) viaRegistry::get(). - It calls
module.run(step, ctx), pushes aDispatchrow ontoctx.trace, and collects theStepOutcome.
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 is a BTreeMap<String, Box<dyn SteeringModule>>. Key methods: register() (:211, chainable, last-wins override), get() (:217), kinds() (:222), and native_default() (: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:
InstallAppinstalls 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 (orchestration.rs:517) is the real call-out that launches the application. It:
-
Resolves the app's
product::ComponentSpec— from the module'sextra_specs(apps not in the built-in registry, e.g.korp) orproduct::lookup(product.rs:761, built-ins likeholger). -
Builds a
layout::Layoutunderctx.work_dir. -
With a registered bundle → runs the FULL
install::install_product(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 (
install.rs:85). Returnsreal: true.
- write the unit files, return the ordered enable/start verbs (
-
Without a bundle → still renders the real
service::ServiceSpecviaservice::renderer_for(service.rs:610) and reports it.real: true(real render ran), moderender_only.
Backends beyond systemd — where the "not-limited-to-systemd" plugs in
There are two pluggable-backend axes, both real traits:
-
Infra/boot backend —
BootBackendselects the bootSteeringModule(KVM / Redfish / OCI / Terraform). Swap by registering a different module underKvmBoot/RedfishBoot/… . -
Service/init backend — the app's service manager is
service::Backend(service.rs:48), an 11-variant enum resolved by theServiceRenderertrait (service.rs:603) viarenderer_for(service.rs:610). All eleven render today — no honestNoneremains:
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):
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
SteeringModuletrait 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 noskidbladnir run <flow.ron>and nothing readsexamples/korp-provision.ronat runtime. Stepis a closed enum: a genuinely new step name requires a newStepvariant 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 returnreal: false. OnlyKvmBoot(qemu argv build; VM spawn still gated onboot+ qemu on PATH) andInstallApprun real subsystems.