chore(agents): remove obsolete guest scaffolding

This commit is contained in:
2026-07-29 09:28:12 -04:00
parent 3e3975c724
commit 7a97ee4e31
11 changed files with 0 additions and 535 deletions

View File

@@ -1,24 +0,0 @@
---
spec: guests
---
## What to build
A new bundle Module, `modules.toolkit`, that turns on the baseline interactive environment — fish, tmux, nvim, git, and direnv — as one unit.
Enabling it on a Host brings up all five together, so any Host or Guest shell feels identical.
This is a deliberate bundle wanted as a unit, distinct from the grouping-directory aggregate enables ADR 0004 rejected.
## Acceptance criteria
- [x] `modules.toolkit` declares an `enable` option following the Enable convention and lives at a path its file location mirrors, per the Namespace convention.
- [x] Enabling `modules.toolkit` turns on fish, tmux, nvim, git, and direnv as a group.
- [x] A Host that enables `modules.toolkit` still reads as a flat checklist — the bundle is one line, not a hidden group of five.
- [x] A Host enabling `modules.toolkit` builds via `nix flake check`, and the five underlying Modules are enabled (verifiable by `nix eval` of their `enable` values).
## Implementation Notes
- `modules/toolkit.nix` follows the sanctioned aggregator pattern of `modules/desktop/desktop.nix`: an index node whose `enable` sets each member's `enable = lib.mkDefault true`, so a Host can still override any single piece while the one flag brings up the bundle.
- `neogaia` was converted as the demonstrating Host: its five individual `modules.{fish,git,direnv,tmux,nvim}.enable = true` lines collapse to one `modules.toolkit.enable = true`.
- The bundle also sets `modules.fish.defaultShell`, so fish is the login shell wherever the toolkit is enabled — part of making any Host or Guest shell feel identical. This is `mkDefault`, so a Host can still opt out. `neogaia`'s previously explicit `defaultShell` line is therefore dropped.
- Verified: `nix flake check` builds `checks.x86_64-linux.neogaia`, and `nix eval` of each of the five members' `enable` on neogaia returns `true`.
- The working tree also carries `.claude/CONTEXT.md`, ADR 0006, and the `guests` spec — planning artifacts for the wider `guests` spec, not this task. They are deliberately left out of this task's commit and staged separately by the broader work.

View File

