# 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
```toml
[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
```rust
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
```rust
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):
```rust
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`):
```rust
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):
```rust
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:
```rust
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`):
```rust
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)
```rust
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.
```rust
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`:
```rust
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.