Files
nixos/modules/hosts/shadow/disks.nix
T
2026-08-16 17:54:19 +01:00

193 lines
9.0 KiB
Nix

{...}: {
# 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";
};
}