@@ -1,54 +0,0 @@
---
spec: guests
blocked-by: 0001-toolkit-bundle
---
## What to build
The tracer bullet for the Guest concept: the thinnest complete path from discovery to a running nested container.
The Auto-loader gains a third kind, discovering every Guest under `guests/` the way it already discovers Modules and Hosts.
The shared base config splits into a host base (`system.nix`, carrying host-only machinery — bootloader, hardware profile, host identity, boot and garbage-collection timers) and a slim guest-base that every nested Guest stands on.
Both keep the primary user, home-manager, and the shared overlays; the guest-base additionally auto-enables the `toolkit` bundle and `modules.ssh`, and imports the full `modules/` tree so any Module is available inside a Guest.
A minimal sample Guest is Module-shaped: it declares its own `guests.<path>` namespace with an `enable` and a `backend` field (default `container`, `microvm` reserved but not built), and guards its body per the Enable convention.
Its body's payload — the one difference from a Module — realizes a nested container running the Guest's interior on the guest-base.
A Host enables the sample Guest exactly as it enables a Module, and the whole thing builds through the existing `nix flake check` seam.
## Acceptance criteria
- [x] The Auto-loader discovers and wires every Guest under `guests/` as a third kind, with no manual `imports` edits, and a Guest's option path mirrors its `guests/` location per the Namespace convention (including the index-node and file-or-folder rules).
- [x] The shared base config is split into a host base and a slim guest-base; the existing Host still builds via `nix flake check` with its host-only machinery intact.
- [x] The guest-base includes the primary user, home-manager, and the shared overlays, auto-enables `toolkit` and `modules.ssh`, and imports the full `modules/` tree.
- [x] A sample Guest declares `guests.<path>.enable` plus a `backend` field defaulting to `container`, guards its body on `enable`, and realizes a nested container running its interior when a Host enables it.
- [x] The `microvm` backend value is accepted as reserved but unimplemented, failing clearly rather than silently building nothing.
- [x] A Host enabling the sample Guest builds via `nix flake check`, the guest is reachable from its Host by `machinectl` with no per-Guest configuration, and the baseline toolset and SSH access are present inside it.
## Implementation Notes
- The base split is realized as three files, not two.
`base.nix` is the shared substrate both bases build on — the primary user, home-manager, the `unstable`/`stable` overlays, and flakes.
`system.nix` (the host base) imports it and adds the host-only machinery (bootloader limit, sops decryption and the password, the maintenance timers, the chaotic cache, console keymap).
`guest.nix` (the guest-base) imports it and adds the slim guest layer.
Factoring the common substrate out keeps "both include the primary user, home-manager, and the shared overlays" a single fact rather than a duplicated one.
- The guest-base imports `sops-nix` and `stylix` alongside the full `modules/` tree.
This is load-bearing, not incidental: the module system pushes an `mkIf` down to the leaves it guards, so an option path a module names must be *declared* even where its `enable` is off.
`modules/ssh.nix` names `sops.*` and the desktop modules name `stylix.*`, so those option namespaces have to exist for the tree to evaluate inside a guest that leaves them disabled.
- `modules/ssh.nix` gained a guest flavor.
`hostKeys.restore` (default on) gates restoring host keys from secrets, and both `hostKeys.sopsFile` and `userKey.sopsFile` are now nullable.
A guest sets `restore = false` and names no sops files, so its daemon self-generates a host key and it carries no age key — verified: the interior's `sops.secrets` is empty and `services.openssh.hostKeys` falls back to the generated defaults, while `neogaia` still restores its committed host keys with `openssh.hostKeys = [ ]`.
- The namespace mirroring is honored by author discipline, exactly as a Module's is: `my.guest { name = "sample"; }` names the option path, and `guests/sample.nix` places it.
The Auto-loader change is the same recursive `collectNixFiles`, so the index-node and file-or-folder rules a folder-shaped guest would use come for free from the loader that already serves Modules — no folder-shaped guest exists yet to exercise them.
- The guest gets `privateNetwork = true` by default, so its interior sshd never contends with the host's on the shared namespace.
It is `mkDefault`, so the networking foundation can later attach the guest to a VLAN bridge.
- `backend` is an `enum [ "container" "microvm" ]` defaulting to `container`.
`microvm` is built by nothing; choosing it trips a build-time assertion with a clear message rather than silently producing no container, per the acceptance criterion and ADR 0006's reserved-value decision.
- `neogaia` enables `guests.sample` to demonstrate the path end to end, the way it demonstrated `modules.toolkit`.
This means a rebuild starts a live nspawn container on the laptop; it is a minimal smoke test and can be turned off with one line.
- `nix flake check` passes and builds the nested `nixos-system-sample` interior in full.
A pre-existing nixvim warning about its own `nixpkgs.follows` now also prints for the guest's reused Neovim config; it is upstream noise, not a defect in this change.

View File

