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

nordisk/Skidbladnir

.nornir/custom-objects.md

Skidbladnir custom objects — the custom-code call-out seam

Class: design · Source of truth: src/orchestration.rs

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:

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)
  1. For each step, run_flow calls step.module_kind() to get the module name.
  2. It looks that name up in the Registry (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 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:

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:

  1. Resolves the app's product::ComponentSpec — from the module's extra_specs (apps not in the built-in registry, e.g. korp) or product::lookup (product.rs:761, built-ins like holger).

  2. Builds a layout::Layout under ctx.work_dir.

  3. 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). Returns real: true.
  4. Without a bundle → still renders the real service::ServiceSpec via service::renderer_for (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 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 (service.rs:48), an 11-variant enum resolved by the ServiceRenderer trait (service.rs:603) via renderer_for (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):

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, &registry, &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: