<!-- nornir:class: design -->
# draupnir `ContainerControl::exec` — the container exec/control surface
Added draupnir 0.1.6 (additive). The engine primitive Skidbladnir's **live-swap
lifecycle** needs: a way to `exec` a command *into* an already-running detached
container over the ONE OCI engine — the missing half of jera "knob 3" (detached +
exec handle). The airgap ROUTE (`--network none`, see
[`oci-network-mode.md`](oci-network-mode.md)) already landed; this is the
start/stop/**exec** SWAP that remained.
## Why it exists
`run_to_completion` (see [`run-to-completion-design.md`](run-to-completion-design.md))
owns a container's **whole lifetime** — fire it up, wait for it to exit, collect
logs, remove it. That is run-to-completion; it cannot express "run a command *inside*
a container that stays up." A live start/stop/exec swap (Skidbladnir restarting a
service in place, a health probe, a live reconfigure, a drain-before-stop) needs
exactly that: a **detached** boot that hands back a long-lived `Machine`, plus a
**surviving handle** that can exec into it repeatedly without tearing it down.
The detached half already existed: `ContainerBoot::boot` (and jera's
`DraupnirContainerBootRunner::spawn_spec`, which wraps it) returns a live `Machine`
whose `id` is the container name. What was missing was the exec seam — draupnir's
`ContainerControl` was only `container_state` / `drain_logs` / `drain_logs_split` /
`stop`, **no `exec`**. This adds it.
## The API (the only design — no alternatives)
```rust
pub struct ExecOutcome {
pub exit_code: Option<i64>, // Some(0) = clean; the exec'd process's status
pub stdout: Vec<String>, // captured stdout lines, in order
pub stderr: Vec<String>, // captured stderr lines, in order
}
// Pure, no-daemon command builder: ["exec", id, argv0, argv1, …] — the canonical
// podman-exec form the live engine drives (id + argv = command[2..]).
pub fn exec_argv(id: &str, argv: &[&str]) -> Vec<String>;
pub trait ContainerControl {
// … container_state / drain_logs / drain_logs_split / stop …
// Exec `argv` inside the running container `machine`. Container-only; empty argv
// rejected. A non-zero exit is the ExecOutcome, not an Err. PROVIDED: does the
// guards + assembles the command via exec_argv, then delegates to exec_command.
fn exec(&self, machine: &Machine, argv: &[&str]) -> Result<ExecOutcome>;
// The backend hook `exec` delegates the live run to. DEFAULT: Error::Unsupported
// (an engine-less mock). ContainerBoot overrides it to drive bollard's /exec.
fn exec_command(&self, command: &[String]) -> Result<ExecOutcome>;
}
```
### Flow (rides the always-compiled seam)
```
caller ── exec(&machine, argv) ─┐ (provided trait method)
│ 1. guard: machine.backend == Container? else Err(Spec)
│ 2. guard: !argv.is_empty()? else Err(Spec)
│ 3. command = exec_argv(machine.id, argv) // ["exec", id, argv…]
└─ exec_command(&command) ──► ContainerBoot (prod): bollard
create_exec → start_exec → inspect_exec
─► ExecOutcome{exit_code, stdout, stderr}
► mock (default): Err(Unsupported)
```
## Design decisions, and why
- **On `ContainerControl`, not a new trait.** jera already holds a
`B: draupnir::Boot + draupnir::container::ContainerControl` and a `draupnir::Machine`
in its `DraupnirContainerProc` (the `BootProc` a `spawn_spec` returns). Adding `exec`
to the trait it already carries means the surviving handle jera needs is exactly the
`(backend, machine)` pair it *already has* — no new type, no lifecycle reshape.
- **`Machine` is the handle; `machine.id` is the exec target.** A detached
`boot`/`spawn_spec` returns a `Machine` whose `id` is the container name
(`draupnir-<spec.name>`). exec targets that id — no separate id/accessor was needed;
the `Machine` already *is* the container-id-carrying handle.
- **Container-only guard, before the engine.** parity with the container-only
`net`/`cmd`/`ports` `validate()` checks: a KVM guest / Redfish node has nothing to
exec into, so a non-container `Machine` is rejected with `Error::Spec` *before* the
engine is touched (a mock can prove this with no daemon). An empty argv (nothing to
run) is likewise rejected.
- **Provided `exec` + defaulted `exec_command` split** — so the guard +
command-assembly path is exercised against a **mock with no daemon** (the mock keeps
the default `exec_command` and records the assembled command), while `ContainerBoot`
overrides `exec_command` to drive the live bollard `/exec`. `exec_argv` is a pure,
directly-asserted, load-bearing builder (the live path uses `command[2..]` as argv).
This is the same additive shape as the `create_body`/`create_body_with_binds`
delegate pair on the create path — every existing `ContainerControl` impl (jera's
mock included) keeps compiling untouched because both methods are provided.
- **A non-zero exit is `Ok`, not `Err`.** The exec'd *process* failed, the *call*
succeeded — inspect `ExecOutcome::exit_code`. Only a guard failure or a
create/start/inspect/connect error returns `Err`. Same contract as `RunOutcome`.
- **Zero-shell live drive.** `ContainerBoot`'s `exec_command` drives podman's `/exec`
REST op via bollard (`create_exec` → `start_exec` → `inspect_exec`), never a
`podman exec` subprocess — parity with the rest of the OCI engine.
## Tests (no-daemon)
`src/container.rs`:
- `exec_argv_builds_the_podman_exec_command` — the pure builder yields
`["exec", id, argv…]`.
- `exec_assembles_the_command_and_returns_the_outcome` — **RED-when-broken**: a
recorder mock captures the exact command `exec` assembled (`["exec", machine.id,
argv…]`) and the returned `ExecOutcome`; neutralize `exec_argv` (drop the id) and
the recorded-command assert fails.
- `exec_is_rejected_on_a_non_container_machine` — the container-only guard rejects a
KVM `Machine` with `Error::Spec` and records no command (guard runs first).
- `exec_rejects_an_empty_argv` — nothing to run → `Error::Spec`, no command.
- `exec_without_an_engine_is_unsupported_not_faked` — the default `exec_command`
(engine-less backend) passes the guards then honestly reports `Unsupported`.
`tests/testmatrix_rows.rs`:
- `container_exec_builds_argv` — the `draupnir/container` matrix row: asserts the
pure builder, the recorder-mock command + outcome, and the KVM/empty-argv guards in
one verdict.
**Live smoke-test deferred** — a real `podman exec` into a running container is a
Loki integration concern (needs a live socket the CI/dev box does not have), same as
the create/run-to-completion live paths. Asserted here: the constructed command +
the handle plumbing.
## Perf / bench
**N/A — no bench, by design.** `exec` is a guard + a `/exec` REST round-trip; there
is no compute hot path to guard, and a real measurement would time the daemon, not
draupnir (parity with `run_to_completion` and the network-mode knob).