@@ -1,35 +0,0 @@
---
spec: guests
---
## What to build
The host-level networking foundation, `modules.network`, that a Host declares once and every Guest attaches to.
A Host states its trunk interface and the set of VLANs to materialize, and the Module emits one bridge per tagged VLAN using systemd-networkd, named by the `br-vlan<id>` convention, and manages the Host's own management address.
This is a standalone host-level Module and does not yet wire any Guest to a bridge — that is the Guest networking placement slice.
## Acceptance criteria
- [x] `modules.network` declares an `enable` option and its option path mirrors its file location per the Namespace convention.
- [x] A Host declares its trunk interface and its set of VLAN ids through the Module's options.
- [x] Enabling the Module emits exactly one systemd-networkd bridge per declared VLAN, each named `br-vlan<id>`, and manages the Host's own management address.
- [x] A Host enabling `modules.network` builds via `nix flake check`, and the emitted bridge names are verifiable by `nix eval`. Verified by temporarily enabling it on `neogaia`; the enablement is not committed (see notes).
## Implementation Notes
Each VLAN materializes as three networkd entries: a `<trunk>.<id>` tagged sub-interface stacked on the trunk, a `br-vlan<id>` bridge, and a network enslaving the sub-interface to the bridge.
The trunk and every bridge set `RequiredForOnline = "no"`, so `systemd-networkd-wait-online` never blocks boot on a link with no carrier.
The management address takes a static CIDR, or DHCP when left null, on the management VLAN's bridge alone.
Two assertions guard it: the management VLAN must be one of the declared VLANs, and a declared management address must name a management VLAN, so an address can never be silently dropped for want of a bridge to carry it.
The Module owns its own NetworkManager `unmanaged` guard for the trunk, sub-interfaces, and bridges, so enabling it is self-sufficient on a host that also runs NetworkManager rather than pushing that wiring into every Host.
A `management.gateway` option was considered and dropped as speculative for this slice, since the foundation carries no other routing.
No host commits an enablement of this Module.
The repo's only host is `neogaia`, a wifi laptop on an access port, and enabling the Module there turns on `systemd-networkd` and pulls in `systemd-resolved`, which takes over the laptop's DNS.
That is an unwanted change to a daily machine that cannot present guests as L2 citizens anyway (wifi does not bridge), so the standing enablement waits for the first wired server host.
The build and bridge-name evaluation were verified by temporarily enabling the Module on `neogaia` (`nix flake check` passed, `nix eval` showed `br-vlan10`/`br-vlan20`), then reverting.
Both stay reproducible from the committed tree by enabling the Module ad hoc through `nixosConfigurations.neogaia.extendModules`, leaving the host file untouched.
No Guest is wired to a bridge — that is the guest networking placement slice.

View File

@@ -1,42 +0,0 @@
---
spec: guests
blocked-by: [0002-guest-walking-skeleton, 0003-network-vlan-foundation]
---
## What to build
The Host-side placement fields that make a Guest a first-class L2 citizen on a tagged VLAN, exactly as Proxmox did.
A Host sets `vlan`, and the Guest attaches to that Host's `br-vlan<id>` bridge by the naming convention, so the Guest states only which VLAN it lives on.
A Host may set `mac` to reuse an existing address so its router's DHCP reservations keep working; an unset `mac` derives a stable, readable address from the Guest's namespace path in a locally-administered range, surfaced via evaluation so the operator can add a reservation.
The MAC is pinned inside the guest through the guest's own systemd-networkd, which is the only way a nested-container MAC stays stable.
A Host may set `address` for a static IP; unset means DHCP, keeping IP management centralized at the router.
A build-time assertion ties the Guest's `vlan` to the set of VLANs its Host's `modules.network` declares, so a Guest naming an undeclared VLAN fails the Host build with a clear message rather than as a broken bridge at runtime.
## Acceptance criteria
- [x] A Host setting `guests.<path>.vlan` attaches the Guest to that Host's `br-vlan<id>` bridge by the naming convention.
- [x] Setting `mac` pins that exact address on the Guest via the guest's own systemd-networkd; leaving it unset derives a stable MAC from the Guest's namespace path in a locally-administered range, readable via `nix eval`.
- [x] Setting `address` gives the Guest a static IP on its VLAN; leaving it unset takes the address by DHCP.
- [x] A Guest whose `vlan` is not among its Host's declared VLANs fails `nix flake check` with a clear, actionable message naming the offending Guest and VLAN.
- [x] A Host with a correctly-placed networked Guest builds via `nix flake check`, and the Guest's resolved bridge attachment and derived MAC are verifiable by `nix eval`.
## Implementation Notes
The `br-vlan<id>` naming was a local helper in `modules/network.nix` and is now a shared `bridgeName` in `lib.nix`, exported through `my` and consumed by both the network foundation and guest placement.
The convention has one source, so the bridge a guest attaches to can never drift from the bridge the host emits.
The interior networking is realized by a small module injected into the guest's container config only when `vlan` is set.
It enables the guest's own systemd-networkd on `eth0` — the name a nested container gives its bridged veth — pinning the placement MAC there and taking the static `address` or DHCP when it is unset.
Pinning the MAC through the guest's own networkd is the only way a nested-container MAC stays stable; the nspawn-assigned veth MAC is otherwise regenerated.
Enabling networkd default-enables `systemd-resolved`, so a DHCP guest also gets its resolver.
The derived MAC is the `mac` option's default, so an unset MAC reads back through `nix eval .#nixosConfigurations.<host>.config.guests.<path>.mac`.
The first octet is `02` (locally-administered, unicast) and the remaining five octets are a hash slice of the namespace path.
A static `address` sets only the on-VLAN IP, with no gateway or DNS.
This mirrors `modules.network`, which deliberately dropped a `management.gateway` as speculative for the foundation slice; off-VLAN routing for a statically-addressed guest is a later concern, and the centralized path stays DHCP.
The networked path is verified by `nix eval` against `neogaia` through `extendModules` rather than by committing an enablement, exactly as task 0003 verified `modules.network`.
`neogaia` is a wifi laptop that cannot bridge, and enabling networkd on it would take over its DNS, so its committed `guests.sample` placement leaves `vlan` unset.
Verified: with `vlan = 10` the guest resolves `hostBridge = br-vlan10` and the interior `eth0` networkd pins the derived MAC; an unset `address` yields `DHCP = "yes"` and a set one yields the static CIDR; and `vlan = 99` against declared `[10 20]` fails the build with the actionable message.
`nix flake check` passes with `guests.sample` building its interior in full.

