Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

mikronova/draupnir

.nornir/container-exec-control.md

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) already landed; this is the start/stop/exec SWAP that remained.

Why it exists

run_to_completion (see 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)

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

Tests (no-daemon)

src/container.rs:

tests/testmatrix_rows.rs:

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).