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:
- a KVM guest returns as soon as tunnr has the QEMU process up — the kernel is still booting;
- a Redfish node returns after the power-on REST call is accepted — the metal is still POSTing / PXE-ing, minutes from ready;
- a container returns once the runtime schedules it — it may still be starting.
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:
- Built on the always-compiled
Lifecycletrait, generic over the backend. No reach into any backend internal, no new dependency — the default build stays pure std. That is what makes it mock-testable with no live instance: a scriptedLifecycletransitionsUnknown -> Unknown -> Onand the driver provespoll -> compare -> Okend to end, and a stuck-Offnode proves the timeout. boot_and_awaitreusesboot()(validate + Boot), not a bespoke path. An invalid spec is rejected atvalidate()before the backend — or any wait — is ever touched (asserted:boot_and_await_rejects_an_invalid_spec_before_booting), so the readback never waits on an unbooted instance. One boot seam, no duplication.- Awaiting
PowerState::Unknownis aSpecerror, up front.Unknownmeans "not observed"; a target ofUnknownis nonsensical (it would spuriously "succeed" the first time a backend could not read the state). Rejected before the first poll. - A
status()error propagates asErr. If the backend can't read the state (BMC unreachable, socket gone), the readback itself failed — that is not a state to keep polling through, it is surfaced immediately. - Timeout is opt-in, mirroring
RunOptions.Default/poll_everywait indefinitely (parity withrun_to_completion);bounded(timeout, poll)returns a clearError::Backendnaming the instance and the last-observed state — a booted-but-dead node is a diagnosable timeout, never a silent hang.WaitOptionsdeliberately mirrorscontainer::RunOptionsfield-for-field so the two readback loops read the same.
Tests (all no-instance, RED-when-broken)
src/lib.rs:
await_power_state_returns_ok_once_the_state_is_reached— polls past twoUnknowns toOnand returns Ok (asserts it actually polled ≥3 times).await_power_state_times_out_when_the_state_is_never_reached— a stuck-Offnode is a boundedError::Backendtimeout. RED-when-broken: drop theobserved == wantgate (return Ok on the first poll) and this + the reached-Ok test fail.await_power_state_rejects_awaiting_unknown—want = UnknownisErr(Spec)before any poll (status_calls == 0).await_power_state_propagates_a_backend_status_error— astatus()Errpropagates.boot_and_await_boots_then_confirms_power_on— boot then confirm On, returns the liveMachine(power == On).boot_and_await_times_out_when_the_instance_never_comes_up— a never-up node makes the one-call seam a bounded timeout, not a hang.boot_and_await_rejects_an_invalid_spec_before_booting— an ISO-on-container spec isErr(Spec)before the backend / any wait is touched.
tests/testmatrix_rows.rs:
await_power_state_confirms_a_boot_readback_across_backends— records thedraupnir/lifecyclematrix row (reached-Ok · never-up-timeout · reject-Unknown ·boot_and_awaitconfirms On).
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:
- Reuses
boot_and_awaitper member, not a bespoke fleet loop. Each member gets the exact same validate → boot → confirm-On path; the fleet call only fans it and classifies. One readback seam, no twin (L5). - Infallible — returns a rollup, never an early
Err. A dead or misconfigured member is a per-memberTimeout/Errorline, so a partial fleet is always observable (parity withboot_fleet). The aggregate isready()= theUpcount,failed()= the rest,all_ready()= non-empty and none failed. - Bounded per-member timeout is what makes it safe. Each member is awaited independently within
opts, so one node that never powers on rolls up as aTimeoutwhile the healthy members areUp— it cannot hang the whole rollup. Pass aWaitOptions::bounded(..); the default unbounded options would block forever on the first dead node (documented, same contract asawait_power_state). - Three honest buckets from
boot_and_await's result.Ok→Up;Err(Backend)→Timeout(the backend did not get the node toOnin budget — a readback timeout or a transient backend failure while booting/polling);Err(Spec|Unsupported)→Error(a hard misconfiguration or an unavailable backend — retrying cannot help). The split lets a dispatcher retryTimeouts and fail fast onErrors.
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:
boot_fleet_and_await_rolls_up_one_dead_member_as_timeout_others_up— a fleet of 3 with a dead middle member rolls up node-2 =Timeout, node-1/node-3 =Up, ready 2 / failed 1. RED-when-broken: neuter theobserved == wantgate (dead node returns Ok → ready 3) or flip theErr(Backend) -> Timeoutarm toError(thematches!(.., Timeout)verdict fails).boot_fleet_and_await_reports_a_fully_ready_fleet— every memberUp,all_ready.boot_fleet_and_await_rolls_up_an_invalid_spec_as_error_not_timeout— an invalid spec isErroron every member (rejected atvalidate()before the backend), neverTimeout— proves the Spec/Backend bucket split.boot_fleet_and_await_of_zero_is_an_empty_not_ready_rollup—n=0is an empty rollup,all_ready()false.
tests/testmatrix_rows.rs:
boot_fleet_and_await_rolls_up_who_is_ready— records thedraupnir/lifecyclematrix row (partial fleet node-2 timeout / others up, ready 2 / failed 1, plus a full-fleet all-ready arm).
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:
gatling::gatling_forkjoin::gatling_for_each— the ONE sanctioned threading home. ROOT-LAW #0 (rayon-free / core-saturation) forbids a barestd::thread::spawnin any non-engine crate; all concurrency originates in gatling. The fork-join pool is scoped, so each member still borrows the shared&backend/&optsand the fleet plan off the stack with noArc/clone, and it returns results in index order (deterministic rollup). Worker count is pinned toplan.len()— one worker per member, NOT the default core count — becauseclassify_memberis blocking-IO (park on a boot), not CPU: every member needs its own parked thread so the wall-clock collapses to the slowest, a deliberate exemption from the core-saturation law (which governs CPU hot paths).- Identical rollup to the serial path, by construction. Both paths classify every member through the one shared
classify_memberstep (validate →boot→ confirm-On → bucket), and the parallel path joins the member threads back in fleet order (node-1…node-n), not completion order. So theFleetReadback— per-memberMemberOutcome, member order, and theready()/failed()/all_ready()tallies — is byte-for-byte the same whichever path booted it. The parallel path changes when members are awaited, never what the rollup says. This is asserted directly (parallel_fleet_boot_is_identical_to_serial_for_a_mixed_fleet). - Still infallible, still one-dead-node-safe. Each member is awaited on its own thread within the bounded
opts, so a dead node times out on its thread and is joined as aTimeoutline while the healthy threads finish independently — one dead node cannot hang the others (same guarantee as the serial call, now truly parallel). Pass a boundedWaitOptionsfor the same reason. B: Syncbound — the serial path stays the general fallback. Sharing&backendacross threads needsB: Sync. All three real backends satisfy it (KvmBoot/ContainerBootareArc<Mutex<..>>,RedfishBootis plain owned fields). A backend that is notSyncsimply calls the serialboot_fleet_and_await, which is kept unchanged as the fully-general path — the working path is never replaced (L2). Two functions, one shared classification, no twin (L5).
Parallel tests (all no-instance, RED-when-broken, mutation-verified) — src/lib.rs:
parallel_fleet_boot_is_identical_to_serial_for_a_mixed_fleet— the equivalence guard: a mixed fleet of 5 (node-3 dead →Timeout, node-5 unbootable →Error, node-1/2/4Up) rolls up to aFleetReadbackequal to the serial one. RED-when-broken: reversing the join order (or any bucket/member divergence) breaks theassert_eq!.parallel_fleet_boot_actually_overlaps_the_members— the concurrency guard: a barrier backend that records peak in-flightstatuscalls proves peak == n (all members awaited at the same instant). RED-when-broken: pointing it at the serialboot_fleet_and_awaitcollapses the peak to 1.parallel_fleet_boot_of_zero_is_an_empty_rollup— parity with the serialn=0.
tests/testmatrix_rows.rs:
boot_fleet_and_await_parallel_matches_the_serial_rollup— records thedraupnir/lifecyclematrix row (parallel == serial over the mixed fleet).
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.