View File

@@ -1,43 +0,0 @@
---
spec: guests
blocked-by: 0002-guest-walking-skeleton
---
## What to build
The host-level pool-import foundation, `modules.zfs`, plus the shared `storage` group that makes identity-mapped pool writes work.
A Host declares its host id, the pools to import, and their dataset mountpoints; the pools are durable state that is imported, never rebuilt, so a service's data survives any rebuild or reimage.
A shared `storage` group with a fixed gid lives in the shared portion of the base config that both the host base and the guest-base include, giving 1:1 group ownership between a Host and its Guests.
This slice establishes the group and the pool import; a Guest actually writing to a mount as that group is the Guest storage placement slice.
## Acceptance criteria
- [x] `modules.zfs` declares an `enable` option and its option path mirrors its file location per the Namespace convention.
- [x] A Host declares its host id, its pools, and their dataset mountpoints; enabling the Module imports those pools rather than recreating them.
- [x] A shared `storage` group with a fixed gid is defined in the shared base and is present identically on both a Host and its Guests.
- [x] A Host enabling `modules.zfs` builds, and the `storage` gid is verifiable by `nix eval`. Verified by ad-hoc enablement on `neogaia`; the enablement is not committed (see notes).
## Implementation Notes
The pool import is realized by `boot.zfs.extraPools`, which imports the named pools rather than creating them, so a rebuild never touches pool contents.
`boot.supportedFilesystems = [ "zfs" ]` pulls the ZFS stack into the kernel and boot even on a host whose root is another filesystem, and `networking.hostId` is required because ZFS refuses to import a pool without a host id to stamp ownership onto.
Declared dataset mountpoints become plain `fileSystems` entries with `fsType = "zfs"`, orthogonal to the import: a pool with an empty map is still imported, leaving its datasets to their own ZFS `mountpoint` property.
`pools` is typed `attrsOf (attrsOf path)` — pool name to a dataset-relative-path to mountpoint map — rather than a per-pool submodule.
No per-pool option beyond the mount map is foreseen at host level, so the extra submodule layer would have been speculative.
The Module is `modules.zfs`, a flat single-file module, not `modules.storage.zfs` under a `storage/` directory.
Nothing else lives under a storage namespace, and the sibling host-level foundation `modules.network` is likewise flat, so the extra directory level would have grouped a single member.
The spec and ADR 0006 were updated to name it `modules.zfs` to match.
The `storage` group carries a fixed gid of 10000, placed in `base.nix` so a host and every guest built from this flake carry the identical number.
That identity is the whole write mechanism: an identity-mapped container write lands on the pool as the same numeric group with no per-service permission juggling.
10000 sits above the ids NixOS assigns automatically, so no generated account collides with it.
This slice only defines the group and the import; a Guest actually writing to a mount as this group is the Guest storage placement slice.
No host commits an enablement of this Module.
The repo's only host is `neogaia`, a laptop with a btrfs root, no ZFS pools, and a bleeding-edge CachyOS kernel whose `zfs-kernel-2.4.3` build is marked broken — so a committed ZFS enablement there would be both dishonest and unbuildable.
This mirrors the networking foundation's decision (task 0003) to verify by temporary enablement rather than commit one to a host that cannot honestly carry the feature.
The enabled build was verified through `nixosConfigurations.neogaia.extendModules`, enabling the Module against a declared pool and forcing `boot.kernelPackages = pkgs.linuxPackages` (a ZFS-supported kernel) so the incompatibility of *neogaia's* kernel choice does not mask the Module's own correctness; the full `nixos-system-neogaia` toplevel built.
The `storage` gid was verified identical (2000) on both the host config and the sample guest's interior, and `nix flake check` passes on the committed, module-inert tree.
The standing enablement waits for the first real storage host, supplied when that host is added.

