Declare the network interface a selected microvm archetype needs #32

Closed
allod-agent wants to merge 1 commit from agent/microvm-guest-networking into master
Member

A machine that selects the microvm runtime now gets a network card. It declares exactly one TAP interface carrying its inventory MAC and a host device name derived from the machine name, and the change's own tests are what prove no address follows the MAC across the boundary. Nothing about libvirt machines changes: all four libvirt derivations are byte-identical to master. Nothing boots yet — this is an evaluation-time slice, and the interface is not proven to carry traffic until the boot milestone.

Before this, microvm.interfaces stayed at upstream's [] default, so the generated QEMU command had no -netdev and no -device virtio-net-* at all. The host half in allod/nexus was already reading guest.microvm.interfaces and waiting for the declaration.

What the guest declares

One entry: type = "tap", id derived as vm-<machine> and overridable through allod.archetypes.microvm.hostInterfaceId, mac from the inventory fact. That id is the host TAP device name allod/nexus hands to ip tuntap add, not a guest-side name.

The declaration is gated on the builders' module lists with lib.optional (runtime == "microvm"), never lib.mkIf, for the reason the volume declarations already record: microvm.interfaces is not declared under the libvirt guest module, so an ungated definition is an unmatched-option error on every libvirt machine, and mkIf defers the value rather than the definition.

What archetypes validates that nothing else does

Upstream checks duplicate ids, bridge consistency, and the 15-character name limit. It performs no MAC validation and no check that an id is a usable interface name, and QEMU accepts a multicast or all-zero MAC and then strands the guest at boot with nothing having reported why. Inventory validates none of it either. So archetypes asserts per entry, over the merged config.microvm.interfaces rather than its own definition: exactly one entry, type tap, the expected and structurally valid host id, the expected and structurally valid unicast non-zero MAC, no bridge, and tap.vhost off. Reporting per entry is what lets one fixture carrying fourteen entries reach fourteen named diagnostics off a single evaluation.

The address scanner and its limits

runtime-module-selection scans eight surfaces for address-shaped values: the merged interface entry, the reconstructed QEMU command line, the generated host TAP scripts, a projected guest surface, boot.kernelParams, the rendered systemd-networkd units, the resolver files, and the /etc/hosts inputs. Two needle kinds run over them — every inventory machine's exact ip, and a generic dotted-quad and IPv6 shape anchored outside store paths.

Stated so the scanner is not read as a whole-system guarantee: activation scripts, non-networkd units, environment.etc beyond the named files, and firewall commands are not scanned, and an address planted there would pass. Closing that needs a sweep over a built artifact, which belongs to the slice that boots a real guest (parent acceptance test 8), not to an evaluation-time check. The generic shape is enforced clean on the privacy fixture; the moved dev fixture runs in expected-superset mode, where a stray finding would pass.

Address sabotages are bound as a findings list rather than added to the sabotage list, because an address evaluates cleanly — there is no assertion for it to trip.

Risk

R2 Medium. Localized to allod/archetypes flake.nix; no host, secret, or deployed state is touched, and rollback is a straight revert. The residual concern is not the libvirt path, which is pinned byte-for-byte below, but the scanner's stated blind spots: an address introduced through an activation script or a non-networkd unit passes this check and would first be caught at the boot milestone.

The MAC is the only machine-derived value crossing into the guest. machines.<name>.ip sits one attribute away from machines.<name>.mac in inventory, which is the specific accident the scanner exists to catch.

Validation

Every libvirt derivation is byte-identical to master:

Machine system.build.toplevel.drvPath
allod-dev 1lhcxfhy9iddfssp3df3r2zwkwnd3ilm
privacy-1 zzzaralwv032jdmbqc1q6ms9fm15w7yf
nexus fad4pq24f8iavzr3q369alz8932jq9q3
installer 2yj5038jnsgzbv6ghhmyafs5kspn9jrg

