feat: drafted out zfs vdev, pool, and dataset implementations

This commit is contained in:
Leyla Becker 2025-11-05 10:56:04 -06:00
parent 2fd14e4cc0
commit d8989bb43d
4 changed files with 551 additions and 10 deletions

View file

@ -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 = {