View File

@@ -1,35 +0,0 @@
---
spec: guests
blocked-by: [0002-guest-walking-skeleton, 0005-storage-zfs-foundation]
---
## What to build
The Host-side placement that lets a Guest mount shared pool paths at any granularity and write to them without permission errors.
A Host sets `mounts`, a map of guest path to host path, each with a per-mount `readOnly`, defaulting to read-write to match the migration reality that services must write to pools.
Each entry is realized as a bind mount of the nested container, so a Guest sees exactly the data it should — a single folder or a whole pool, read-only or read-write.
Because the container backend uses identity mapping, a guest service that writes as the shared `storage` group lands on the pool as that same group, which is the entire "no permission errors" mechanism and the privileged-container equivalent the operator already trusts from Proxmox.
## Acceptance criteria
- [x] A Host setting `guests.<path>.mounts` bind-mounts each host path at its guest path inside the nested container.
- [x] Each mount honours its per-mount `readOnly`, defaulting to read-write.
- [x] Identity mapping is configured so a guest service writing as the `storage` group lands on the host pool as that same group with no permission error.
- [x] A Host with a Guest that has pool mounts builds, and the resolved bind mounts are verifiable by `nix eval`. Verified by ad-hoc mounts on the sample guest; no host commits mounts (see notes).
## Implementation Notes
`mounts` is a placement option on the `guest` builder: an attribute set keyed by the guest-interior path, each value carrying `hostPath` and a `readOnly` flag that defaults to `false`.
It is realized as the nested container's `bindMounts`, where the container option's `mountPoint` defaults to the attribute key, so the guest path is stated once as the key.
The upstream `bindMounts` `isReadOnly` defaults to read-only, so it is driven explicitly from `readOnly` to make read-write the default here, matching the migration reality that services must write to their pools.
Identity mapping is pinned with `privateUsers = "no"`, which runs the container in the host's uid and gid space one to one.
That is the current NixOS default, but the option documents `"pick"` (a shifting map that would break pool writes) as its recommended value, so the property is pinned rather than left to a default that may drift.
With this mapping and the shared `storage` group from the storage foundation (identical gid on host and guest), a guest process writing as that group lands on a bind-mounted pool as the same group.
The write itself is verified manually on the target host per the spec's testing decisions, since a real identity-mapped ZFS write cannot be reproduced in the build.
No host commits pool mounts.
The repo's only host, `neogaia`, is a laptop with no ZFS pools, so a committed mount would point at a host path that does not exist and would fail the bind at container start.
This mirrors the storage and networking foundations (tasks 0005 and 0003), which verify by ad-hoc enablement rather than committing a placement a host cannot honestly carry.
The realization was verified through `nixosConfigurations.neogaia.extendModules`, setting two mounts on the sample guest: the resolved `containers.sample.bindMounts` carried the right `hostPath`, `mountPoint`, and per-mount `isReadOnly` (read-write and read-only), `privateUsers` resolved to `"no"`, the `storage` gid was identical on host and guest interior, and the full toplevel built.
`nix flake check` passes on the committed tree, where the sample guest declares no mounts.

View File

