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

nordisk/Skidbladnir

.nornir/machine-jera-routing.md

machine.rs spawns → jera (edda), the ONE job/container/VM manager

jera-dedup audit (2026-07-14), worst offender. Skidbladnir's machine module is a complete parallel container+VM engine: container::ContainerController shells straight to podman/docker (run -d / rm -f / exec) and kvm::KvmController spawns qemu-system-x86_64 — consuming zero jera despite already depending on draupnir. The constellation law (L5) is that job/container/VM spawning lives in exactly ONE place: jera (edda/crates/jera), the durable job ledger over draupnir's ONE OCI/KVM engine.

This is the additive, swap-ready first step (feature jera). It adds no new spawn path and removes none — the working Command paths are the untouched fallback. What it adds is the seam the swap needs plus an honest gap-list that keeps a live swap from silently regressing airgap isolation.

What landed (feature jera, src/machine.rs::jera_route)

Four pure methods on MachineSpec, plus RED-when-broken tests and a machine-jera testmatrix row:

method does
job_policy() -> jera::JobPolicy classify: an OCI image ref → Container (draupnir OCI); a disk image (Path/Cached) → Vm (draupnir KVM). The one source of truth for "which jera job kind is this machine".
to_jera_container_spec() -> Result<jera::container::ContainerSpec> map a container spec onto jera's ContainerSpec — image + name + net mode + bind mounts (LANDED) — which maps onward onto a draupnir::BootSpec via jera's to_draupnir. Bails on a VM disk (that routes through jera's vm seam).
jera_net() -> jera::container::NetMode the single container net mapping: Nat→Default (no --network), Isolated→None (airgap --network none), Named→Default (gap-guarded, never routed).
jera_unsupported() -> Vec<&'static str> the HONEST gap, now shrunk to the ONE remaining case: "net" when the spec asks for an arbitrary named host-network (jera's NetMode is Default/None/Host/Bridge, no arbitrary name). NAT + airgap net and bind mounts now carry faithfully.
jera_routable() -> bool true for a NAT, an airgap (Isolated), and a mount-bearing container (empty gap). Only a named-network container keeps the direct podman fallback. The guard a routed start checks before delegating to jera.

The chain MachineSpec → jera::container::ContainerSpec → draupnir::BootSpec is proven daemon-free: the test asserts the mapped BootSpec validate()s and carries the image, exercising only draupnir's always-compiled types (no podman, no feature engine).

The KVM/VM seam (mirror of the container seam, for kvm::KvmController::start)

kvm::KvmController::start shells straight to qemu-system-x86_64. The same swap-ready seam now classifies + maps a VM spec — four more pure MachineSpec methods, mirroring the container ones:

method does
to_jera_vm_spec() -> Result<jera::vm::BootSpec> map a disk VM onto jera's VM payload (name + disk + size); kernel/rootfs empty — a Skidbladnir VM is a bootloader-in-disk boot. Bails on an OCI ref (that routes through the container seam).
to_jera_vm_payload() -> Result<jera::agent::VmBoot> map onto the serde-ready agent VM payload (image/cores/mem_mb) — the wire-safe spec a remote worker maps back onto jera's VM runner (WorkPayload::Vm) in the multi-machine dispatch path.
jera_vm_unsupported() -> Vec<&'static str> the HONEST gap: "disk-boot" (always — see below), "net" (airgap -nic none/named), "provision" (cloud-init/Ignition seed jera's VM payload drops), "iso" (install/data CD).
jera_vm_routable() -> bool disk VM with an empty gap. Currently always false (the disk-boot gap never clears), so every VM keeps the direct qemu path.

Why the VM seam is a seam only — the load-bearing disk-boot gap

A Skidbladnir VM boots a full qcow2 through its own in-image bootloader with no separate -kernel (kvm::KvmController::full_argv attaches the disk as a virtio drive under snapshot=on). But jera/draupnir's VM payload is kernel-required: draupnir's KVM image sources are KernelRootfs { kernel, rootfs } and Disk { kernel, disk }, and draupnir::BootSpec::validate() rejects an empty kernel path — there is no kernel-less bootloader-in-disk VM mode. On top of that, jera's vm::BootSpec::to_draupnir is only compiled under the heavy vm-tunnr feature (unlike container's, which is always compiled), so the VM→draupnir leg isn't even reachable from the lean jera build. So "disk-boot" is always in the gap-list and jera_vm_routable() is always false — the direct qemu path is the untouched fallback (L2). The day jera/draupnir gain a kernel-less disk boot (+ net + cloud-init + iso passthrough, and an always-compiled VM to_draupnir), the gap clears, the tripwire test flips, and the live VM route lands.

