Files
nevaforget 205e3eb60f feat: desktop host with /home on a second disk
Adds hosts/desktop: 512G system disk, 1T disk for /home, each in its own
LUKS container. modules/disk.nix gains moonarch.disk.home, which moves the
/home subvolume to the second disk and drops it from the first.

New modules/user.nix declares moonarch.user without a default, so the login
name exists once per host instead of once per reference. Both hosts set an
initialPassword: swaylock authenticates through pam_unix and cannot unlock a
screen for an account without one.
2026-08-20 12:09:07 +02:00

183 lines
6.1 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 the store stays out of the snapper snapshots of /. It is
# reproducible from the flake and would only inflate them.
"/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; };
};
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;
};
# 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>`.
'';
};
};
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;
};
};
};
})
]);
}