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

mikronova/draupnir

.nornir/guide.md

draupnir — guide

Draupnir fires up a runtime instance from one BootSpec across three backends — KVM (via tunnr), OCI container, and Redfish bare-metal — and drives its power lifecycle. This is the usage walk-through; the architecture is in design.md.

Add it

[dependencies]
draupnir = { version = "0.1" }                 # pure-std default: types + traits + validation
# draupnir = { version = "0.1", features = ["backend-tunnr"] }   # live KVM boot via tunnr

The default build has no dependencies. Each live backend is a feature:

feature backend delegates to
(default) — the trait surface + BootSpec/Machine + validation + plan_fleet + the pure NoCloud seed file-set / seed-dir builder (seed)
backend-tunnr KVM VM tunnr_vm::boot_test (pulls seed)
backend-oci (planned) OCI container bollard
backend-redfish bare metal ureq/rustls Redfish client (pin BMC cert or insecure)
seed — fatfs — the vfat seed image authoring (label cidata, no mkisofs)
testmatrix — nornir-testmatrix — turns functional_status markers into matrix rows

Describe an instance

use draupnir::{BootSpec, BmcEndpoint};

// A KVM appliance (kernel + rootfs) — booted through tunnr.
let vm = BootSpec::kvm_kernel_rootfs("wg-appliance", "/boot/bzImage", "/img/rootfs.cpio.gz");

// A redis service instance — an OCI container.
let redis = BootSpec::container("cache", "docker.io/library/redis:7")
    .with_env("REDIS_ARGS", "--save 60 1000");

// A bare-metal node — a Redfish virtual-media ISO boot.
let node = BootSpec::redfish_iso(
    "node-42",
    "/images/rhel-9.iso",
    BmcEndpoint {
        host: "https://bmc-42.dc.example".into(),
        username: "admin".into(),
        system_id: "System.Embedded.1".into(),
    },
);

// Reject an inconsistent spec before touching a backend.
vm.validate().unwrap();
node.validate().unwrap();

Fire it up

use draupnir::{Boot, kvm::KvmBoot, container::ContainerBoot, redfish::RedfishBoot};

let machine = KvmBoot::new().boot(&vm)?;          // feature backend-tunnr
let cache   = ContainerBoot::new().boot(&redis)?; // feature backend-oci
let bare    = RedfishBoot::new().boot(&node)?;    // feature backend-redfish

Each boot() returns a Machine { id, spec_name, backend, power }. Drive it with the Lifecycle trait (power_on / power_off / status). For Redfish, the VirtualMedia trait exposes the raw steps (insert_media / set_boot_override / eject_media) that boot() sequences for you.

Without its feature, a backend's boot() returns Err(Error::Unsupported(..)) telling you which feature to enable — it never pretends to boot.

Confirm it is actually up (boot-status readback)

boot() returns the moment the backend accepts the launch — a KVM guest is still booting, a Redfish node still POSTing, a container still starting. To know the instance is up, await its power state (any backend):

use draupnir::{boot_and_await, await_power_state, PowerState, WaitOptions};
use std::time::Duration;

// One call: validate + boot, then wait until it reports On (bounded so a
// never-up node is a timeout, not a hang). Returns the live Machine only once up.
let opts = WaitOptions::bounded(Duration::from_secs(300), Duration::from_secs(2));
let machine = boot_and_await(&RedfishBoot::new(), &node, &opts)?;

// Or await an already-booted machine (e.g. confirm a power_off took effect).
await_power_state(&backend, &machine, PowerState::Off, &opts)?;

A status() error propagates (the readback failed), and awaiting PowerState::Unknown is rejected — Unknown means "not observed". WaitOptions mirrors container RunOptions; the default waits forever, so prefer bounded for a boot readback. See boot-readback-design.md.

Roll a provision back (rollback)