nix build --rebuild .#checks.x86_64-linux.runtime-module-selection passes on this branch head and reproduces its output. It reports both fixtures matching their declared runtime and all 22 negative cases failing, of which these are this slice's:

  • interface entries breaking every declared rule at once — one fixture, fourteen entries, nineteen separately pinned diagnostics covering wrong type, a real bridge, tap.vhost, wrong id, wrong MAC, empty/slash/space/colon/../overlong ids, a malformed MAC, a multicast MAC, the all-zero MAC, upstream's duplicate-id and length assertions, and a non-null microvm.extraArgsScript — the one route into the QEMU command line no evaluation-time scan can follow, so it is closed by assertion instead;
  • microvm guest with its interface removed.

Each needle is anchored to its own reporter, so deleting one rule turns the fixture red for that rule alone rather than red for any reason.

A fresh evaluation of this check peaks at 4.83 GB — measured with \time -v unbounded. That is inside the box's headroom today but is the cost that motivates allod/archetypes#31, which lands before the credentials slice.

Review fixes in the second commit

An adversarial diff review returned a merge verdict with five fixes, applied in 8aa0fce: an unused option-name binding and a disjunct that can never hold deleted (the dev and privacy fixture names come from filterAttrs over disjoint type values); the assertion-rejection arm removed from the per-fixture reporter, because both consumers force the configuration first so a module rejection surfaces as NixOS's own failed-assertions throw and this arm could never be the reported diagnostic; the multicast sabotage MAC's first octet changed from 53 to 03, since with 53 both hex digits are odd and the case passed whether the rule read the low digit or the high one; and the two comments above narrowed to state what the scanner does not cover.

Closes allod/archetypes#28
Refs allod/strategy#20

