feat(storage): import ZFS pools and add the shared write group
Add `modules.storage.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.
This commit is contained in:
39
.claude/tasks/0005-storage-zfs-foundation.md
Normal file
39
.claude/tasks/0005-storage-zfs-foundation.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
spec: guests
|
||||||
|
blocked-by: 0002-guest-walking-skeleton
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
The host-level pool-import foundation, `modules.storage.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.storage.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.storage.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 `storage` group carries a fixed gid of 2000, 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.
|
||||||
|
2000 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.
|
||||||
6
base.nix
6
base.nix
@@ -62,6 +62,12 @@ in
|
|||||||
extraGroups = [ "wheel" ];
|
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.
|
||||||
|
# 2000 clears the system-group ids assigned automatically and leaves headroom above the primary user, so nothing else claims it.
|
||||||
|
users.groups.storage.gid = 2000;
|
||||||
|
|
||||||
# home-manager as a NixOS module: one build produces the system and user
|
# home-manager as a NixOS module: one build produces the system and user
|
||||||
# environment together, sharing the system's pkgs and installing user
|
# environment together, sharing the system's pkgs and installing user
|
||||||
# packages into the system profile.
|
# packages into the system profile.
|
||||||
|
|||||||
72
modules/storage/zfs.nix
Normal file
72
modules/storage/zfs.nix
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
# The host-level ZFS pool import: durable service state a host mounts, never rebuilds.
|
||||||
|
let
|
||||||
|
cfg = config.modules.storage.zfs;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.modules.storage.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
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user