Gunnar

nordisk/Skidbladnir

.nornir/socket-deployment-guide.md

<!-- nornir:class: guide -->
# Sockets — guide

Design: [`socket-deployment.md`](socket-deployment.md). Feature: `sockets` (off by default).

```toml
skidbladnir = { version = "0.1", features = ["sockets"] }
```

## The whole lifecycle

```rust
use skidbladnir::socket::{socket_for, BuildThingType, Mode, FixtureKind, FixtureHealth};

// 1. Type in, socket out. No per-cell config — this is the registry the pane calls.
let ty = BuildThingType::from_kind("tunnr-appliance").unwrap();   // from build-variants.toml
let socket = socket_for(ty);

// 2. The plan + topology render BEFORE anything boots (so the pane is never empty).
let plan = socket.plan(Mode::Test);
for row in plan.topology() {
    println!("{:<16} {:<28} {}", row.kind.as_str(), row.detail,
             row.endpoint.as_deref().unwrap_or("-"));
}

// 3. Light it. Fixtures are realized; health is Ready / NotApplicable / Down / NotImplemented.
let mut dep = socket.light(Mode::Test);
let netdev = dep.qemu_netdev_arg().expect("this socket runs on a shared net");
// -> "user,id=n0,net=10.9.0.0/24,host=10.9.0.1,restrict=off,dhcpstart=10.9.0.100,dns=10.9.0.1"
// Prepend `-netdev ` and pair it with `-device virtio-net` on the guest.

// 4. The operator adds a WireGuard key (Test mode only).
dep.add_wg_peer(peer_pubkey_b64, Some("10.9.0.1:51820".into()), vec!["10.9.0.0/24".into()])?;

// 5. Feed the captured boot console back in. THIS is what turns things green.
dep.confirm(&console);
assert_eq!(dep.fixture(FixtureKind::Dhcp).unwrap().health, FixtureHealth::Serving);

// 6. Assert against the node.
for probe in socket.probes() {
    let v = probe.run(&console);
    println!("{}: {} — {}", v.probe, if v.passed { "PASS" } else { "FAIL" }, v.detail);
}

// 7. Render, then tear down (consumes it; the operator's keys go with it).
let json = dep.json();
let order = dep.teardown();
```

## Reading the health of a fixture

| Health | `ok()` | What it means |
|---|---|---|
| `Ready` | ✔ | provisioned, will serve — **not yet observed serving** |
| `Serving` | ✔ | observed serving, from real console evidence |
| `NotApplicable` | ✔ | off in this mode by design (Prod DHCP). Serves nothing, fakes nothing |
| `Down(why)` | ✘ | it failed, or Prod refused it. `why()` quotes the proof |
| `NotImplemented(why)` | ✘ | nothing provisions this yet. `why()` names the missing seam |

`dep.all_ok()` is true only if every fixture is in the top three. `dep.refusals()` gives you
`(kind, why)` for the bottom two — render that, not a spinner. **Every `why` carries a
`TO FIX:` clause** (`socket::REMEDY_MARKER`); split on it to show the remedy as its own line.

## Reading the evidence

`confirm()` classifies the console per fixture into an `Evidence`:

| `Evidence` | meaning |
|---|---|
| `Served` | proof it worked — a bound lease in the pool, a **completed** handshake |
| `Failed(line)` | proof it did not, quoting the line. Wins over success evidence anywhere else, and downgrades even `Serving` |
| `Unknown(why)` | does not settle it, and says why ("a DHCP attempt but no bound lease in the pool…") |

The readers are public and independently testable: `NetSpec::dhcp_evidence`,
`wg_evidence`, `dns_evidence`, plus `NetSpec::leased_address` / `in_dhcp_pool` and
`parse_handshake_age_secs`.

## WireGuard peer states

| `Handshake` | `is_up()` | when |
|---|---|---|
| `NeverConnected` | ✘ | added, nothing observed |
| `Handshaking` | ✘ | an initiation was seen, no completion |
| `Established { last_handshake_secs }` | ✔ if fresh | a completed handshake |
| `Established` past 180 s | ✘ (`as_str()` = `"stale"`) | older than WireGuard's `REJECT_AFTER_TIME` |
| `Lost` | ✘ | it was up, and the tunnel is now proven dead |

`Handshake::STALE_AFTER_SECS` is 180 — WireGuard's own `REJECT_AFTER_TIME`. Staleness can only
be detected when the console carries a `wg show` capture (`latest handshake: … ago`); a plain
boot console has no age signal, so `confirm` records the observation as fresh.

## Adding things

**A fixture:** `impl Fixture` (at minimum `kind()`; override `runtime()` and `light_health()` if
it has preconditions or its own runtime) → add one arm to `fixture_for_kind`. If a socket should
compose it, add the kind to that socket's arm in `SocketPlan::for_type`. Nothing else — the plan
and the deployment stay in lockstep automatically.

**A socket:** add an arm to `SocketPlan::for_type` (its fixture list, console count) → `impl
Socket` (usually just `accepts` + `plan`; override `probes()` if it has cluster-level assertions)
→ add an arm to `socket_for`. Zero pane changes.

**A probe:** `impl Probe { fn name(&self) -> &str; fn run(&self, console: &str) -> ProbeVerdict }`
→ return it from the relevant socket's `probes()`.

**A network backend:** `impl NetworkBackend`. **Override `is_test_grade() -> false` only if it
really is production-fit** — the default is `true`, and `Mode::Prod` refuses every fixture a
test-grade backend realizes.

## Gotchas

- **`light()` never returns `Serving`.** If you expected green and got `Ready`, you have not
  called `confirm()` with a console yet.
- **`Mode::Prod` on `TunnrTestSocket` is `all_ok() == false` on purpose.** The only net backend
  is slirp, which is test-grade. Read `refusals()`; it explains each one. See the design doc.
- **`net` is an `Option`.** Single-boot sockets (exe/console/kvm/iso/container) have none, and
  `qemu_netdev_arg()` returns `None` for them.
- **`confirm()` can go backwards.** Failure evidence downgrades even a `Serving` fixture, and
  takes the peer rows with it (`Established` → `Lost`). Deliberate: a tunnel that dropped is not
  still serving.
- **Silence changes nothing.** An uninformative console leaves every fixture exactly as it was —
  and `Evidence::Unknown(why)` tells you what it looked for.
- **An attempt is not a success.** `udhcpc: broadcasting select for 10.9.0.157` is a DHCPREQUEST,
  not a lease; `Sending handshake_initiation` is not a completed handshake. If you are writing a
  fake console for a test, use `lease of … obtained` and `Received handshake_response`, or crib
  from the `transcripts` module.
- **A stale handshake is not up.** Past 180 s (`REJECT_AFTER_TIME`) `is_up()` is false.