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.