Files
moonix/modules/disk.nix
T
nevaforget 7fc45ae0b8 fix(thinkpad): ready the host for its first install
The host still carried snapper, which the desktop dropped in d3cc03c:
disk.nix creates no .snapshots subvolumes, so the timer would fail every
hour without ever taking a snapshot. The account sat in the docker group
while docker itself was off, and stateVersion named 26.05 although the
machine is first installed on 26.11.

Also drops the stale snapper mentions elsewhere and adds git-firefly to
Zed's auto-installed extensions.
2026-09-23 13:14:30 +02:00

284 lines
10 KiB
Nix

# ABOUTME: Declarative disk layout for every host: GPT, optional LUKS2, btrfs.
# ABOUTME: A host sets moonarch.disk.device; the layout itself is shared.
{ config, lib, ... }:
let
cfg = config.moonarch.disk;
mountOptions = [ "compress=zstd" "noatime" ];
systemSubvolumes = {
"/root" = { mountpoint = "/"; inherit mountOptions; };
# Separate so a snapshot or copy of / leaves the store out. It is
# reproducible from the flake and would only inflate either.
"/nix" = { mountpoint = "/nix"; inherit mountOptions; };
} // lib.optionalAttrs (!cfg.home.enable) {
# Only here when there is no second disk. With one, /home lives over there
# and the main disk carries the system alone.
"/home" = { mountpoint = "/home"; inherit mountOptions; };
} // lib.optionalAttrs (cfg.swapSize != "") {
# No compression or noatime here: disko creates the swapfile inside this
# subvolume, and btrfs requires it to be nodatacow, which it sets itself.
"/swap" = {
mountpoint = "/.swapvol";
swap.swapfile.size = cfg.swapSize;
};
};
homeSubvolumes = {
"/home" = { mountpoint = "/home"; inherit mountOptions; };
};
# A disk holding nothing anyone waits for is allowed to be absent. These
# options make that a non-event: the mount happens on first access rather than
# at boot, systemd stops waiting for the device after five seconds, and the
# filesystem is unmounted again once nothing has touched it for ten minutes,
# which leaves the disk idle and cool most of the time.
scratchMountOptions = mountOptions ++ [
"noauto"
"nofail"
"x-systemd.automount"
"x-systemd.device-timeout=5"
"x-systemd.mount-timeout=10"
"x-systemd.idle-timeout=600"
# Without this the disk is reachable only by typing its path: file managers
# list removable media and gvfs entries, not plain fstab lines. Since the
# entry is noauto, it shows up as a drive that is not mounted yet, which is
# what it usually is.
"x-gvfs-show"
"x-gvfs-name=Storage"
];
storageSubvolumes = {
"/storage" = { mountpoint = "/storage"; mountOptions = scratchMountOptions; };
};
btrfs = subvolumes: {
type = "btrfs";
# Overwrite an existing signature instead of asking. The device is erased
# by this point either way.
extraArgs = [ "-f" ];
inherit subvolumes;
};
# Neither passwordFile nor settings.keyFile is set, which makes disko's
# askPassword default to true: it prompts for the passphrase twice while
# formatting and never puts it on a command line.
#
# With a second disk each container is asked for separately while formatting.
# Give both the same passphrase: systemd stage 1 caches the first one and
# tries it on the second container, so the boot prompts once. If it ever asks
# twice, boot.initrd.systemd.enable = false falls back to the script initrd,
# whose boot.initrd.luks.reusePassphrases does the same thing explicitly.
luks = name: content: {
type = "luks";
inherit name;
# Lets TRIM reach the SSD through the container. The tradeoff is that the
# pattern of used blocks becomes visible on the raw device.
settings.allowDiscards = true;
inherit content;
};
# Same container, opened after stage 1 instead of inside the initrd. A disk
# that stage 1 waits for can stop the machine from booting when it is missing
# or has died, which is exactly what must not happen for expendable storage;
# initrdUnlock = false keeps it out of there. Nothing then opens it on its own,
# so the caller has to supply a crypttab entry — see storageCrypttab below.
luksAfterInitrd = name: content: (luks name content) // { initrdUnlock = false; };
# Wraps a filesystem in LUKS unless the host opted out of encryption.
maybeEncrypted = name: subvolumes:
if cfg.encrypt then luks name (btrfs subvolumes) else btrfs subvolumes;
in
{
options.moonarch.disk = {
enable = lib.mkEnableOption "the declarative disk layout";
device = lib.mkOption {
type = lib.types.str;
example = "/dev/disk/by-id/nvme-eui.0123456789abcdef";
description = ''
Whole disk to partition, as a /dev/disk/by-id/ path — that one survives
enumeration changes, unlike /dev/nvme0n1.
This value decides which disk gets erased. Check it against lsblk on the
target machine before installing.
Only the partitioning reads it. The generated fileSystems entries go
through /dev/disk/by-partlabel/, so a booted system does not depend on
the path staying the same.
'';
};
home = {
enable = lib.mkEnableOption "a second disk carrying /home";
device = lib.mkOption {
type = lib.types.str;
example = "/dev/disk/by-id/nvme-eui.fedcba9876543210";
description = ''
Second whole disk, erased and partitioned like the first one. It takes
the /home subvolume, which then no longer exists on the main disk.
Encryption follows moonarch.disk.encrypt, so both disks are either
encrypted or neither is. The container is named crypted-home to keep
it apart from the system one.
disko-install addresses it as `--disk home <path>`, the same way the
main disk is `--disk main <path>`.
'';
};
};
storage = {
enable = lib.mkEnableOption "a disk for data whose loss costs nothing";
device = lib.mkOption {
type = lib.types.str;
example = "/dev/disk/by-id/nvme-eui.0f1e2d3c4b5a6978";
description = ''
Whole disk for expendable data, mounted at /storage — a game library,
downloads, images. Nothing that would be missed and nothing anyone
waits for, because this disk is allowed to be slow, absent or dead.
Unlike the other two it is never unlocked in the initrd, so a failure
cannot hold up the boot. It is mounted on first access instead and
unmounted again when idle. That is what makes it usable for a drive
that is not entirely trustworthy.
disko-install addresses it as `--disk storage <path>`.
'';
};
keyFile = lib.mkOption {
type = lib.types.str;
default = "/var/keys/storage.key";
description = ''
Key file that unlocks the container without a prompt. It has to live on
an already-decrypted filesystem — the default sits on the root disk, so
it is covered by that disk's encryption and unreadable without the boot
passphrase.
Create it once and add it as a second key slot; the existing passphrase
keeps working:
dd bs=512 count=4 if=/dev/random of=<keyFile> iflag=fullblock
chmod 400 <keyFile>
cryptsetup luksAddKey <partition> <keyFile>
'';
};
};
encrypt = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Put the filesystem inside a LUKS2 container. The layout is otherwise
identical, so a machine that does not need encryption — a throwaway VM —
gets the same subvolumes.
'';
};
espSize = lib.mkOption {
type = lib.types.str;
default = "1G";
description = ''
Size of the EFI system partition. Every boot entry keeps its kernel and
initrd here, so this scales with the number of generations kept.
'';
};
swapSize = lib.mkOption {
type = lib.types.str;
default = "8G";
example = "";
description = ''
Size of the swapfile in the /.swapvol subvolume. Empty string creates no
swap at all.
Sized as a safety valve, not as a replacement for RAM. Hibernation needs
at least as much swap as RAM plus a resume_offset kernel parameter, which
is not set up here — see DECISIONS.md.
'';
};
};
config = lib.mkIf cfg.enable (lib.mkMerge [
{
disko.devices.disk.main = {
type = "disk";
device = cfg.device;
content = {
type = "gpt";
partitions = {
ESP = {
size = cfg.espSize;
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
# Named the same whether or not it is encrypted, so the partlabel the
# fileSystems entries point at does not depend on cfg.encrypt.
root = {
size = "100%";
content = maybeEncrypted "crypted" systemSubvolumes;
};
};
};
};
}
# Second disk. No ESP on it: the firmware boots from the main disk, and a
# second one would only be another thing to keep in sync.
(lib.mkIf cfg.home.enable {
disko.devices.disk.home = {
type = "disk";
device = cfg.home.device;
content = {
type = "gpt";
partitions.home = {
size = "100%";
content = maybeEncrypted "crypted-home" homeSubvolumes;
};
};
};
})
# Expendable storage, same shape as the disk above with one difference that
# changes everything about it: its container never enters the initrd, so a
# drive that is slow, missing or dead cannot stop the machine from booting.
(lib.mkIf cfg.storage.enable {
disko.devices.disk.storage = {
type = "disk";
device = cfg.storage.device;
content = {
type = "gpt";
partitions.storage = {
size = "100%";
content =
if cfg.encrypt
then luksAfterInitrd "storage" (btrfs storageSubvolumes)
else btrfs storageSubvolumes;
};
};
};
})
# Keeping it out of the initrd means nothing opens it any more, so this is
# what does. The partition is addressed by its partlabel, which disko derives
# from the disk and partition names above — no UUID has to be pasted in here,
# and a replaced disk needs no edit. noauto leaves the opening to the mount
# in storageSubvolumes, nofail keeps a failure from becoming a boot problem.
(lib.mkIf (cfg.storage.enable && cfg.encrypt) {
environment.etc.crypttab.text = ''
storage /dev/disk/by-partlabel/disk-storage-storage ${cfg.storage.keyFile} noauto,nofail
'';
})
]);
}