Gunnar

mikronova/draupnir

.nornir/run-to-completion-design.md

<!-- 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`.

![run_to_completion flow](assets/flow_run_to_completion.svg)

## 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`.