@@ -1,40 +0,0 @@
---
spec: guests
blocked-by: 0002-guest-walking-skeleton
---
## What to build
The Host-side placement that gives a Guest only the decrypted secrets it names, while no Guest ever holds a decryption key.
A Host sets `secrets`, the names of the secret files the Guest needs.
The Host is the sole decryptor, consistent with the host identity and the existing sops-nix setup: it decrypts, and the Guest receives only the specific named secret files, read-only bind-mounted in, with ownership aligned by the identity mapping.
A Guest holds no age key.
## Acceptance criteria
- [x] A Host setting `guests.<path>.secrets` gives the Guest exactly the named secret files and no others.
- [x] The named secrets are decrypted by the Host and bind-mounted into the Guest read-only, with ownership aligned by the identity mapping.
- [x] The Guest holds no age key and performs no decryption of its own.
- [x] A Host with a Guest that names secrets builds via `nix flake check`, and the resolved secret mounts are verifiable by `nix eval`.
## Implementation Notes
`secrets` is a placement option on the `guest` builder: a list of secret names.
For each name the builder declares `sops.secrets.<name>` on the host, so the host is the sole decryptor from the sops files it already holds, and bind-mounts the decrypted file read-only into the guest at the same `/run/secrets/<name>` path it occupies on the host.
A service inside the guest therefore reads its credentials at the location it would on a host, keeping a module placement-agnostic.
The guest holds no age key and declares no secrets of its own.
This is inherited, not added: `guest.nix` stands on `base.nix`, not on the host base `system.nix` that sets `sops.age.keyFile`.
Verified through `nixosConfigurations.neogaia.extendModules`: with `guests.sample.secrets = [ "alexion-password" ]`, the interior's `sops.age.keyFile` is null and its `sops.secrets` is empty, while the host declares the secret and the container's `bindMounts` carries exactly the one entry, read-only, keyed and sourced at the secret's path.
Ownership alignment needs no new code.
The container already runs with `privateUsers = "no"` (task 0006), so the host and guest share one uid and gid space, and the decrypted file's host owner is its owner inside the guest.
A secret defaults to `root:root` mode `0400`, so a guest service running as a non-root user needs the operator to set `sops.secrets.<name>.owner` on the host, which merges cleanly with the builder's stub declaration.
The `secrets` field stays a list of names per the spec, which treats the owner as host-set data.
A build-time assertion rejects an in-guest path claimed by both a `mounts` entry and a secret, since the two attribute sets merge and the collision would otherwise resolve silently in the secret's favour.
No host commits a `secrets` placement, mirroring the mounts, networking, and storage foundations (tasks 00030006): the only host, `neogaia`, is a laptop carrying no service that names one.
`nix flake check` passes on the committed tree, where the sample guest declares no secrets.
The full host toplevel was built with the sample guest naming `alexion-password` (a real key in `secrets/shared.yaml`); sops-nix validates at build time that each named key exists in the host's sops files, so a build with a name absent from those files fails clearly rather than at activation.
Real identity-mapped reads on the target host are verified manually, per the spec's testing decisions.

View File

@@ -1,29 +0,0 @@
---
spec: guests
blocked-by: 0002-guest-walking-skeleton
---
## What to build
The Host-side placement that caps a Guest's resources so one misbehaving service cannot starve its Host, plus the boot-start toggle.
A Host sets `limits``memory`, `cpu`, and `tasksMax` — applied to the guest's unit, uncapped by default.
A Host sets `autoStart` to control whether the Guest starts at boot, on by default.
## Acceptance criteria
- [x] A Host setting `guests.<path>.limits.memory`, `.cpu`, or `.tasksMax` applies the corresponding cap to the guest's unit.
- [x] Each limit is uncapped when unset.
- [x] `autoStart` starts the Guest at boot by default, and disabling it leaves the Guest defined but not started at boot.
- [x] A Host with a capped Guest builds via `nix flake check`, and the resolved unit caps are verifiable by `nix eval`.
## Implementation Notes
- The caps map to the guest's own systemd unit, `container@<name>.service`, which the container backend generates.
The guest module contributes `serviceConfig.MemoryMax`, `.CPUQuota`, and `.TasksMax`, and the module system merges these with the backend's own `serviceConfig` for that unit.
Only set caps appear: a `filterAttrs` drops any limit left null, so an unset limit contributes no key and systemd keeps its uncapped default rather than the module writing an explicit "infinity".
- `limits.memory` and `limits.cpu` are strings passed through to systemd verbatim (`2G`, `150%`), since systemd already parses size and percentage forms and re-inventing the parsing here would only narrow what the operator can express.
`limits.tasksMax` is a positive int, matching `TasksMax`'s count.
- `autoStart` is now a placement option defaulting true, and the container's `autoStart` reads from it directly rather than the previous `mkDefault true`.
The backend gates `wantedBy = [ "machines.target" ]` on `autoStart`, so `false` leaves the `container@<name>.service` unit fully defined but out of `machines.target` — startable on demand, not at boot — verified by evaluation.
- `neogaia`'s skeleton guest carries modest demonstrative caps (`memory = "1G"`, `cpu = "100%"`, `tasksMax = 512`), following task 0002's precedent of exercising the guest path on this host through its own `nix flake check`.
Its `autoStart` is left at the default so 0002's live boot smoke test is unaffected.