Airgap live-route — LANDED (Skidbladnir → jera → draupnir --network none)

The container airgap route is now wired end-to-end at the spec/request layer. jera grew the two load-bearing container knobs (edda/crates/jera: NetMode on ContainerSpec, commit e317d6b; bind mounts, 8718d70), and jera's ContainerSpec::to_draupnir threads the net onward (d.net = draupnir::NetMode::from_oci_value(self.net.oci_value())) so draupnir's OCI engine sets HostConfig.network_mode = "none" on the live podman run (draupnir 970dacb). Skidbladnir now maps into it:

MachineSpec.net = NetMode::Isolated          (airgap intent)
        │  jera_net()
        ▼
jera::container::ContainerSpec.net = NetMode::None
        │  to_draupnir()  (draupnir::NetMode::from_oci_value(Some("none")))
        ▼
draupnir::BootSpec.net = NetMode::None  →  live `podman create --network none`  (NO egress)

The tripwire has flipped: jera_routable() is now true for an airgap (Isolated) container, so the airgap launch is delegatable through jera→draupnir instead of a direct podman --network none subprocess. The old gate — jera_unsupported() pushing "net" for any non-NAT net, which kept airgap off the route — is bypassed for Isolated: only an arbitrary named network still lands in the gap (jera cannot express an arbitrary named net). The direct podman path (run_argv honouring --network none) is kept unchanged as the documented fallback (L2) — nothing was removed.

Asserted, not just claimed (L4). airgap_container_routes_through_jera_with_network_none asserts the mapped jera::container::ContainerSpec carries net == NetMode::None AND its onward draupnir::BootSpec Debug carries net: None (the live --network none knob). RED-when-broken: neutralizing the Isolated → None mapping in jera_net() makes both catch the egress-allowed regression (verified). The machine-jera testmatrix row records the same as a green cell.

Live smoke-test (deferred). A real podman create --network none run + an egress-blocked probe is a Loki/integration-bencher concern (needs a podman host); this pass lands + asserts the spec/request carrying net=None. The remaining container knob for a lifecycle swap of start/stop/exec (a detached handle + exec-into model — jera's run_container is run-to-completion) stays deferred.

Why the VM live route is still a seam only

A live route of ContainerController::start's lifecycle (and the whole VM path) through jera is still gated on missing jera API. jera's run_container is run-to-completion (no detached handle + exec-into), and the VM payload is kernel-required (no bootloader-in-disk mode). jera_routable() / jera_vm_routable() remain the tripwires: any residual gap keeps the direct podman/qemu path.

Missing jera knobs (logged for the jera agent in Skidbladnir_agents_problems.md)

  1. ✅ ContainerSpec net mode — LANDED (jera e317d6b): NetMode::None → --network none. The airgap container route is live at the spec/request layer (see the "Airgap live-route" section above). The one residual sub-gap is an arbitrary named host-network (jera has Default/None/Host/Bridge).
  2. ✅ ContainerSpec bind mounts — LANDED (jera 8718d70): BindMount → draupnir -v run. Skidbladnir Mount now maps onto it; pack.rs/msi.rs can adopt it next.
  3. detached container with a stateless handle + exec-into — MachineController is start→Handle / stop(Handle) / exec(Handle, cmd); jera's DraupnirContainerBootRunner is a stateful BootProc with no exec, and run_container is run-to-completion. The lifecycle shapes don't line up.
  4. image build — pack.rs/msi.rs do podman build; jera wraps draupnir's run/boot, not build.
  5. VM: kernel-less bootloader-in-disk boot — draupnir's KVM image sources both require a -kernel; there is no full-disk (in-image bootloader) VM mode. The load-bearing VM gap (the analogue of the container net gap) — Skidbladnir boots exactly this shape, so no VM is routable until it exists.
  6. VM: net mode + cloud-init passthrough on jera::vm::BootSpec — jera's VM payload has no net mode (airgap -nic none) and no cloud-init (draupnir's BootSpec carries cloud_init, but jera's VM spec drops it) and no extra-ISO attach. Add these so a routed VM keeps airgap + firstboot provisioning.
  7. VM: jera::vm::BootSpec::to_draupnir is gated behind vm-tunnr — unlike container's (always-compiled), so the VM→draupnir leg is unreachable from a lean jera build. Make it always-compiled (it only needs draupnir's default BootSpec constructors) so the seam can prove the VM→draupnir mapping daemon-free like the container one does.

Remaining bypass sites in Skidbladnir (documented follow-ups)