When a provision has to be reverted — a boot that never reached On, a wait_ready that timed out, a fleet member being reclaimed — tear the instance down with rollback (power-off through any backend's Lifecycle):

use draupnir::rollback;

// Undo a boot: power the instance off. Ok(()) once the backend confirms it down;
// a backend failure to power it down is an Err (and a RED matrix row) — never swallowed.
rollback(&backend, &machine)?;

boot · rollback · wait_ready · the readback loops · the seed builders each record a functional_status verdict (GREEN/RED) for nornir's introspection-coverage matrix — so a failed boot / probe / teardown is a RED row automatically. See functional-status-emit.md.

Exec into a running container (live start/stop/exec)

A detached container boot hands back a live Machine; a surviving handle can then run commands into it without tearing it down — a health probe, a live reconfigure, a drain. That is ContainerControl::exec (the podman exec analogue over the one OCI engine):

use draupnir::{Boot, container::ContainerBoot};
use draupnir::container::ContainerControl;   // brings exec into scope

let backend = ContainerBoot::new();          // feature backend-oci
let machine = backend.boot(&redis)?;         // detached — the container stays up

// Run a command inside it; a non-zero exit is the outcome, not an error.
let out = backend.exec(&machine, &["redis-cli", "ping"])?;   // ExecOutcome
assert_eq!(out.exit_code, Some(0));
assert_eq!(out.stdout, vec!["PONG"]);

// … later, tear it down (idempotent).
backend.stop(&machine);

exec is container-only: a KVM/Redfish Machine (or an empty argv) is rejected with Error::Spec before the engine is touched. The (backend, machine) pair is the control handle — hold it across calls to start/exec/stop in place. See container-exec-control.md.

Redfish TLS: pin the BMC cert, don't skip verification

BMCs ship self-signed certs. The secure way to trust one is to pin it — verification stays on and only that cert is trusted:

let bmc = RedfishBoot::for_node(endpoint, bmc_password)
    .pin_cert_pem(std::fs::read("bmc.pem")?); // sole trusted root, MITM-resistant

.insecure(true) (disable all verification) still exists but is a last resort only — a pinned cert always takes precedence over it, and a malformed pinned PEM is a hard error rather than a silent downgrade. The BMC password is never printed by Debug (it shows <redacted>), so it is safe to log a RedfishBoot.

Provision at first boot (cloud-init NoCloud seed)

Attach a NoCloud seed to a KVM boot — the guest's cloud-init reads it at first boot from the cidata volume. draupnir owns the seed builder (draupnir::seed):

use draupnir::{BootSpec, CloudInit, seed};

let ci = CloudInit::user_data("#cloud-config\nhostname: web-01\nruncmd:\n  - [systemctl, start, holger]\n")
    .with_network_config("version: 2\nethernets:\n  eth0:\n    dhcp4: true\n");

// Attach it to a KVM boot — the backend authors the seed image and wires it in.
let vm = BootSpec::kvm_disk("web-01", "/boot/bzImage", "/img/disk.qcow2")
    .with_cloud_init(ci.clone());

// Or build the seed yourself: a pure seed *directory* (no deps) …
seed::write_seed_dir("/srv/web-01/seed", &ci)?;
// … or a vfat seed *image* (feature `seed`, label `cidata`).
# #[cfg(feature = "seed")]
seed::build_seed_image("/srv/web-01/seed.img", &ci)?;

Boot a fleet from one ISO (the ring)

use draupnir::plan_fleet;

// One ISO -> eight identically-payloaded, distinctly-named specs (node-1 … node-8).
for member in plan_fleet(&node, 8) {
    RedfishBoot::new().boot(&member)?;
}

Boot a fleet AND roll up who is ready (boot_fleet_and_await)

The fleet analogue of boot_and_await: boot n members and await each to On under a bounded budget, then read a per-member rollup — a dead node is a per-member verdict, never a hang. This is the multi-machine provision-readback seam jera's dispatcher consumes.

use draupnir::{boot_fleet_and_await, MemberOutcome, WaitOptions};
use std::time::Duration;

// Bounded per-member timeout so one dead node can't stall the rollup.
let opts = WaitOptions::bounded(Duration::from_secs(300), Duration::from_secs(2));
let fleet = boot_fleet_and_await(&node, 8, &RedfishBoot::new(), &opts);

if !fleet.all_ready() {
    // ready()/failed() are the tally; the per-member lines say who to retry vs reject.
    for m in &fleet.members {
        match &m.outcome {
            MemberOutcome::Up(_)        => {}                    // ready
            MemberOutcome::Timeout(why) => eprintln!("retry {}: {why}", m.name),
            MemberOutcome::Error(why)   => eprintln!("reject {}: {why}", m.name),
        }
    }
}
println!("{} of {} up", fleet.ready(), fleet.members.len());

Up = booted and confirmed On; Timeout = the backend didn't bring it up within the budget (retryable); Error = a hard misconfig / unavailable backend (validate() rejected it before boot — retrying can't help). The rollup is infallible — a partial fleet is always observable. See boot-readback-design.md.

Boot the fleet CONCURRENTLY (boot_fleet_and_await_parallel)

The serial call above awaits members one-at-a-time, so a big fleet with a long per-member budget waits the sum of the per-member waits. boot_fleet_and_await_parallel is the drop-in concurrent sibling — same arguments, same FleetReadback result — that boots and awaits every member on the gatling fork-join pool (one worker per member), so a fleet of n nodes with a 5-minute budget takes ~5 minutes, not ~5n:

use draupnir::boot_fleet_and_await_parallel;

// Same call, same rollup — just concurrent. Prefer this for a real multi-node fleet.
let fleet = boot_fleet_and_await_parallel(&node, 8, &RedfishBoot::new(), &opts);

It needs the backend to be Sync (it is shared across the threads); all three real backends are. For a !Sync backend, use the serial boot_fleet_and_await — it stays the fully-general fallback. Both produce the identical rollup (same per-member verdicts, same fleet order, same tallies). See boot-readback-design.md.

Where it sits

jera (job handler) and Skidbladnir (service/airgap) both depend on Draupnir directly and call down into it — neither reimplements boot. Draupnir drives tunnr for the KVM backend. See design.md for the ownership table.