View File

@@ -1,37 +0,0 @@
---
spec: guests
blocked-by: 0002-guest-walking-skeleton
---
## What to build
The Host-side placement that makes the OCI fallback a plain Guest, so image-only software has a declarative home without a separate mechanism.
A Host sets `nesting` to grant the nested-container prerequisites so the Guest's interior can run Podman and other OCI containers.
The Skeleton emits the nested-container cgroup-delegation and capability prerequisites once, so the operator flips one boolean and the interior's `oci-containers` runtime works, with Podman as the default runtime.
## Acceptance criteria
- [x] A Host setting `guests.<path>.nesting` grants the nested-container cgroup-delegation and capability prerequisites, off by default.
- [x] With `nesting` on, the Guest's interior can define `oci-containers` running Podman as the default runtime.
- [x] With `nesting` off, those prerequisites are absent and the Guest is unaffected.
- [x] A Host with a nesting Guest that defines an OCI container builds via `nix flake check`.
## Implementation Notes
- `nesting` is a Host-side placement field on the guest, a bool defaulting off, alongside the other placement fields in `lib.nix`.
On, it grants the guest's container `CAP_NET_ADMIN` and the `/dev/net/tun` and `/dev/fuse` device nodes, the capability and devices an OCI runtime reaches for to network its containers and back their overlay storage.
Off, both `additionalCapabilities` and `allowedDevices` are empty, matching the NixOS defaults, so a non-nesting guest is untouched.
- The capability prerequisite is `CAP_NET_ADMIN` alone.
A container-backend guest runs privileged (`privateUsers = "no"`), so it already retains the broad nspawn capability set including `CAP_SYS_ADMIN`; the one addition an OCI runtime needs is network administration for its bridges and firewall rules.
- cgroup delegation is not toggled by `nesting`, a deliberate deviation from the criterion's wording that the flag "grants" it and that it is "absent" when off.
The NixOS container backend sets `Delegate = true` on every container's unit unconditionally, so the delegated cgroup subtree an OCI runtime manages is always present.
Re-emitting it under `nesting` would be redundant, and forcing it off for non-nesting guests to make it literally "absent" would remove a harmless, useful default for no gain.
The Skeleton records the prerequisite as satisfied-elsewhere with an absence pointer comment, so a reader does not think delegation was forgotten.
- A new `guests/nesting-sample.nix` carries an interior that defines an `oci-containers` workload, the payload the criteria exercise, and `neogaia` enables it with `nesting = true`.
This follows the walking-skeleton's precedent of proving a guest path through the one Host's `nix flake check`: the flake check builds the nested `nixos-system-nesting-sample` in full, pulling in `podman` and the generated `podman-hello.service` unit, which is how criteria two and four are verified on the build seam.
The sample carries the same modest caps as the walking-skeleton guest so an interior container cannot starve the laptop.
- The two behaviors the build seam cannot prove — that the interior Podman actually starts a container and that its networking works — are left to manual verification on the target Host and to the VM integration test of task 0010, per the spec's testing decisions.

View File

