{...}: { # Every filesystem on shadow lives here, hand-maintained. # # hardware.nix is generated with `--no-filesystems` precisely so it never # emits fileSystems/swapDevices entries of its own. That keeps regeneration # (`nix run .#sync-hardware`) safe: re-running it can never clobber these # definitions, nor produce duplicates for whichever data drives happened to # be mounted at the time it ran. flake.nixosModules.shadowDisks = {pkgs, ...}: let # Secondary data drives, addressed by filesystem label. # # nofail on all of them: it stops a missing or dying disk from becoming a # failed boot dependency that drops the machine into emergency mode. # # On-disk ownership is already ahmza mode 755 on all three, so no uid mapping # is needed — ext4 stores ownership in the filesystem and ignores uid=/gid=. # Spinning disks (sda/sdc, both WD, rotational=1). noauto + # x-systemd.automount makes systemd generate an .automount unit and mount on # first access to the path rather than at boot, so they stay parked until # something actually touches them; idle-timeout unmounts them again once # quiet so they can spin back down. # # Note the idle unmount fails with "target is busy" while any shell has its # cwd inside the mount. That is harmless — it simply stays mounted and retries. spinningDrive = label: { device = "/dev/disk/by-label/${label}"; fsType = "ext4"; options = [ "nofail" "noauto" "x-systemd.automount" "x-systemd.idle-timeout=600" "x-systemd.device-timeout=10" ]; }; # Always-on drive. Used for the games library: it is an SSD (Samsung 870 QVO, # rotational=0) so there is no spin-down to gain, and a Steam library is # better off never sitting behind an automount trigger. # # Unrelated but worth knowing, since it looks like a mount problem: Steam's # FHS sandbox builds its bind list at launch by iterating /*, so a top-level # directory that does not exist when Steam starts is invisible to that # instance for its whole lifetime, and Steam reports it as "not writable". # /mnt only comes into existence when these entries are activated, so Steam # must be restarted after a fresh boot-time change here. eagerDrive = label: { device = "/dev/disk/by-label/${label}"; fsType = "ext4"; options = [ "nofail" "x-systemd.device-timeout=10" ]; }; # Pin the NFS server until nas.hfx.ahmza.com points at 10.10.100.4. # Automounting keeps an unavailable NAS out of the boot path. nfsShare = export: { device = "10.10.100.4:${export}"; fsType = "nfs"; options = [ "nfsvers=4.2" "noauto" "nofail" "_netdev" "x-systemd.automount" "x-systemd.idle-timeout=600" "x-systemd.mount-timeout=10" ]; }; in { fileSystems = { # Boot essentials, by UUID. UUIDs rather than labels here on purpose: # these two must resolve unambiguously, and a label is only as unique as # the last disk someone plugged in. "/" = { device = "/dev/disk/by-uuid/deff63fc-1738-4cfc-a79c-06e58c78471a"; fsType = "ext4"; }; "/boot" = { device = "/dev/disk/by-uuid/5873-AD05"; fsType = "vfat"; options = ["fmask=0077" "dmask=0077"]; }; "/mnt/dump" = spinningDrive "DUMP"; "/mnt/bak" = spinningDrive "BAK"; "/mnt/games" = eagerDrive "GAMES"; "/mnt/nas" = nfsShare "/srv/nas"; "/mnt/media" = nfsShare "/srv/media"; }; # Swap exists for hibernation, not for memory pressure — 31GiB of RAM on a # desktop rarely needs paging. The hibernation image must fit in swap, so the # floor is "as big as RAM"; 40GiB leaves headroom above that. # # NVIDIA video memory does NOT land here: with # NVreg_PreserveVideoMemoryAllocations=1 (set by # hardware.nvidia.powerManagement.enable) the driver spools the 3090's VRAM # to NVreg_TemporaryFilePath, which is unset and so defaults to /tmp. That is # only safe because /tmp is on this ext4 root — if /tmp ever becomes a tmpfs # (boot.tmp.useTmpfs) that VRAM dump goes into RAM and hibernation breaks. # # On non-btrfs filesystems NixOS creates this with `dd if=/dev/zero`, so the # file is fully allocated rather than sparse. That matters because the kernel # writes swap through the block layer using the extent map swapon built, # bypassing the filesystem — so the mapping has to be stable and complete. # # The failure is loud, not silent, though: swapon refuses a file with holes # outright ("skipping - it appears to have holes"), so a sparse file would # simply leave the system with no swap rather than corrupt anything. Genuine # silent corruption is the copy-on-write case, which is why btrfs needs # chattr +C and a NOCOW-from-creation file. swapDevices = [ { device = "/var/lib/swapfile"; size = 40 * 1024; # MiB } ]; # Resuming from hibernation needs the kernel to be told where the image is, # and for a swap *file* that means both halves below. # # boot.initrd.systemd.enable is true on this host, which makes this # mandatory rather than optional: under systemd initrd the `resume=` kernel # param is emitted ONLY from boot.resumeDevice. The legacy stage-1 script # that scans swapDevices to find a resume device is not in play. Without # this, hibernate succeeds and the machine then cold-boots, silently # discarding the session. # # resume_offset is the physical block offset of the swapfile's first extent, # because the kernel addresses the file by raw block, not by path. Recompute # it with the following if the swapfile is ever recreated, resized, or moved # — a stale value resumes from the wrong blocks: # # sudo filefrag -v /var/lib/swapfile | awk '$1=="0:" {gsub(/\.\./,"",$4); print $4}' # # filefrag reports in 4096-byte filesystem blocks and the kernel wants # PAGE_SIZE units; on x86_64 both are 4096, so the number transfers directly. boot.resumeDevice = "/dev/disk/by-uuid/deff63fc-1738-4cfc-a79c-06e58c78471a"; boot.kernelParams = ["resume_offset=499355648"]; # A stale resume_offset fails *silently*: the kernel finds no swsusp # signature at the given block, logs `PM: Image not found (code -22)`, and # cold-boots — the hibernated session is simply gone, with no error at # hibernate time. Since the value above is hand-copied, check it on every # activation so drift surfaces as a warning instead of as a lost session. # # Two known limits, both acceptable: # * nixpkgs only recreates the swapfile when `size` changes, so on the one # rebuild that resizes it this check still sees the old file and passes; # it fires on the next rebuild instead. It is a drift detector, not a # pre-flight gate. # * Warn rather than fail: activation must not be blocked over this, and it # is expected to read nothing at all on a fresh install where # mkswap-var-lib-swapfile.service has not run yet. system.activationScripts.checkResumeOffset.text = '' swapfile=/var/lib/swapfile want=499355648 if [ -f "$swapfile" ]; then got=$(${pkgs.e2fsprogs}/bin/filefrag -v "$swapfile" 2>/dev/null \ | ${pkgs.gawk}/bin/awk '$1=="0:" {gsub(/\.\./,"",$4); print $4; exit}') || got="" if [ -n "$got" ] && [ "$got" != "$want" ]; then echo "WARNING: resume_offset is pinned to $want in modules/hosts/shadow/disks.nix," >&2 echo " but $swapfile now starts at block $got. Hibernation will cold-boot" >&2 echo " and lose the session until boot.kernelParams is updated to $got." >&2 fi fi ''; # This host sleeps via suspend-then-hibernate (hypridle in the dotfiles repo # is what invokes it): S3 first so a quick return is instant, then a real # suspend-to-disk once the machine has clearly been abandoned. # # Deliberately NOT hybrid-sleep, which writes the disk image up front on # every single sleep. Measured here that cost ~59s of filesystem sync plus an # 11GiB write before the screens even went off, and because an S3 wake # invalidates the image the next boot always reported `PM: Image not found # (code -22)` — full hibernation price paid every time, redeemed only on a # power cut. suspend-then-hibernate defers that write until it is actually # worth doing, and unlike hybrid-sleep it is a mode NVIDIA explicitly # supports (see the system-sleep hook installed by the gpuNvidia module). # # HibernateDelaySec is set explicitly because systemd otherwise derives the # handoff from battery discharge estimation, and this desktop has no system # battery — the sole /sys/class/power_supply entry is a Logitech mouse # reporting scope=Device, which is ignored for that purpose. systemd.sleep.settings.Sleep.HibernateDelaySec = "30min"; }; }