Skidbladnir OWNS the Build Thing SOCKETS
Upstream design: workspace_nordisk/.nornir/socket-deployment-design.md — the bulb/socket metaphor, the type→socket table, and the cross-repo split live there. This doc is the authority for what Skidbladnir actually implements, in src/socket.rs behind the sockets cargo feature (additive, off by default, never perturbs the shipping install/PKI paths).
A Build Thing is a bulb (an output artifact of a TYPE); every type has a matching socket — the fixture graph it plugs into to light up and be observed. The dwarves ⚒ pane reads the bulb's type and fires the matching socket automatically. Sockets carry a Mode { Test, Prod }, so this is a deployment/topology system and "test" is one mode.
What lives here (the ownership, complete)
| Piece | Where | Note |
Mode, BuildThingType, FixtureKind |
socket.rs |
the taxonomy |
| type→socket registry | socket_for(BuildThingType) -> Box<dyn Socket> |
what the pane calls |
| the composition table | SocketPlan::for_type |
one match arm per bulb type |
| fixture registry | fixture_for_kind(FixtureKind) -> Box<dyn Fixture> |
one arm per kind |
| the four seams | Socket · Fixture · NetworkBackend · Probe |
traits |
| net backend | TestNet (slirp/user-net) → NetSpec |
mode-aware |
| fixtures | Dhcp · Dns · WireGuardPeer · TestNet · NoCloudSeed · Host* · Unimplemented* |
|
| lifecycle | light → SocketDeployment → confirm → teardown |
|
| WG key management | WgKeyPanel (own key, peers, add-a-peer, handshake) |
Test-mode surface |
| probes | AppAliveProbe · NodesReadyProbe · WorkloadHealthyProbe |
over console text |
| pane surface | SocketDeployment::json() · SocketPlan::topology() |
auto-fetch, never a dead pane |
Not ours: the fixture runtimes (dhcp/dns/wg payloads) are tiny tunnr appliances and the bulb boots ride tunnr's QEMU/OVMF primitive; the rendering (console grid, topology graph, keys panel, probe results) is the dwarves-demo ⚒ pane.
The plan/deployment lockstep
Socket::fixtures maps the socket's own plan().fixtures through fixture_for_kind, and SocketDeployment::compose is the single constructor. So the topology the pane draws (plan.topology()) and the deployment it reads (light().fixtures) are the same list, in the same order, by construction — a socket cannot grow a fixture in its plan that has no deployment row behind it. (It could before: TunnrTestSocket planned 6 kinds and lit 3.) Guarded by every_socket_lights_exactly_the_fixtures_its_plan_composes, which sweeps every registered type in both modes.
Two honesty rules
1. Nothing is green until it is observed — and a failure is louder than a success. Fixture::light_health may return Ready / NotApplicable / Down(why) / NotImplemented(why) — never Serving. Only SocketDeployment::confirm(console) moves a fixture, by reading the boot console into an Evidence:
Evidence |
means | effect |
Served |
the console proves it worked | Ready → Serving (unless Prod refuses it) |
Failed(line) |
the console proves it did not | Ready/Serving → Down, quoting the line |
Unknown(why) |
the console does not settle it, and says why | no change — unobserved is not healthy |
The marker vocabularies come from the real tools
Not from the happy path someone remembered. Transcribed from:
- busybox udhcpc 1.37.0 —
strings /usr/bin/busyboxfor the complete set of format strings it can print, plus live captures on t14s (2026-07-27): udhcpc againstdnsmasqover a veth pair in an unprivileged user+network namespace (unshare -Urn, no root). - boringtun 0.7.1 —
src/noise/timers.rsandsrc/noise/errors.rs(tracing::messages and theWireGuardErrorvariants). This host has nowgbinary, so these are the tool's own message strings, not a capture, and the transcripts say so. - ISC dhclient and
wg(8)— documented output.
They live in the transcripts test module — success, failure, partial and silent for each fixture, every one naming its provenance.
The governing rule: a success marker must never be able to appear inside a failure or attempt line. Three real cases where the naive check got that wrong — two of them found by reading the actual tool output rather than by reproducing a bug:
| line | what it is | old verdict |
udhcpc: broadcasting select for 10.9.0.157, server 10.9.0.1 |
a DHCP REQUEST, carrying a real pool address | read as a lease |
Sending handshake_initiation |
an attempt nobody answered | read as a completed handshake |
HANDSHAKE(REKEY_TIMEOUT) |
boringtun's retry warning | read as a completed handshake |
no_success_marker_can_hide_inside_a_failure_or_attempt_marker enforces the rule structurally, so the next loose marker fails at test time rather than in the field.
DHCP now requires two things together: a bound marker (lease of … / bound to … / DHCPACK) and an address inside the pool. Either alone is a fake green — an attempt line carries a pool address, and a static fallback carries a subnet address. saw_dhcp_lease was tightened to mean what its name always claimed; it used to mean "any subnet address that is not the gateway".
WireGuard now requires a completed handshake (Received handshake_response / Sending handshake_response / latest handshake:), never an initiation.
Failure markers are matched first and they win, anywhere on the console, and they win even from Serving — a tunnel that dropped is not still serving. The first two fake-greens this caught, both regression-tested:
wg: Handshake did not complete after 5 seconds— the failure message contains the word "handshake", and the substring check read it as a successful handshake.udhcpc: no lease, failing over to static 10.9.0.55— a client that got no lease read as having leased one, turning DHCP and the DNS riding it green.
NotImplemented is fail-closed too: FixtureHealth::ok() is false, so a socket composing an unbuilt fixture never reports itself healthy, and refusals() gives the pane the reason to render instead of a dead button.
A resolver on a net that declares no nameserver is Down at light-up: it could not resolve anything, so it does not get to be Ready.
DNS inherits DHCP's evidence — one direction only
The DNS fixture may lean on the DHCP fixture's evidence only when it is literally the same slirp stack (FixtureRuntime::SlirpBuiltin), and then it may never be more confident than what it inherits. The relation is deliberately asymmetric:
| DHCP evidence | DNS (same slirp stack) | why |
Served |
Served |
the stack demonstrably answered |
Failed |
Unknown(why) |
DHCP failing does not prove the resolver is broken — inheriting a failure as a failure would blame DNS for DHCP's problem |
Unknown |
Unknown(why) |
confidence cannot be manufactured |
A DNS fixture running as its own payload inherits nothing and says so: "this resolver is its own payload, so it needs its own evidence".
The WireGuard peer lifecycle
The peer rows in WgKeyPanel follow the tunnel rather than only ever going green — the fixture going Down while its peers stayed Established was the same fake green one level in.
| observation | peer transition |
| completed handshake | → Established { last_handshake_secs } |
| tunnel proven failed/expired | Established/Handshaking → Lost; NeverConnected stays put |
| only an initiation seen | NeverConnected → Handshaking (never Established) |
| invalid / duplicate key | rejected at add_peer, never enters the panel |
Lost is deliberately distinct from NeverConnected: "it broke" and "it never worked" are different things to show an operator.
Staleness is real and is not invented. Handshake::STALE_AFTER_SECS = 180 is WireGuard's own REJECT_AFTER_TIME (boringtun src/noise/timers.rs:20) — the point at which the protocol refuses the session's keys. is_up() is false past it and the pane label becomes stale.
The honest limit: the age signal only exists in a wg show capture (latest handshake: 6 minutes, 12 seconds ago), which parse_handshake_age_secs reads. A plain boot console has no age — boringtun's Received handshake_response says a handshake happened, not how long ago. So confirm uses the parsed age when a wg show capture is present and otherwise records the observation as fresh (age 0). It does not invent a timeout where no age signal exists; a socket that never captures wg show simply cannot detect staleness, and that is stated rather than papered over.
2. Mode::Prod never silently serves a test fixture. FixtureRuntime::is_test_grade() marks the fakes. Today that is slirp/QEMU user-net: an in-process, unroutable NAT with a toy DHCP server and a DNS forwarder bolted on — a superb test fixture and not a network anyone deploys onto. A tunnr Payload (real boringtun) and the Host boot path are the real things.
In Mode::Prod, any fixture that would come up Ready/Serving on a test-grade runtime becomes Down("refused in Mode::Prod: …"). This is enforced twice — at compose (light-up) and again in confirm (the evidence path), because a genuine slirp lease is still test evidence and must not turn a Prod fixture green. NotApplicable and NotImplemented are left alone: they serve nothing, so they fake nothing. The default is fail-closed — NetworkBackend::is_test_grade() returns true unless a backend explicitly claims production fitness, so forgetting to think about Prod cannot produce a Prod-labelled test net.
What that means for a Prod tunnr socket today
| Fixture | Mode::Test |
Mode::Prod |
test-net (slirp) |
Ready | Down — refused, test-grade |
dhcp |
Ready → Serving | NotApplicable (static/seeded, serves nothing) |
dns (slirp forwarder) |
Ready → Serving | Down — refused, test-grade |
wireguard-peer (wg-appliance) |
Ready → Serving | Ready → Serving (real boringtun) |
nocloud-seed |
Ready (pre-filled, auth-less) | NotImplemented — a Prod seed needs real credentials, which this layer mints none of |
kvm-boot |
Ready | Ready |
So TunnrTestSocket.light(Mode::Prod).all_ok() is false, on purpose. Prod is not implemented end to end because no production-grade NetworkBackend exists yet (bridge-tap / router-VM / qemu-socket-mesh are all future impls) and nothing mints Prod credentials. Rather than shipping a green Prod button backed by slirp, the socket refuses and says why, per fixture.
Every refusal is actionable
A reason that only says "test-grade" leaves an operator stuck, so every Down / NotImplemented reason in the system carries a concrete next step after a TO FIX: marker (REMEDY_MARKER), which the pane can render as an instruction:
| gap | the remedy it names |
| slirp net / DNS / DHCP / NAT in Prod | impl NetworkBackend with is_test_grade() -> false (bridge-tap, router-VM, qemu-socket-mesh) and compose it in place of TestNet |
| Prod NoCloud seed | give NoCloudSeedFixture a credential source — pki and openbao are already in this crate |
| k8s control-plane | boot a Talos/k3s node on the socket's net (machine::kvm already boots VMs) and hand talos_apid its endpoint — both ends exist, the launcher between them does not |
| k8s worker | the same launcher, with MachineConfig::worker(..) and a join token |
| CNI | once a control-plane fixture exists, apply the CNI manifests through K8sApplier like any other workload |
| OCI registry | run one as a tunnr payload on the socket's net, or point the cluster at an external one via MachineConfig::with_mirror |
every_refusal_and_every_gap_names_what_would_fix_it sweeps every registered socket in both modes and fails if any reason is a dead end, so a new refusal cannot ship without one.
k8s playground — what is real and what is not
socket_for(K8sWorkload) → K8sSocket, composing [TestNet, K8sControlPlane, K8sWorker×2, Cni, OciRegistry] with a 4-console grid.
Real: the composition, the topology (each node's endpoint), the probes, and Skidbladnir's existing k8s limbs — k8s::KubernetesRenderer (manifests as data), k8s_apply::K8sApplier (server-side apply / scale / wait against an existing cluster, feature kubernetes), unfold::talos::MachineConfig + talos_apid (machineconfig render + apid ApplyConfiguration / Bootstrap, feature talos). See kubernetes-target-design.md, k8s-unfold.md, talos-unfold.md.
Not built — and it says so: nothing boots a cluster node for this socket. The control-plane / worker / CNI / registry fixtures light as NotImplemented carrying the exact reason, so K8sSocket.light(..).all_ok() is false and refusals() names the four gaps. The missing piece is a provisioner that boots a Talos/k3s node in a VM on the socket's net and hands talos_apid the endpoint; the renderer and the push already exist on both sides of it.
The probes are real now and are the half that can be trusted before the provisioner lands:
NodesReadyProbe { expected_nodes }— parses theNAME STATUS …node roster off the control-plane console. Fail-closed: an empty roster fails, a missing node fails, andReady,SchedulingDisabledis not Ready.WorkloadHealthyProbe— rollout completion +READY n/mreplica columns. Any under-replicated workload fails even if another line claims "successfully rolled out"; a console with no rollout evidence at all fails.
Both are zero-shell: they read console text the Build Thing already captures. No kubectl is ever executed — K8sApplier is the API-side counterpart.
Consuming it
The pane never needs per-cell config: type in, socket out. Adding a socket = one arm in SocketPlan::for_type + one arm in socket_for + impl Socket. Adding a fixture = impl Fixture + one arm in fixture_for_kind. Zero pane changes either way.
Worked examples: socket-deployment-guide.md (the lifecycle, the health table, how to add each kind of thing) and socket-deployment-for-idiots.md (zero-knowledge walkthrough, including why a Prod socket is unhealthy on purpose).
Status
cargo test --features sockets: 244 lib tests green (61 in socket::). Default-feature build unchanged at 183. Every rule above has a test that goes RED when the rule is reverted — verified by reverting is_test_grade (2 red), the plan/deployment lockstep (5 red), the failure-first evidence rule (3 red), the three k8s honesty rules (3 red), the WireGuard completion requirement (3 red), the DHCP bound-marker requirement (5 red), handshake staleness (3 red) the peer-follows-the-tunnel rule (2 red) and the actionable-remedy rule (3 red).
Open, in order: (1) a production NetworkBackend + a Prod credential source, which together turn Mode::Prod from "refuses, with reasons" into a real deployment; (2) the k8s node provisioner; (3) Fixture::provision as a live process/VM handle rather than a config descriptor — today light() realizes fixtures as descriptors the consumer attaches (the netdev arg, the payload config), which is honest but is not yet a supervisor.