diff --git a/modules/nixos-modules/storage/impermanence.nix b/modules/nixos-modules/storage/impermanence.nix index c51e35f..b1fd6b5 100644 --- a/modules/nixos-modules/storage/impermanence.nix +++ b/modules/nixos-modules/storage/impermanence.nix @@ -14,6 +14,7 @@ in { datasets = lib.mkOption { type = lib.types.attrsOf (lib.types.submodule impermanenceDatasetSubmodules); + default = {}; }; # TODO: this should just live under home-manager.users..storage.impermanence @@ -23,6 +24,7 @@ in { # We should by default create the `local/home/${name}`, and `persist/home/${name}` datasets datasets = lib.mkOption { type = lib.types.attrsOf (lib.types.submodule impermanenceDatasetSubmodules); + default = {}; }; })); }; diff --git a/modules/nixos-modules/storage/submodules/dataset.nix b/modules/nixos-modules/storage/submodules/dataset.nix index a6cc3e6..482671e 100644 --- a/modules/nixos-modules/storage/submodules/dataset.nix +++ b/modules/nixos-modules/storage/submodules/dataset.nix @@ -1,3 +1,96 @@ -{name, ...}: { - # TODO: we need to figure out what options a dataset can have in zfs +{lib, ...}: {name, ...}: { + options = { + type = lib.mkOption { + type = lib.types.enum ["zfs_fs" "zfs_volume"]; + default = "zfs_fs"; + description = "Type of ZFS dataset (filesystem or volume)"; + }; + + # ZFS dataset options that match what's currently hardcoded in rootFsOptions + canmount = lib.mkOption { + type = lib.types.nullOr (lib.types.enum ["on" "off" "noauto"]); + default = null; + description = "Controls whether the file system can be mounted"; + }; + + mountpoint = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Controls the mount point used for this file system"; + }; + + xattr = lib.mkOption { + type = lib.types.nullOr (lib.types.enum ["on" "off" "sa" "dir"]); + default = null; + description = "Extended attribute storage method"; + }; + + acltype = lib.mkOption { + type = lib.types.nullOr (lib.types.enum ["off" "nfsv4" "posixacl"]); + default = null; + description = "Access control list type"; + }; + + relatime = lib.mkOption { + type = lib.types.nullOr (lib.types.enum ["on" "off"]); + default = null; + description = "Controls when access time is updated"; + }; + + compression = lib.mkOption { + type = lib.types.nullOr (lib.types.enum ["on" "off" "lz4" "gzip" "zstd" "lzjb" "zle"]); + default = null; + description = "Compression algorithm to use"; + }; + + encryption = lib.mkOption { + type = lib.types.nullOr (lib.types.enum ["on" "off" "aes-128-ccm" "aes-192-ccm" "aes-256-ccm" "aes-128-gcm" "aes-192-gcm" "aes-256-gcm"]); + default = null; + description = "Encryption algorithm to use"; + }; + + keyformat = lib.mkOption { + type = lib.types.nullOr (lib.types.enum ["raw" "hex" "passphrase"]); + default = null; + description = "Format of the encryption key"; + }; + + keylocation = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Location of the encryption key"; + }; + + autoSnapshot = lib.mkOption { + type = lib.types.nullOr lib.types.bool; + default = null; + description = "Enable automatic snapshots for this dataset"; + }; + + # Additional common ZFS options + recordsize = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Suggested block size for files in the file system"; + }; + + sync = lib.mkOption { + type = lib.types.nullOr (lib.types.enum ["standard" "always" "disabled"]); + default = null; + description = "Synchronous write behavior"; + }; + + atime = lib.mkOption { + type = lib.types.nullOr (lib.types.enum ["on" "off"]); + default = null; + description = "Controls whether access time is updated"; + }; + + # Custom options for disko integration + postCreateHook = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Script to run after dataset creation"; + }; + }; } diff --git a/modules/nixos-modules/storage/submodules/impermanenceDataset.nix b/modules/nixos-modules/storage/submodules/impermanenceDataset.nix index f9a4df6..193ab80 100644 --- a/modules/nixos-modules/storage/submodules/impermanenceDataset.nix +++ b/modules/nixos-modules/storage/submodules/impermanenceDataset.nix @@ -1,4 +1,4 @@ -{lib, ...}: let +{lib, ...}: {...}: let pathPermissions = { read = lib.mkEnableOption "should the path have read permissions"; write = lib.mkEnableOption "should the path have read permissions"; diff --git a/modules/nixos-modules/storage/zfs.nix b/modules/nixos-modules/storage/zfs.nix index e5793ca..20e41ae 100644 --- a/modules/nixos-modules/storage/zfs.nix +++ b/modules/nixos-modules/storage/zfs.nix @@ -5,6 +5,49 @@ args @ { ... }: let datasetSubmodule = (import ./submodules/dataset.nix) args; + + # max gpt length is 36 and disk adds formats it like disk-xxxx-zfs which means we need to be 9 characters under that + hashDisk = drive: (builtins.substring 0 27 (builtins.hashString "sha256" drive)); + + poolVdevs = [ + (builtins.map ( + device: let + deviceStr = + if builtins.isString device + then device + else device.device; + in + lib.attrsets.nameValuePair (hashDisk deviceStr) deviceStr + ) + config.storage.zfs.pool.vdevs) + ]; + + poolCache = builtins.map ( + name: let + device = config.storage.zfs.pool.cache.${name}; + deviceStr = + if builtins.isString device + then device + else device.device; + in + lib.attrsets.nameValuePair (hashDisk deviceStr) deviceStr + ) (builtins.attrNames config.storage.zfs.pool.cache); + + bootDrives = + builtins.map ( + device: + if builtins.isString device + then device + else device.device + ) (builtins.filter ( + device: + if builtins.isString device + then false + else device.boot + ) + config.storage.zfs.pool.vdevs); + + allDrives = (lib.lists.flatten poolVdevs) ++ poolCache; in { options.storage = { zfs = { @@ -46,35 +89,438 @@ in { boot = lib.mkEnableOption "should this device be a boot device"; }; in { - encryption = lib.mkEnableOption "Should encryption be enabled on this pool."; + encryption = { + enable = lib.mkEnableOption "Should encryption be enabled on this pool."; + keyformat = lib.mkOption { + type = lib.types.enum ["raw" "hex" "passphrase"]; + default = "hex"; + description = "Format of the encryption key"; + }; + keylocation = lib.mkOption { + type = lib.types.str; + default = "prompt"; + description = "Location of the encryption key"; + }; + }; + mode = lib.mkOption { + type = lib.types.enum ["stripe" "mirror" "raidz1" "raidz2" "raidz3"]; + default = "raidz2"; + description = "ZFS redundancy mode for the pool"; + }; + bootPartitionSize = lib.mkOption { + type = lib.types.str; + default = "2G"; + description = "Size of the boot partition on boot drives"; + }; vdevs = lib.mkOption { type = lib.types.listOf deviceType; default = []; }; cache = lib.mkOption { type = lib.types.attrsOf deviceType; + default = {}; }; }; - # TODO: create the root dataset automatically - # TODO: dataset option that is a submodule that adds datasets to the system - # warnings for when a dataset was created in the past on a system but it is now missing some of the options defined for it + rootDataset = lib.mkOption { + type = lib.types.submodule datasetSubmodule; + description = "Root ZFS dataset to create"; + default = {}; + }; + datasets = lib.mkOption { type = lib.types.attrsOf (lib.types.submodule datasetSubmodule); + description = "Additional ZFS datasets to create"; + default = {}; }; }; }; config = lib.mkIf config.storage.zfs.enable (lib.mkMerge [ { + assertions = [ + { + assertion = builtins.length bootDrives > 0; + message = '' + ZFS configuration requires at least one boot drive. Please configure at least one device with boot = true in storage.zfs.pool.vdevs. + ''; + } + { + assertion = + !( + config.storage.zfs.pool.encryption.enable + && (config.storage.zfs.rootDataset.encryption + != null + || config.storage.zfs.rootDataset.keyformat != null + || config.storage.zfs.rootDataset.keylocation != null) + ); + message = '' + Cannot set encryption options in both pool.encryption and rootDataset. + Use either pool.encryption for default settings or rootDataset encryption options for explicit control, but not both. + ''; + } + ]; + services.zfs = { autoScrub.enable = true; autoSnapshot.enable = true; }; - # TODO: post activation script that makes sure that our configured pool match the pool that exist on the system - # TODO: validation that we have a boot drive - # TODO: disko config mapping + # Disko configuration based on pool settings + disko.devices = { + disk = ( + builtins.listToAttrs ( + builtins.map + (drive: + lib.attrsets.nameValuePair (drive.name) { + type = "disk"; + device = "/dev/disk/by-id/${drive.value}"; + content = { + type = "gpt"; + partitions = { + ESP = lib.mkIf (builtins.elem drive.value bootDrives) { + size = config.storage.zfs.pool.bootPartitionSize; + type = "EF00"; + content = { + type = "filesystem"; + format = "vfat"; + mountpoint = "/boot"; + mountOptions = ["umask=0077"]; + }; + }; + zfs = { + size = "100%"; + content = { + type = "zfs"; + pool = "rpool"; + }; + }; + }; + }; + }) + allDrives + ) + ); + zpool = { + rpool = { + type = "zpool"; + mode = { + topology = { + type = "topology"; + vdev = ( + builtins.map (disks: { + mode = config.storage.zfs.pool.mode; + members = + builtins.map (disk: disk.name) disks; + }) + poolVdevs + ); + cache = builtins.map (disk: disk.name) poolCache; + }; + }; + + options = { + ashift = "12"; + autotrim = "on"; + }; + + rootFsOptions = let + rootDataset = config.storage.zfs.rootDataset; + # Start with defaults that match the original hardcoded values + defaults = { + canmount = "off"; + mountpoint = "none"; + xattr = "sa"; + acltype = "posixacl"; + relatime = "on"; + compression = "lz4"; + "com.sun:auto-snapshot" = "false"; + }; + # Override defaults with non-null values from rootDataset + userOptions = lib.attrsets.filterAttrs (_: v: v != null) { + canmount = rootDataset.canmount; + mountpoint = rootDataset.mountpoint; + xattr = rootDataset.xattr; + acltype = rootDataset.acltype; + relatime = rootDataset.relatime; + compression = rootDataset.compression; + encryption = rootDataset.encryption; + keyformat = rootDataset.keyformat; + keylocation = rootDataset.keylocation; + recordsize = rootDataset.recordsize; + sync = rootDataset.sync; + atime = rootDataset.atime; + "com.sun:auto-snapshot" = + if rootDataset.autoSnapshot == null + then null + else + ( + if rootDataset.autoSnapshot + then "true" + else "false" + ); + }; + # Only apply pool encryption if user hasn't set encryption options in rootDataset + poolEncryptionOptions = + lib.attrsets.optionalAttrs ( + config.storage.zfs.pool.encryption.enable + && rootDataset.encryption == null + && rootDataset.keyformat == null + && rootDataset.keylocation == null + ) { + encryption = "on"; + keyformat = config.storage.zfs.pool.encryption.keyformat; + keylocation = config.storage.zfs.pool.encryption.keylocation; + }; + in + defaults // userOptions // rootDataset.options // poolEncryptionOptions; + + datasets = lib.mkMerge [ + ( + lib.attrsets.mapAttrs (name: value: { + type = value.type; + options = let + # For datasets, only include non-null user-specified values + userOptions = lib.attrsets.filterAttrs (_: v: v != null) { + canmount = value.canmount; + xattr = value.xattr; + acltype = value.acltype; + relatime = value.relatime; + compression = value.compression; + encryption = value.encryption; + keyformat = value.keyformat; + keylocation = value.keylocation; + recordsize = value.recordsize; + sync = value.sync; + atime = value.atime; + "com.sun:auto-snapshot" = + if value.autoSnapshot == null + then null + else + ( + if value.autoSnapshot + then "true" + else "false" + ); + }; + in + userOptions // (value.options or {}); + mountpoint = value.mountpoint; + postCreateHook = value.postCreateHook or ""; + }) + config.storage.zfs.datasets + ) + ]; + }; + }; + }; + + # Post-activation scripts for validation + system.activationScripts = { + # Script 1: Validate pool, cache devices, and vdevs + zfs-pool-validation = { + text = '' + echo "Running ZFS pool validation..." + + # Function to check if a device exists in a vdev or cache + check_device_in_pool() { + local device_id="$1" + local device_type="$2" # "cache" or "vdev" + + if ! zpool status rpool | grep -q "$device_id"; then + echo "ERROR: Device $device_id not found in pool rpool ($device_type)" + return 1 + fi + return 0 + } + + # Function to validate vdev configuration + validate_vdevs() { + local expected_mode="${config.storage.zfs.pool.mode}" + local pool_status=$(zpool status rpool) + + # Check if pool exists + if ! zpool list rpool >/dev/null 2>&1; then + echo "ERROR: ZFS pool 'rpool' does not exist" + return 1 + fi + + # Validate each configured vdev device + ${lib.concatMapStringsSep "\n" ( + device: let + deviceStr = + if builtins.isString device + then device + else device.device; + in '' + if ! check_device_in_pool "${deviceStr}" "vdev"; then + echo "ERROR: Vdev device ${deviceStr} not found in pool" + exit 1 + fi + '' + ) + config.storage.zfs.pool.vdevs} + + # Check pool mode matches configuration + if ! echo "$pool_status" | grep -q "$expected_mode"; then + echo "WARNING: Pool mode may not match expected configuration ($expected_mode)" + fi + + echo "✓ All vdev devices validated successfully" + return 0 + } + + # Function to validate cache configuration + validate_cache() { + ${lib.concatMapStringsSep "\n" ( + name: let + device = config.storage.zfs.pool.cache.${name}; + deviceStr = + if builtins.isString device + then device + else device.device; + in '' + if ! check_device_in_pool "${deviceStr}" "cache"; then + echo "ERROR: Cache device ${deviceStr} (${name}) not found in pool" + exit 1 + fi + '' + ) (builtins.attrNames config.storage.zfs.pool.cache)} + + echo "✓ All cache devices validated successfully" + return 0 + } + + # Run validations + if validate_vdevs && validate_cache; then + echo "✓ ZFS pool validation completed successfully" + else + echo "✗ ZFS pool validation failed" + exit 1 + fi + ''; + deps = ["zfs"]; + }; + + # Script 2: Validate datasets and their options + zfs-dataset-validation = { + text = '' + echo "Running ZFS dataset validation..." + + # Function to check if dataset exists + check_dataset_exists() { + local dataset="$1" + if ! zfs list "$dataset" >/dev/null 2>&1; then + echo "ERROR: Dataset $dataset does not exist" + return 1 + fi + return 0 + } + + # Function to validate dataset options + validate_dataset_options() { + local dataset="$1" + local expected_options="$2" + + # Parse expected options (format: "option=value option2=value2") + echo "$expected_options" | tr ' ' '\n' | while IFS='=' read -r option expected_value; do + if [ -n "$option" ] && [ -n "$expected_value" ]; then + local actual_value=$(zfs get -H -o value "$option" "$dataset" 2>/dev/null) + if [ "$actual_value" != "$expected_value" ]; then + echo "ERROR: Dataset $dataset option $option is '$actual_value', expected '$expected_value'" + return 1 + fi + fi + done + return 0 + } + + # Validate root dataset + echo "Validating root dataset..." + if check_dataset_exists "rpool"; then + root_options="" + ${lib.concatMapStringsSep "\n" ( + option: let + value = config.storage.zfs.rootDataset.${option}; + in + lib.optionalString (value != null) '' + root_options="$root_options ${option}=${toString value}" + '' + ) ["canmount" "xattr" "acltype" "relatime" "compression" "encryption" "keyformat" "keylocation" "recordsize" "sync" "atime"]} + + # Add autoSnapshot option + ${lib.optionalString (config.storage.zfs.rootDataset.autoSnapshot != null) '' + root_options="$root_options com.sun:auto-snapshot=${ + if config.storage.zfs.rootDataset.autoSnapshot + then "true" + else "false" + }" + ''} + + if validate_dataset_options "rpool" "$root_options"; then + echo "✓ Root dataset options validated" + else + echo "✗ Root dataset validation failed" + exit 1 + fi + else + echo "✗ Root dataset validation failed" + exit 1 + fi + + # Validate configured datasets + ${lib.concatMapStringsSep "\n" ( + name: let + dataset = config.storage.zfs.datasets.${name}; + in '' + echo "Validating dataset: rpool/${name}" + if check_dataset_exists "rpool/${name}"; then + dataset_options="" + ${lib.concatMapStringsSep "\n" ( + option: let + value = dataset.${option}; + in + lib.optionalString (value != null) '' + dataset_options="$dataset_options ${option}=${toString value}" + '' + ) ["canmount" "xattr" "acltype" "relatime" "compression" "encryption" "keyformat" "keylocation" "recordsize" "sync" "atime"]} + + # Add autoSnapshot option + ${lib.optionalString (dataset.autoSnapshot != null) '' + dataset_options="$dataset_options com.sun:auto-snapshot=${ + if dataset.autoSnapshot + then "true" + else "false" + }" + ''} + + # Add custom options + ${lib.concatMapStringsSep "\n" ( + optName: let + optValue = dataset.options.${optName}; + in '' + dataset_options="$dataset_options ${optName}=${toString optValue}" + '' + ) (builtins.attrNames (dataset.options or {}))} + + if validate_dataset_options "rpool/${name}" "$dataset_options"; then + echo "✓ Dataset rpool/${name} options validated" + else + echo "✗ Dataset rpool/${name} validation failed" + exit 1 + fi + else + echo "✗ Dataset rpool/${name} validation failed" + exit 1 + fi + '' + ) (builtins.attrNames config.storage.zfs.datasets)} + + echo "✓ ZFS dataset validation completed successfully" + ''; + deps = ["zfs" "zfs-pool-validation"]; + }; + }; } (lib.mkIf config.storage.zfs.notifications.enable { programs.msmtp = {