@@ -1,56 +0,0 @@
---
spec: guests
blocked-by: [0004-guest-networking-placement, 0006-guest-storage-placement]
---
## What to build
One NixOS VM integration test, added to the flake's `checks` so `nix flake check` runs it, exercising the external observable behavior of a Guest and its foundations rather than the internal shape of the generated nested-container config.
A single harness boots the `modules.network` foundation and one sample Guest and asserts the three hard requirements together: the Guest presents its own MAC, gets its own IP on a tagged VLAN across a virtual L2 segment, and can write to a bind-mounted directory owned by the shared `storage` group.
The upstream NixOS test suite's nested-container networking cases (macvlan, extra-veth) are the model.
The two behaviors a VM cannot honestly reproduce — real 802.1Q against the physical switch and real ZFS identity-mapped writes on the pool — are out of this test and verified manually on the target Host.
## Acceptance criteria
- [-] A NixOS VM test is added to the flake's `checks` and runs as part of `nix flake check`.
- [-] The test boots `modules.network` and one sample Guest on a virtual L2 segment.
- [-] The test asserts the Guest presents its own MAC distinct from the Host's.
- [-] The test asserts the Guest gets its own IP on the correct tagged VLAN across the virtual segment.
- [-] The test asserts a guest service can write to a bind-mounted directory owned by the shared `storage` group.
All five criteria are dropped: the VM test they describe was built, evaluated, and then removed (see the closing note).
## Implementation Notes
The test lives in `tests/guest-integration.nix` and is merged into `checks.x86_64-linux` as `guest-integration`, built through `pkgs.testers.runNixOSTest`.
The flake builds its nodes with the same `my`/`inputs` special arguments every configuration gets, passed through `node.specialArgs`, so the host node imports the real `modules/network.nix` and the real `my.guest` builder rather than a hand-rolled stand-in.
The harness is two nodes on one test-framework segment, the "virtual L2 segment".
The host runs `modules.network` with `trunk = "eth1"` and `vlans = [10]`, and one guest placed on VLAN 10.
A second `router` node speaks VLAN 10 only on a tagged `eth1.10` sub-interface and serves DHCP there, so the guest getting a `10.0.10.x` lease and the reverse `router → guest` ping succeed only when 802.1Q tagging works end to end across the segment.
This exercises the tagged path honestly rather than plain co-segment reachability.
The MAC assertion checks the guest's `eth0` equals its derived placement MAC and differs from the host trunk, and the storage assertion relies on the identity map (`privateUsers = "no"`) carrying gid 10000 through unshifted, so `stat -c %G` reading `storage` on the host is the "no permission errors" mechanism under test.
Booting a networked guest surfaced a latent defect in the guest networking foundation: enabling the guest's own networkd default-enables `systemd-resolved`, which conflicts with the nested-container default of inheriting the host's `resolv.conf`, and the guest's toplevel failed to build with "Using host resolv.conf is not supported with systemd-resolved".
Task 0004 never hit this because it only evaluated derived values, never built a networked guest's toplevel, and no committed host places a networked guest.
The fix is one line in `guestNet` (`networking.useHostResolvConf = false`), so any networked guest keeps its own resolver.
The guest interior here carries a storage-writing service as test scaffolding, since a real guest seals its own interior and the sample guest carries no service.
The host node also orders `container@sample` after the bridge's device unit, since the container enslaves its veth to the bridge at start and the upstream containers module orders only after `network.target`, not after the specific `hostBridge`.
Following the pattern of tasks 0003, 0004, and 0006, no committed host places a networked or pool-mounted guest, since the repo's only host is a wifi laptop with no bridge or pool.
The behaviors a VM cannot honestly reproduce — real 802.1Q against the physical switch and real ZFS identity-mapped writes on the pool — stay out of this test and are verified manually on the target Host, as the spec's testing decisions direct.
### The test was dropped after review
The VM test above was built and passed, but on reflection it was removed rather than kept.
Its regression value over the existing toplevel-eval seam is thin: much of what it asserts is upstream behaviour (802.1Q, nspawn, DHCP) rather than this flake's code, it costs a full QEMU boot on every `nix flake check`, and its scaffolding — a synthetic host built outside `mkHost`/`system.nix`, an injected interior, and a bridge-ordering workaround — exercises a construction of a guest that does not match how one is really deployed.
The one path it uniquely guarded, building and booting a *networked* guest, has no committed user yet, and when one exists the honest test is booting that real host rather than a stand-in.
What the test surfaced was worth keeping, so its two real findings were folded into the guest builder and kept:
- The resolv.conf fix in `guestNet` (`networking.useHostResolvConf = false`), so a networked guest keeps its own resolver.
- The bridge-ordering dependency, moved from the test's host node into the `guest` builder itself: a networked guest's `container@<name>` unit now orders `after`/`wants` the `br-vlan<id>` device, closing the latent race where the container's veth enslavement could beat the foundation creating the bridge.
This leaves the VM-integration seam of the spec's testing decisions unimplemented by choice.
Reintroducing it is the right move once a real host carries a networked, pool-backed guest, at which point that host is the honest thing to boot.