jera container — the ContainerSpec & its run seam
Class: design · strict "what it IS". Sibling:
jobsrecords that a container ran; this module (jera::container) is how one is described and routed THROUGH draupnir's ONE OCI engine.
f0 — the whole
A ContainerSpec is the plain-serde descriptor a caller (korp, a server RPC, Skidbladnir's container route) hands jera to bring a container up. jera keeps only the policy + descriptor; the single bollard/podman REST engine for the whole constellation lives in draupnir (draupnir::container). Two shapes both delegate to draupnir: a long-lived boot ([DraupnirContainerBootRunner]) and a run-to- completion job ([run_container]). There is no second engine here.
f1 — the descriptor (the run overrides)
ContainerSpec{ image, name, cmd, env, ports, net, mounts }. image is the OCI ref; name empty ⇒ a fresh jera-boot-<uuid> (a kill addresses the exact container); cmd/env/ports are the entrypoint / environment / published-port overrides. All fields are #[serde(default)] + skip-when-empty, so a spec built by struct-literal (ContainerSpec { image, ..Default::default() }) or from an older wire form is byte-identical — every knob is additive.
f2 — net mode (the airgap knob) — wired to the live run
net: NetMode — Default | None | Host | Bridge. NetMode::None renders --network none: no network namespace, no egress — the airgap isolation a routed job must not lose. [ContainerSpec::network_arg] returns the --network <mode> arg (None for the engine Default, so an existing NAT spec emits no flag and stays byte-identical); [NetMode::oci_value] is the value draupnir's engine sets on the container HostConfig.network_mode.
Live-engine wire (landed).
to_draupnirnow threads the net choice onto thedraupnir::BootSpecboth jera legs consume:d.net = draupnir::NetMode::from_oci_value(self.net.oci_value()). draupnir readsspec.netin both boot paths (Boot::bootandContainerBoot::run_to_completion_with_binds), settingHostConfig.network_mode, so a routedNetMode::Nonespec produces a real--network noneairgap container at the live run.NetMode::Default⇒from_oci_value(None)⇒ draupnirDefault⇒ no--networkflag ⇒ byte-identical create body (an existing NAT spec is unchanged). The airgap route is now un-gated: Skidbladnir may route--network nonethrough jera→draupnir instead of a direct podman subprocess.
f3 — bind mounts (-v)
mounts: Vec<BindMount{ host, container, ro }>. [BindMount::bind_string] renders host:container[:ro]; [ContainerSpec::bind_strings] is the exact &[String] draupnir's [ContainerBoot::run_to_completion_with_binds] consumes. This one is load-bearing today: run_container/run_container_with_reports route through draupnir's mount-aware run, so a routed job keeps its mounts. Empty mounts ⇒ byte-identical to the mount-less run (draupnir guarantees an identical HostConfig).
f4 — the run seam (one ledger, two engine calls)
run_recorded is the single ledger wrap (open running row → drive the engine → terminal done/failed + Health). Two callers reuse it (L5):
| caller | engine call | binds |
run_container_with_reports (production) |
ContainerBoot::run_to_completion_with_binds |
carries bind_strings() |
run_container_on<B> (mock test seam) |
generic run_to_completion (draupnir Boot) |
none — the Boot trait has no mount-aware boot |
The mock seam proves the ledger/logs/exit wiring daemon-free; the production wrapper adds the mounts. to_draupnir maps image + per-boot name + cmd/env/ports and net (via d.net = NetMode::from_oci_value(self.net.oci_value())) onto a valid draupnir::BootSpec, so the net choice flows through both legs automatically; mounts are carried alongside via bind_strings (fed to the mount-aware run, since draupnir's generic Boot trait has no mount-aware boot).
f5 — the detached + exec handle (knob 3 — landed)
Skidbladnir's MachineController is start()->Handle / exec(Handle,cmd) / stop(Handle) (run detached, exec into it, rm -f). The detached handle + the stop half already existed as jera's stateful [BootProc] (poll/kill); the exec op is now landed, completing the airgap→lifecycle-swap chain (draupnir's ContainerControl::exec half landed in draupnir 0.1.6).
- [
BootProc::exec] —fn exec(&self, argv: &[&str]) -> Result<ExecOutcome, String>, a provided trait method (additive, likesend_stdin): the default is an honest "this boot proc does not support exec" (a plain Exe/VM boot has no container). Only the detached container proc overrides it, so a caller reaches it on the returnedBox<dyn BootProc>with no downcast. - The forward. The detached [
DraupnirContainerBootRunner] proc holds the exact(backend: B, machine: draupnir::Machine)pair draupnir's exec needs, so its override is a one-liner:self.backend.exec(&self.machine, argv).machine.id(draupnir-<name>) IS the container idpodman exectargets — nothing else to plumb. draupnir's [ContainerControl::exec] runs its own container-only + non-empty- argv guards, assembles["exec", id, argv…], and drives the ONE bollard/execop. - [
ExecOutcome] ({ exit_code, stdout, stderr }) is re-exported fromdraupnir::containerasjera::container::ExecOutcome, so a consumer reads the result without naming draupnir. A non-zero exec exit is inexit_code, never anErr; only a guard failure or an engine/connect error isErr. - Always compiled, mock-tested, feature for the live run. The forward is exercised daemon-free via draupnir's provided
execagainst jera's mockContainerControl(the test asserts the proc's machine id + argv reach the engine and the outcome maps back — RED-when-broken on a wrong machine/argv). The LIVEpodman execneeds jera'scontainer-oci(⇒draupnir/backend-oci); without it draupnir'sexec_commandhonestly reportsUnsupportedrather than faking a run. The live smoke is a Loki integration concern, deferred (same posture as the create/run-to-completion paths).
Skidbladnir consume (next): spawn detached → get the proc → proc.exec(&["…"]) now expresses the full start/stop/exec live-swap over jera→draupnir, unblocking the Skidbladnir lifecycle swap.