draupnir run_to_completion — the run-to-completion container seam
Added draupnir 0.1.4 (additive). This is the missing seam that completes the container-engine consolidation (.nornir/container-consolidation-plan.md): a single call that fires up a container, waits for it to exit, collects its logs, and returns an exit-code-aware result — the run-to-completion analogue of how a VM / container boot already delegates through draupnir::Boot.
Why it exists
The chain is app -> jera (job) -> draupnir (provision). KVM and container boot were already unified: jera's DraupnirVmBootRunner / DraupnirContainerBootRunner drive draupnir::Boot + ContainerControl, one engine. But jera kept a second, duplicate bollard engine (BollardEngine) alive for exactly one reason: its run_container is a run-to-completion job (create -> start -> wait for exit -> capture stdout/stderr + exit code -> remove) and draupnir offered only boot + lifecycle, never that one-shot. nornir::jobs::run_container consumes jera's engine, so the duplicate could not be removed.
run_to_completion closes that gap. jera's run_container (and nornir's above it) can repoint onto this seam, after which BollardEngine / BollardBootRunner / the ContainerEngine trait become dead code and are deleted — one engine, not two. (The repoint itself is jera/nornir work, owned by those repos' agents — this pass only provides + documents the draupnir API.)
The API (the only design — no alternatives)
pub struct RunOutcome {
pub exit_code: Option<i64>, // Some(0) = clean; None = gone before a code was read
pub stdout: Vec<String>, // captured stdout lines, in order
pub stderr: Vec<String>, // captured stderr lines, in order
}
pub struct RunOptions {
pub timeout: Option<Duration>, // None = wait forever (jera's wait_container parity)
pub poll_interval: Duration, // default 200ms
}
// The generic, mock-testable seam — drives a live ContainerBoot in production
// AND a mock in a unit test with no daemon.
pub fn run_to_completion<B>(backend: &B, spec: &BootSpec, opts: &RunOptions) -> Result<RunOutcome>
where B: Boot + ContainerControl;
// The ergonomic production entry point (forwards to the free fn with `self`).
impl ContainerBoot {
pub fn run_to_completion(&self, spec: &BootSpec, opts: &RunOptions) -> Result<RunOutcome>;
}
Design decisions, and why:
- Built on the always-compiled
Boot+ContainerControlseam, generic over the backend. No new bollard code, no reach intoEngine. That is what makes it mock-testable with no daemon — a scripted backend transitionsRunning -> Running -> Exited(code)and hands out log batches; the driver provesstart -> wait -> exit-code -> logsend to end.ContainerBoot::run_to_completionis a thin production convenience over the same free function. - A non-zero exit is
Ok, notErr. The container failed, the call succeeded — inspectexit_code. Only a boot/connect failure or atimeoutreturnsErr. This is byte-for-byte jera'srun_containercontract, so the repoint is behaviour-preserving. RunOutcomemirrors jera'sContainerOutcomefield-for-field (exit_code: Option<i64>, plus stdout/stderr) so jera maps one onto the other with no loss. draupnir's type carries no serde (the lean pure-std default is preserved); jera owns the serde-serialised ledger shape.- Split stdout/stderr preserved additively. The follow-task log buffer now tags each line
(is_stderr, line).ContainerControl::drain_logsstill returns the combined ordered stream (unchanged public behaviour); a new defaulteddrain_logs_split(&Machine) -> (Vec<String>, Vec<String>)returns the two streams apart. The default routes every combined line to stdout, so every existingContainerControlimpl (incl. jera's mock) keeps compiling untouched;ContainerBootoverrides it to surface the real tag. - Timeout is opt-in.
Nonewaits indefinitely (parity with jera's blockingwait_container);Some(d)stops + removes the container and returns a clearError::Backendnaming the instance — no leak, no fake success.
Tests (all no-daemon)
src/container.rs:
run_to_completion_starts_waits_collects_split_logs_and_exit_code— the full path: booted the spec, polled toExited(0), logs split across ticks + a final post-exit flush, container removed.run_to_completion_surfaces_a_nonzero_exit_as_ok_not_err—Exited(137)comes back asOk(RunOutcome{exit_code: Some(137)}).run_to_completion_reports_none_when_the_container_is_gone—Gone->None(never a fake 0), still removed.run_to_completion_times_out_with_a_clear_error_and_stops_the_container.run_to_completion_propagates_a_boot_failure_without_polling.default_drain_logs_split_routes_combined_logs_to_stdout— the trait default.
Perf / bench
N/A — no bench added, by design. run_to_completion is a poll loop gated on container start/exit + log-stream IO; there is no compute hot path to guard, and a real measurement would need a live podman socket on the bencher (Loki), which it does not have. Forcing a bench here would measure daemon latency, not draupnir. Noted in bencher_agents_problems.md.