{ vars, lib, pkgs, ... }: let poolName = lib.removePrefix "/" vars.storageRoot; # For each NFS share subpath, generate every ancestor path so ZFS datasets # are created parent-first. e.g. "docker/config" → ["docker" "docker/config"] ancestors = path: let parts = lib.splitString "/" path; in lib.imap1 (i: _: lib.concatStringsSep "/" (lib.take i parts)) parts; poolDatasets = lib.unique ( lib.concatMap (share: ancestors share.subpath) (lib.filter builtins.isAttrs (lib.attrValues vars.nfsShares)) ); # Generates /etc/exports lines for all nfsShares data entries (every # attrset value — excludes the bare `options` string). Both server and # ha-server export the same share set from different storage roots, so # this helper is the single source of truth for the export line format. mkNfsExports = storageRoot: lib.concatMapStrings (share: " ${storageRoot}/${share.subpath} ${vars.lanCidr}${vars.nfsShares.options}\n") (lib.filter builtins.isAttrs (lib.attrValues vars.nfsShares)); in { imports = [ ../beszel/enable-agent.nix ../services/zfs/enable-service.nix ]; boot.zfs.extraPools = [ poolName ]; # On a fresh image deploy the data disk (scsi1) starts blank — no pool # exists yet, so zfs-import-tank.service would spin for 60 s and fail. # This service runs first: if the pool is already present it exits instantly; # otherwise it creates it (with all required datasets) so the standard # import service finds it ready on the very first boot. systemd.services."zfs-init-${poolName}" = { description = "Initialize '${poolName}' ZFS pool on first boot if not present"; wantedBy = [ "zfs-import-${poolName}.service" ]; before = [ "zfs-import-${poolName}.service" ]; after = [ "systemd-udev-settle.service" ]; unitConfig.DefaultDependencies = false; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; path = [ pkgs.zfs_unstable ]; script = '' # Already imported — nothing to do. if zpool list "${poolName}" >/dev/null 2>&1; then exit 0 fi # Locate the data disk first — used for both the fallback import # attempt and, only if the disk is genuinely blank, pool creation. DATA_DISK="" for candidate in /dev/disk/by-id/scsi-*drive-scsi1; do [[ "$candidate" == *-part* ]] && continue [ -b "$candidate" ] && DATA_DISK="$candidate" && break done if [ -z "$DATA_DISK" ]; then echo "zfs-init-${poolName}: no data disk found (expected /dev/disk/by-id/scsi-*drive-scsi1)" >&2 exit 1 fi # Try importing via the by-id symlink directory first (normal path), # then fall back to scanning the disk directly. The two-step exists # because of a udev race: systemd-udev-settle.service can clear before # /dev/disk/by-id/ entries are fully populated, causing the first # import to fail even when the pool is intact on the disk. if zpool import -d /dev/disk/by-id -N "${poolName}" 2>/dev/null; then exit 0 fi if zpool import -d "$DATA_DISK" -N "${poolName}" 2>/dev/null; then exit 0 fi # Both import attempts failed. Before creating a new pool, verify the # disk is genuinely blank — if ZFS label metadata is present the import # failed for some other reason and we must not clobber existing data. if zdb -l "$DATA_DISK" 2>/dev/null | grep -q "name: '${poolName}'"; then echo "zfs-init-${poolName}: $DATA_DISK has ZFS pool '${poolName}' metadata but import failed — refusing to overwrite existing data. Run 'zpool import -d $DATA_DISK ${poolName}' manually to investigate." >&2 exit 1 fi # Disk is genuinely blank: create the pool. -f is intentionally # omitted so that if we somehow reach this point with an existing pool # on the disk, zpool refuses rather than silently destroying data. echo "zfs-init-${poolName}: creating pool on $DATA_DISK" zpool create "${poolName}" "$DATA_DISK" ${lib.concatMapStrings (ds: '' zfs create "${poolName}/${ds}" '') poolDatasets} ''; }; systemd.services.nfs-server = { after = [ "zfs-mount.service" ]; requires = [ "zfs-mount.service" ]; }; services.nfs.server = { enable = true; exports = mkNfsExports vars.storageRoot; }; # mountd (20048) is needed for showmount/NFSv3 mount protocol — without it # clients can reach portmapper (111) and get the mountd port back, then # time out trying to connect to it. All three ports need TCP and UDP. networking.firewall.allowedTCPPorts = [ vars.ports.nfsRpcbind vars.ports.nfsd vars.ports.nfsMountd ]; networking.firewall.allowedUDPPorts = [ vars.ports.nfsRpcbind vars.ports.nfsd vars.ports.nfsMountd ]; }