From 7edc1ce94b517e099b1682f8e6ada1258c0f5d8b Mon Sep 17 00:00:00 2001 From: alexion Date: Sat, 25 Jul 2026 17:08:11 -0400 Subject: [PATCH] feat(zfs): import ZFS pools and add the shared write group Add `modules.zfs`, the host-level pool import: a host declares its ZFS host id and the pools to import with their dataset mountpoints, and the module imports those pools as durable state rather than recreating them, so a service's data survives any rebuild or reimage. Add a shared `storage` group with a fixed gid to the base config both the host base and the guest-base build on, so a host and every guest carry the same number and an identity-mapped container write lands on the pool as that group without per-service permission juggling. No host enables the module: the only host is a laptop with no pools and a kernel with no ZFS build, so the enabled build was verified by ad-hoc enablement against a ZFS-supported kernel while the committed tree stays inert. --- .claude/adr/0006-guest-as-third-concept.md | 2 +- .claude/spec/guests.md | 2 +- .claude/tasks/0005-storage-zfs-foundation.md | 43 ++++++++++++ base.nix | 6 ++ modules/zfs.nix | 72 ++++++++++++++++++++ 5 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 .claude/tasks/0005-storage-zfs-foundation.md create mode 100644 modules/zfs.nix diff --git a/.claude/adr/0006-guest-as-third-concept.md b/.claude/adr/0006-guest-as-third-concept.md index d1cbf64..74a0d64 100644 --- a/.claude/adr/0006-guest-as-third-concept.md +++ b/.claude/adr/0006-guest-as-third-concept.md @@ -30,6 +30,6 @@ Making the Guest a peer of Host and Module — same Auto-loader, same convention - The Skeleton grows a Guest realization: the Auto-loader discovers `guests/`, a Guest's interior compiles into a nested container, and placement fields wire VLAN attachment, MAC pinning, bind mounts, secret mounts, unit caps, and the nesting prerequisites. - The shared base config splits into a host base (`system.nix`) and a slim guest-base; both include the primary user, home-manager, and the shared overlays, and the guest-base auto-enables the `toolkit` bundle and `modules.ssh`. -- Guests attach to host-level foundations declared once per Host: `modules.network` for the trunk and per-VLAN bridges, and `modules.storage.zfs` with a shared fixed-gid `storage` group for identity-mapped pool writes. +- Guests attach to host-level foundations declared once per Host: `modules.network` for the trunk and per-VLAN bridges, and `modules.zfs` with a shared fixed-gid `storage` group for identity-mapped pool writes. - OCI software has a declarative home without a new mechanism: a Guest with `nesting` runs Podman in its interior. - The `microvm` backend, multi-instance Guests, and the concrete homelab Host with its real trunk, VLAN, pool, and MAC values remain future work. diff --git a/.claude/spec/guests.md b/.claude/spec/guests.md index b347470..4d14356 100644 --- a/.claude/spec/guests.md +++ b/.claude/spec/guests.md @@ -94,7 +94,7 @@ The guest-base auto-enables it; a Host enables it explicitly, keeping the Host a This is a bundle wanted as a unit, distinct from the grouping-directory enables ADR 0004 rejected. - **`modules.network`** (new): the host-level networking foundation. A Host declares its trunk interface and the set of VLANs to materialize, and the Module emits one bridge per tagged VLAN with systemd-networkd and manages the Host's own management address. -- **`modules.storage.zfs`** (new): the host-level pool import. +- **`modules.zfs`** (new): the host-level pool import. A Host declares its host id, the pools to import, and their dataset mountpoints; the pools are durable state that is imported, never rebuilt. - **A shared `storage` group** with a fixed gid in the shared base gives 1:1 ownership between Host and Guest. Because the container backend uses identity mapping, a guest service that writes as the `storage` group lands on the pool as that same group, which is the entire "no permission errors" mechanism. diff --git a/.claude/tasks/0005-storage-zfs-foundation.md b/.claude/tasks/0005-storage-zfs-foundation.md new file mode 100644 index 0000000..d111c47 --- /dev/null +++ b/.claude/tasks/0005-storage-zfs-foundation.md @@ -0,0 +1,43 @@ +--- +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. diff --git a/base.nix b/base.nix index 6c02160..c6f0993 100644 --- a/base.nix +++ b/base.nix @@ -62,6 +62,12 @@ in extraGroups = [ "wheel" ]; }; + # The shared write group. + # Its gid is fixed, so a host and every guest carry the same number. + # An identity-mapped container write then lands on the pool as this group, sparing every service the permission juggling. + # 10000 clears the system-group ids assigned automatically and leaves headroom above the primary user, so nothing else claims it. + users.groups.storage.gid = 10000; + # home-manager as a NixOS module: one build produces the system and user # environment together, sharing the system's pkgs and installing user # packages into the system profile. diff --git a/modules/zfs.nix b/modules/zfs.nix new file mode 100644 index 0000000..728641a --- /dev/null +++ b/modules/zfs.nix @@ -0,0 +1,72 @@ +{ + config, + lib, + ... +}: +# The host-level ZFS pool import: durable service state a host mounts, never rebuilds. +let + cfg = config.modules.zfs; +in +{ + options.modules.zfs = { + enable = lib.mkEnableOption "importing durable ZFS pools that hold service state"; + + hostId = lib.mkOption { + type = lib.types.strMatching "[0-9a-f]{8}"; + example = "deadbeef"; + description = '' + This host's 8-hex-digit ZFS host id, written to `networking.hostId`. ZFS + stamps an imported pool with the importing host's id, so a pool still + held by another machine is refused rather than silently dual-mounted. It + must be fixed for the machine and distinct across machines that can reach + the same pool. + ''; + }; + + pools = lib.mkOption { + type = lib.types.attrsOf (lib.types.attrsOf lib.types.path); + default = { }; + example = lib.literalExpression '' + { + tank = { + media = "/srv/media"; + downloads = "/srv/downloads"; + }; + } + ''; + description = '' + The ZFS pools to import at boot, keyed by pool name, each pool mapping a + dataset path relative to it to that dataset's mountpoint. A pool is + durable state imported as it stands, never created or destroyed by a + rebuild, so a service's data survives any rebuild or reimage. A pool + with an empty map is still imported, leaving each dataset to its own ZFS + `mountpoint` property. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + # The ZFS stack in the kernel and boot, needed even where the root filesystem is another kind. + boot.supportedFilesystems = [ "zfs" ]; + + # ZFS refuses to import a pool without a host id to stamp its ownership onto. + networking.hostId = cfg.hostId; + + # The declared pools are imported at boot, distinct from any pool backing the root filesystem. + boot.zfs.extraPools = lib.attrNames cfg.pools; + + # Each declared dataset is mounted at its host path as a native ZFS filesystem. + fileSystems = lib.mkMerge ( + lib.mapAttrsToList ( + pool: mounts: + lib.mapAttrs' ( + dataset: mountpoint: + lib.nameValuePair mountpoint { + device = "${pool}/${dataset}"; + fsType = "zfs"; + } + ) mounts + ) cfg.pools + ); + }; +} -- 2.47.3