<!-- nornir:class: design -->
# 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)
```rust
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` + `ContainerControl` seam, generic over the
backend.** No new bollard code, no reach into `Engine`. That is what makes it
**mock-testable with no daemon** — a scripted backend transitions
`Running -> Running -> Exited(code)` and hands out log batches; the driver proves
`start -> wait -> exit-code -> logs` end to end. `ContainerBoot::run_to_completion`
is a thin production convenience over the same free function.
- **A non-zero exit is `Ok`, not `Err`.** The *container* failed, the *call*
succeeded — inspect `exit_code`. Only a boot/connect failure or a `timeout`
returns `Err`. This is byte-for-byte jera's `run_container` contract, so the
repoint is behaviour-preserving.
- **`RunOutcome` mirrors jera's `ContainerOutcome` field-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_logs` still returns the
combined ordered stream (unchanged public behaviour); a new **defaulted**
`drain_logs_split(&Machine) -> (Vec<String>, Vec<String>)` returns the two streams
apart. The default routes every combined line to stdout, so every existing
`ContainerControl` impl (incl. jera's mock) keeps compiling untouched;
`ContainerBoot` overrides it to surface the real tag.
- **Timeout is opt-in.** `None` waits indefinitely (parity with jera's blocking
`wait_container`); `Some(d)` stops + removes the container and returns a clear
`Error::Backend` naming 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 to `Exited(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 as `Ok(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`.