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

mikronova/draupnir

.nornir/boot-readback-design.md

draupnir boot-status readback — await_power_state / boot_and_await

Added draupnir 0.1.5 (additive). The cross-backend boot-status readback seam: a Boot::boot fires an instance up but returns before it has actually powered on, so a consumer that needs to know the instance is up polls its Lifecycle::status until it reports the wanted state. This is the power-lifecycle analogue of the container run_to_completion drive loop, but written against the plain Lifecycle trait so it drives any backend — KVM, container, Redfish — and a mock alike.

Why it exists

The chain is app -> jera (job) / Skidbladnir (service) -> draupnir (provision). Boot::boot returns a Machine the instant the backend accepts the launch:

So boot() succeeding does not mean the instance is up. Before this seam a consumer had only Lifecycle::status — a single instantaneous read — and had to hand- roll its own poll-until-up loop (and its own timeout / never-comes-up handling) per call site. That is exactly the duplicated orchestration draupnir exists to own once. await_power_state provides the loop once; boot_and_await is the one-call "provision and confirm up" seam jera / Skidbladnir actually want.

The API (the only design — no alternatives)

pub struct WaitOptions {
    pub timeout: Option<Duration>,  // None = wait forever; Some(d) = timeout after d
    pub poll_interval: Duration,    // default 200ms
}

// Poll a Lifecycle until it reports `want`; Ok(()) the moment it does.
// A live status() error propagates; awaiting PowerState::Unknown is an Err(Spec);
// a bounded budget makes a never-reached state an Err(Backend) timeout, not a hang.
pub fn await_power_state<L: Lifecycle>(
    lifecycle: &L, machine: &Machine, want: PowerState, opts: &WaitOptions,
) -> Result<()>;

// The one-call provision seam: validate + boot, then await PowerState::On,
// returning the live Machine only once it is actually up.
pub fn boot_and_await<B: Boot + Lifecycle>(
    backend: &B, spec: &BootSpec, opts: &WaitOptions,
) -> Result<Machine>;

Design decisions, and why:

Tests (all no-instance, RED-when-broken)

src/lib.rs:

tests/testmatrix_rows.rs:

Fleet-level readback — boot_fleet_and_await (draupnir 0.1.5, additive)

The single-node boot_and_await confirms one instance. A multi-machine dispatcher (jera) boots a fleet from one image and needs to know who came up — not to stop at the first dead node. boot_fleet_and_await is the fleet analogue: it plan_fleets the spec into n members and drives each through boot_and_await (no per-backend twin — L5), rolling up a per-member verdict plus aggregate tallies.

pub enum MemberOutcome {
    Up(Machine),      // booted AND confirmed On within the budget — ready
    Timeout(String),  // Err(Backend): readback timed out / backend couldn't bring it up in budget
    Error(String),    // Err(Spec|Unsupported): hard misconfig — waiting can't help
}
pub struct MemberReadback { pub name: String, pub outcome: MemberOutcome }
pub struct FleetReadback { pub members: Vec<MemberReadback> }  // in fleet order
impl FleetReadback { fn ready(&self)->usize; fn failed(&self)->usize; fn all_ready(&self)->bool; }

// Boots n members from one spec, awaits each to On under `opts`, rolls up who is ready.
pub fn boot_fleet_and_await<B: Boot + Lifecycle>(
    spec: &BootSpec, n: usize, backend: &B, opts: &WaitOptions,
) -> FleetReadback;   // infallible by construction

Design decisions, and why:

How jera calls it: build the shared BootSpec + fleet size n from the Roster/WorkPayload, pass a bounded WaitOptions, then read FleetReadback: all_ready() to gate the whole fleet, ready()/failed() for the tally, and the members[] lines (name → Up/Timeout/Error) to decide which members to retry (the Timeouts) versus reject (the Errors).

Fleet tests (all no-instance, RED-when-broken) — src/lib.rs:

tests/testmatrix_rows.rs:

Parallel fleet boot — boot_fleet_and_await_parallel (draupnir 0.1.5, additive)

boot_fleet_and_await boots+awaits its members serially, so a fleet's wall-clock is the sum of the per-member waits: 8 nodes on a 5-minute budget is up to 40 minutes, one node at a time. But the per-member wait is almost entirely the backend's own boot latency (a metal node POSTing, a guest booting) — idle time draupnir spends sleeping in await_power_state. boot_fleet_and_await_parallel overlaps that idle time: it fans each member onto the gatling fork-join pool (one worker per member) so all n are booted+awaited at once and the wall-clock collapses to roughly the slowest member's wait.

// Same arguments + same FleetReadback as the serial call — just concurrent.
pub fn boot_fleet_and_await_parallel<B: Boot + Lifecycle + Sync>(
    spec: &BootSpec, n: usize, backend: &B, opts: &WaitOptions,
) -> FleetReadback;

Design decisions, and why:

Parallel tests (all no-instance, RED-when-broken, mutation-verified) — src/lib.rs:

tests/testmatrix_rows.rs:

Perf / bench

await_power_state and the serial boot_fleet_and_await are poll loops gated on the backend's own status latency (a QEMU handle read, a container-state query, a BMC REST round-trip) — no compute hot path to guard, and a real measurement would time the backend / the network, not draupnir. The parallel fleet boot IS a clean measure, though: it overlaps exactly that per-member latency, so benches/fleet_boot.rs (criterion, dev-dependency only — the lib's runtime deps are untouched) benches serial vs _parallel over a fake backend whose status sleeps a small fixed latency modelling the readback round-trip. The serial arm pays it n times in series, the parallel arm once:

n serial parallel speedup
4 8.30 ms 2.13 ms ~3.9x
8 16.53 ms 2.18 ms ~7.6x
16 33.05 ms 2.29 ms ~14.4x

(Loki 12c/64GiB, 2 ms per-member latency, light --sample-size 10 run; serial ≈ n × latency, parallel ≈ one latency.) These are indicative — the authoritative HEAVY numbers are the Loki bencher's; noted in draupnir_agents_problems.md.

Related — teardown + functional-status emit

await_power_state / boot_fleet_and_await[_parallel] each record their verdict as a draupnir/lifecycle functional_status row, and the undo seam rollback() tears a booted instance back down (power-off through the same Lifecycle trait), likewise a GREEN/RED row. The full table of emitting lifecycle surfaces — boot · rollback · await_power_state · wait_ready · the seed builders — and the RED-when-broken failed_boot_and_rollback_emit_red_rows test are documented in functional-status-emit.