diff --git a/.claude/tasks/0006-guest-storage-placement.md b/.claude/tasks/0006-guest-storage-placement.md new file mode 100644 index 0000000..21214ad --- /dev/null +++ b/.claude/tasks/0006-guest-storage-placement.md @@ -0,0 +1,35 @@ +--- +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..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. diff --git a/lib.nix b/lib.nix index 9140007..fd8160a 100644 --- a/lib.nix +++ b/lib.nix @@ -160,6 +160,43 @@ let guest takes its address by DHCP, keeping IP management at the router. ''; }; + mounts = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.submodule { + options = { + hostPath = lib.mkOption { + type = lib.types.str; + example = "/srv/media"; + description = "The path on the host bind-mounted into the guest."; + }; + readOnly = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Mount the path read-only. Read-write by default, since a + service must write to the pool data it owns. + ''; + }; + }; + } + ); + default = { }; + example = lib.literalExpression '' + { + "/data/media" = { hostPath = "/srv/media"; }; + "/data/config" = { + hostPath = "/srv/config/jellyfin"; + readOnly = true; + }; + } + ''; + description = '' + Host paths bind-mounted into the guest, keyed by the path they appear + at inside the guest, so a guest sees exactly the data it should at any + granularity — a single folder or a whole pool. Each mount is + read-write unless `readOnly` is set. + ''; + }; }; config = lib.mkIf cfg.enable { @@ -189,6 +226,16 @@ let # a first-class L2 citizen on that segment. hostBridge = lib.mkIf networked (bridgeName cfg.vlan); + # The container shares the host's uid and gid space one to one. + # A guest process writing as the shared storage group then lands on a bind-mounted pool as that same group, with no permission juggling. + # A private-user mapping would shift the ids and reintroduce those errors, so it stays off. + privateUsers = lib.mkDefault "no"; + + bindMounts = lib.mapAttrs (_guestPath: m: { + inherit (m) hostPath; + isReadOnly = m.readOnly; + }) cfg.mounts; + inherit specialArgs; config = {