A machine that selects the microvm runtime now gets a network card. It declares exactly one TAP interface carrying its inventory MAC and a host device name derived from the machine name, and the change's own tests are what prove no address follows the MAC across the boundary. Nothing about libvirt machines changes: all four libvirt derivations are byte-identical to master. Nothing boots yet — this is an evaluation-time slice, and the interface is not proven to carry traffic until the boot milestone. Before this, `microvm.interfaces` stayed at upstream's `[]` default, so the generated QEMU command had no `-netdev` and no `-device virtio-net-*` at all. The host half in `allod/nexus` was already reading `guest.microvm.interfaces` and waiting for the declaration. ### What the guest declares One entry: `type = "tap"`, `id` derived as `vm-<machine>` and overridable through `allod.archetypes.microvm.hostInterfaceId`, `mac` from the inventory fact. That `id` is the host TAP device name `allod/nexus` hands to `ip tuntap add`, not a guest-side name. The declaration is gated on the builders' module lists with `lib.optional (runtime == "microvm")`, never `lib.mkIf`, for the reason the volume declarations already record: `microvm.interfaces` is not declared under the libvirt guest module, so an ungated definition is an unmatched-option error on every libvirt machine, and `mkIf` defers the value rather than the definition. ### What archetypes validates that nothing else does Upstream checks duplicate ids, bridge consistency, and the 15-character name limit. It performs no MAC validation and no check that an `id` is a usable interface name, and QEMU accepts a multicast or all-zero MAC and then strands the guest at boot with nothing having reported why. Inventory validates none of it either. So archetypes asserts per entry, over the merged `config.microvm.interfaces` rather than its own definition: exactly one entry, type `tap`, the expected and structurally valid host id, the expected and structurally valid unicast non-zero MAC, no bridge, and `tap.vhost` off. Reporting per entry is what lets one fixture carrying fourteen entries reach fourteen named diagnostics off a single evaluation. ### The address scanner and its limits `runtime-module-selection` scans eight surfaces for address-shaped values: the merged interface entry, the reconstructed QEMU command line, the generated host TAP scripts, a projected guest surface, `boot.kernelParams`, the rendered `systemd-networkd` units, the resolver files, and the `/etc/hosts` inputs. Two needle kinds run over them — every inventory machine's exact `ip`, and a generic dotted-quad and IPv6 shape anchored outside store paths. Stated so the scanner is not read as a whole-system guarantee: activation scripts, non-networkd units, `environment.etc` beyond the named files, and firewall commands are **not** scanned, and an address planted there would pass. Closing that needs a sweep over a built artifact, which belongs to the slice that boots a real guest (parent acceptance test 8), not to an evaluation-time check. The generic shape is enforced clean on the privacy fixture; the moved dev fixture runs in expected-superset mode, where a stray finding would pass. Address sabotages are bound as a findings list rather than added to the sabotage list, because an address evaluates cleanly — there is no assertion for it to trip. ## Risk R2 Medium. Localized to `allod/archetypes` `flake.nix`; no host, secret, or deployed state is touched, and rollback is a straight revert. The residual concern is not the libvirt path, which is pinned byte-for-byte below, but the scanner's stated blind spots: an address introduced through an activation script or a non-networkd unit passes this check and would first be caught at the boot milestone. The MAC is the only machine-derived value crossing into the guest. `machines.<name>.ip` sits one attribute away from `machines.<name>.mac` in inventory, which is the specific accident the scanner exists to catch. ## Validation Every libvirt derivation is byte-identical to master: | Machine | `system.build.toplevel.drvPath` | |---|---| | `allod-dev` | `1lhcxfhy9iddfssp3df3r2zwkwnd3ilm` | | `privacy-1` | `zzzaralwv032jdmbqc1q6ms9fm15w7yf` | | `nexus` | `fad4pq24f8iavzr3q369alz8932jq9q3` | | `installer` | `2yj5038jnsgzbv6ghhmyafs5kspn9jrg` | `nix build --rebuild .#checks.x86_64-linux.runtime-module-selection` passes on this branch head and reproduces its output. It reports both fixtures matching their declared runtime and all 22 negative cases failing, of which these are this slice's: - interface entries breaking every declared rule at once — one fixture, fourteen entries, nineteen separately pinned diagnostics covering wrong type, a real bridge, `tap.vhost`, wrong id, wrong MAC, empty/slash/space/colon/`..`/overlong ids, a malformed MAC, a multicast MAC, the all-zero MAC, upstream's duplicate-id and length assertions, and a non-null `microvm.extraArgsScript` — the one route into the QEMU command line no evaluation-time scan can follow, so it is closed by assertion instead; - microvm guest with its interface removed. Each needle is anchored to its own reporter, so deleting one rule turns the fixture red for that rule alone rather than red for any reason. A fresh evaluation of this check peaks at 4.83 GB — measured with `\time -v` unbounded. That is inside the box's headroom today but is the cost that motivates allod/archetypes#31, which lands before the credentials slice. ### Review fixes in the second commit An adversarial diff review returned a merge verdict with five fixes, applied in `8aa0fce`: an unused option-name binding and a disjunct that can never hold deleted (the dev and privacy fixture names come from `filterAttrs` over disjoint `type` values); the assertion-rejection arm removed from the per-fixture reporter, because both consumers force the configuration first so a module rejection surfaces as NixOS's own failed-assertions throw and this arm could never be the reported diagnostic; the multicast sabotage MAC's first octet changed from `53` to `03`, since with `53` both hex digits are odd and the case passed whether the rule read the low digit or the high one; and the two comments above narrowed to state what the scanner does not cover. Closes allod/archetypes#28 Refs allod/strategy#20
Preserved after an agent session limit interrupted the change. The
libvirt no-op still holds at this point; validation is incomplete.

Refs allod/strategy#20
Author
Member

Opened against the wrong head. allod change submit defaulted the head to the local branch name agent/microvm-guest-networking, whose remote copy is a superseded work-in-progress snapshot from an interrupted session; the reviewed work is on agent/microvm-guest-networking-v2. Reopening from that branch.

Opened against the wrong head. `allod change submit` defaulted the head to the local branch name `agent/microvm-guest-networking`, whose remote copy is a superseded work-in-progress snapshot from an interrupted session; the reviewed work is on `agent/microvm-guest-networking-v2`. Reopening from that branch.
allod-agent closed this pull request 2026-08-01 03:21:27 +01:00

Pull request closed

Sign in to join this conversation.
No description provided.