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
- On
ContainerControl, not a new trait. jera already holds aB: draupnir::Boot + draupnir::container::ContainerControland adraupnir::Machinein itsDraupnirContainerProc(theBootProcaspawn_specreturns). Addingexecto 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. Machineis the handle;machine.idis the exec target. A detachedboot/spawn_specreturns aMachinewhoseidis the container name (draupnir-<spec.name>). exec targets that id — no separate id/accessor was needed; theMachinealready is the container-id-carrying handle.- Container-only guard, before the engine. parity with the container-only
net/cmd/portsvalidate()checks: a KVM guest / Redfish node has nothing to exec into, so a non-containerMachineis rejected withError::Specbefore the engine is touched (a mock can prove this with no daemon). An empty argv (nothing to run) is likewise rejected. - Provided
exec+ defaultedexec_commandsplit — so the guard + command-assembly path is exercised against a mock with no daemon (the mock keeps the defaultexec_commandand records the assembled command), whileContainerBootoverridesexec_commandto drive the live bollard/exec.exec_argvis a pure, directly-asserted, load-bearing builder (the live path usescommand[2..]as argv). This is the same additive shape as thecreate_body/create_body_with_bindsdelegate pair on the create path — every existingContainerControlimpl (jera's mock included) keeps compiling untouched because both methods are provided. - A non-zero exit is
Ok, notErr. The exec'd process failed, the call succeeded — inspectExecOutcome::exit_code. Only a guard failure or a create/start/inspect/connect error returnsErr. Same contract asRunOutcome. - Zero-shell live drive.
ContainerBoot'sexec_commanddrives podman's/execREST op via bollard (create_exec→start_exec→inspect_exec), never apodman execsubprocess — 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 commandexecassembled (["exec", machine.id, argv…]) and the returnedExecOutcome; neutralizeexec_argv(drop the id) and the recorded-command assert fails.exec_is_rejected_on_a_non_container_machine— the container-only guard rejects a KVMMachinewithError::Specand 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 defaultexec_command(engine-less backend) passes the guards then honestly reportsUnsupported.
tests/testmatrix_rows.rs:
container_exec_builds_argv— thedraupnir/containermatrix 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).