Gunnar

mikronova/draupnir

.nornir/oci-network-mode.md

<!-- nornir:class: design -->
# OCI network mode — the airgap `--network none` wire (Skidbladnir's #1 blocker)

> **Why.** Skidbladnir's airgap container route needs a container that reaches **no
> network** — `podman run --network none`. jera's `ContainerSpec` already *renders*
> that choice (`net: NetMode`, `network_arg()` → `--network none`), but draupnir owns
> the ONE OCI engine and its create body carried `port_bindings` + `binds` and **no
> `network_mode`** — so jera could express the airgap but it never reached the live
> `podman create`. This threads the net mode onto the create body, **additively**, so
> `NetMode::None` actually cuts the container off from egress at the real run.

## The knob (a `net` field on `BootSpec`, `NetMode::Default` = unchanged)

Every `draupnir::BootSpec` is built through a constructor + builders — no consumer
(nornir/korp/knut/tunnr/jera/Skidbladnir) constructs it by raw struct literal (audited:
the only raw `BootSpec {…}` literals in the constellation are each repo's *own* local
`BootSpec` type). So a new field is safe as long as the four constructors default it.
`net` defaults to `NetMode::Default`, which renders **no** `--network` flag and leaves
the create body **byte-for-byte identical** to a spec that never named a net mode.

```rust
pub enum NetMode { Default, None, Host, Bridge }   // Default = no --network flag
impl NetMode {
    pub fn oci_value(self) -> Option<&'static str>;    // None | "none" | "host" | "bridge"
    pub fn is_default(self) -> bool;                    // parity guard
    pub fn from_oci_value(v: Option<&str>) -> Self;     // the cross-repo wire (jera passes this)
}
pub struct BootSpec { /* …, */ pub net: NetMode }
impl BootSpec { pub fn with_net(self, net: NetMode) -> Self; }   // builder
```

`NetMode` mirrors jera's `ContainerSpec::NetMode` field-for-field. jera passes its
rendered value across the repo boundary as the **OCI string** (`"none"`/`"host"`/
`"bridge"`) via `NetMode::from_oci_value(jera_spec.net.oci_value())` — no cross-repo
variant coupling. `net` is **container-only**: `validate()` rejects a non-default net
on KVM/Redfish (parity with the `cmd`/`ports` container-only guards).

## The wire (rides the existing bind delegate chain)

The create body already had a `*_with_binds` delegate chain; net rides it with one
more `*_and_net` layer, so the bind-free / net-free entries stay byte-identical.

| net-free (unchanged) | net-aware (new) | note |
|---|---|---|
| `Engine::create_body_with_binds` | `Engine::create_body_with_binds_and_net` | pure body builder; `None` net ⇒ identical body (`HostConfig.network_mode` stays `None`) |
| `Engine::create_and_start_with_binds` | `Engine::create_and_start_with_binds_and_net` | create+start with `HostConfig.network_mode` |
| — | `Boot::boot` / `boot_with_binds` **read `spec.net`** | both boot paths thread `spec.net.oci_value()` onto the create body |

Because both boot paths read `spec.net`, **every** downstream entry —
`Boot::boot`, `boot_with_binds`, `run_to_completion`, `run_to_completion_with_binds` —
honours the net mode automatically. A consumer just sets `spec.net`.

```
 jera ContainerSpec { net: NetMode::None }        draupnir (ONE OCI engine, bollard)
 ┌──────────────────────────────────────┐          ┌───────────────────────────────────┐
 │ to_draupnir():                        │          │ boot / boot_with_binds read spec.net│
 │   d.net = NetMode::from_oci_value(    │ ───────▶ │   → create_and_start_with_binds_and │
 │     self.net.oci_value())  // "none"  │          │     _net(…, Some("none"))           │
 │ run_to_completion_with_binds(&d, …)   │          │   → HostConfig.network_mode="none"  │
 └──────────────────────────────────────┘          │   → podman create --network none    │
   (was: expressed but dropped at the run)          └───────────────────────────────────┘
```

## The jera handoff (one line)

jera's `ContainerSpec::to_draupnir()` builds the `draupnir::BootSpec` that BOTH its
boot leg (`DraupnirContainerBootRunner`) and its run-to-completion leg
(`run_to_completion_with_binds`) consume. One added line carries the net mode through
both:

```rust
// in jera ContainerSpec::to_draupnir()
d.net = draupnir::NetMode::from_oci_value(self.net.oci_value());
```

After that line, `NetMode::None` reaches the live podman run, and Skidbladnir can
un-gate its airgap container route (drop the `--network none` fallback to a direct
`podman` subprocess).

## Status
- draupnir: **landed** — builds default (no bollard) + `--features backend-oci`;
  RED-when-broken engine test `create_body_sets_network_mode_none_and_default_is_byte_identical`
  + testmatrix cell `net_mode_renders_oci_value_and_is_container_only` green.
- jera: **one-line `to_draupnir()` change pending** (above) — then the net flows.
- Skidbladnir: un-gate the airgap route once jera's line lands.