feat(zfs): manage jeeves datasets declaratively from nix #47

Open
Richie wants to merge 1 commits from feature/manage-jeeves-datasets-declaratively-from-nix into main
11 changed files with 1169 additions and 184 deletions
+168
View File
@@ -0,0 +1,168 @@
{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.zfs_manager;
snapshotOptions = {
options = {
"15_min" = lib.mkOption {
type = lib.types.int;
default = 0;
description = "How many 15 minute snapshots to keep.";
};
hourly = lib.mkOption {
type = lib.types.int;
default = 0;
description = "How many hourly snapshots to keep.";
};
daily = lib.mkOption {
type = lib.types.int;
default = 0;
description = "How many daily snapshots to keep.";
};
monthly = lib.mkOption {
type = lib.types.int;
default = 0;
description = "How many monthly snapshots to keep.";
};
};
};
datasetOptions = {
options = {
manageProperties = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether zfs_manager owns this dataset's properties. When false the
dataset only contributes its snapshot retention, which is how
root_pool datasets are declared.
'';
};
properties = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
description = ''
The zfs properties this dataset should have. Values are compared
against the live dataset and corrected when they differ.
'';
};
snapshots = lib.mkOption {
type = lib.types.submodule snapshotOptions;
default = { };
description = "Snapshot retention for this dataset.";
};
};
};
# snapshot_manager.py only ever walks datasets below a pool root, so pool
# roots are left out of the retention table. It also indexes the table
# directly, which is why every entry carries all four keys.
snapshotTable = lib.mapAttrs (_: dataset: dataset.snapshots) (
lib.filterAttrs (name: _: lib.hasInfix "/" name) cfg.datasets
);
snapshotConfig = (pkgs.formats.toml { }).generate "snapshot_config.toml" (
snapshotTable // { default = cfg.defaultSnapshots; }
);
# Every declared dataset is emitted, including the ones whose properties are
# not managed, so the tool can tell "deliberately hands off" apart from
# "nobody has written this down yet".
datasetConfig = (pkgs.formats.json { }).generate "zfs_datasets.json" {
datasets = lib.mapAttrs (_: dataset: {
inherit (dataset) manageProperties properties;
}) cfg.datasets;
};
in
{
options = {
services.zfs_manager = {
enable = lib.mkEnableOption "declarative ZFS dataset management";
datasets = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule datasetOptions);
default = { };
example = lib.literalExpression ''
{
"media/temp".properties = {
sync = "disabled";
redundant_metadata = "none";
};
}
'';
description = ''
The datasets to manage, keyed by full dataset name. Missing datasets
are created and drifted properties are corrected. Nothing is ever
destroyed, and datasets that are not declared are left alone.
A name without a "/" is a pool root filesystem. Its properties are
managed but it is never created, pool creation stays manual.
'';
};
defaultSnapshots = lib.mkOption {
type = lib.types.submodule snapshotOptions;
default = { };
description = ''
Retention for any dataset that is not declared above, emitted as the
"default" table of the snapshot config.
'';
};
dryRun = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Log every change that would be made without touching zfs. Use this to
validate a new or heavily edited declaration before applying it.
'';
};
PYTHONPATH = lib.mkOption {
type = lib.types.str;
description = ''
the PYTHONPATH to use for the zfs_manager service.
'';
};
EnvironmentFile = lib.mkOption {
type = lib.types.nullOr (lib.types.coercedTo lib.types.path toString lib.types.str);
default = null;
description = ''
Single environment file for the service (e.g. /etc/zfs-manager/env).
Use a leading "-" to ignore if missing (systemd feature).
'';
};
};
};
config = lib.mkIf cfg.enable {
services.snapshot_manager.path = snapshotConfig;
systemd = {
services.zfs_manager = {
description = "ZFS Dataset Manager";
requires = [ "zfs-import.target" ];
after = [
"zfs-import.target"
"zfs-mount.service"
];
wantedBy = [ "multi-user.target" ];
path = [ pkgs.zfs ];
# Re-run on nixos-rebuild switch whenever the declaration changes.
restartTriggers = [ datasetConfig ];
environment = {
PYTHONPATH = cfg.PYTHONPATH;
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "${pkgs.my_python}/bin/python -m python.tools.zfs_manager ${lib.escapeShellArg datasetConfig}${lib.optionalString cfg.dryRun " --dry-run"}";
}
// lib.optionalAttrs (cfg.EnvironmentFile != null) {
EnvironmentFile = cfg.EnvironmentFile;
};
};
};
};
}
+267
View File
@@ -0,0 +1,267 @@
"""zfs_manager.
Reconciles the live zfs datasets against a declaration generated by
common/optional/zfs_manager.nix. Datasets are created and properties are
corrected, but nothing is ever destroyed or renamed.
"""
from __future__ import annotations
import json
import logging
import sys
from pathlib import Path # noqa: TC003 This is required for the typer CLI
import typer
from python.common import configure_logger
from python.signal_alert import signal_alert
from python.zfs import create_dataset, get_properties, list_dataset_names, set_property
logger = logging.getLogger(__name__)
# Properties that can only be chosen at creation time. Attempting to zfs set
# these fails on every run, so a mismatch is reported instead of retried.
CREATE_ONLY_PROPERTIES = frozenset(
{
"casesensitivity",
"encryption",
"keyformat",
"normalization",
"utf8only",
"volblocksize",
},
)
# Properties whose values zfs reports in bytes but which are conventionally
# declared with a size suffix, so "16k" and "16384" mean the same thing.
SIZE_PROPERTIES = frozenset(
{
"quota",
"recordsize",
"refquota",
"refreservation",
"reservation",
"special_small_blocks",
"volblocksize",
"volsize",
},
)
SIZE_SUFFIXES = {"b": 1, "k": 1024, "m": 1024**2, "g": 1024**3, "t": 1024**4, "p": 1024**5}
# Sources that mean the value was deliberately put on this dataset rather than
# inherited from a parent or left at the zfs default.
LOCAL_SOURCES = ("local", "received")
def main(config_file: Path, *, dry_run: bool = False) -> None:
"""Main.
Args:
config_file (Path): The path to the generated dataset declaration.
dry_run (bool): Log the changes that would be made without making them.
"""
configure_logger(level="DEBUG")
logger.info(f"Starting zfs_manager {dry_run=}")
try:
declared = json.loads(config_file.read_text())["datasets"]
existing = set(list_dataset_names())
unusable: set[str] = set()
# Parents before children so a newly created parent exists by the time
# its children are reconciled.
for name in sorted(declared, key=lambda name: (name.count("/"), name)):
if has_unusable_parent(name, unusable):
logger.error(f"skipping {name}, its parent could not be created")
continue
# Declared purely to record retention, its properties belong to
# whoever set them.
if not declared[name].get("manageProperties", True):
logger.debug(f"{name} is declared but its properties are not managed")
continue
properties = declared[name]["properties"]
if name in existing:
reconcile_dataset(name, properties, dry_run=dry_run)
elif create_missing_dataset(name, properties, dry_run=dry_run):
existing.add(name)
elif not dry_run:
unusable.add(name)
report_undeclared_datasets(existing, declared)
except Exception:
logger.exception("zfs_manager failed")
signal_alert("zfs_manager failed")
sys.exit(1)
else:
logger.info("zfs_manager completed")
def has_unusable_parent(name: str, unusable: set[str]) -> bool:
"""Check whether an ancestor of a dataset could not be created.
Args:
name (str): The name of the dataset.
unusable (set[str]): The datasets that do not exist and could not be created.
Returns:
bool: True if any ancestor is unusable.
"""
parts = name.split("/")
return any("/".join(parts[:depth]) in unusable for depth in range(1, len(parts)))
def create_missing_dataset(name: str, properties: dict[str, str], *, dry_run: bool) -> bool:
"""Create a dataset that is declared but does not exist yet.
Pool root filesystems are never created, creating a pool is out of scope.
Args:
name (str): The name of the dataset.
properties (dict[str, str]): The properties to create the dataset with.
dry_run (bool): Log the change without making it.
Returns:
bool: True if the dataset now exists.
"""
if "/" not in name:
message = f"pool {name} is declared but does not exist, zfs_manager does not create pools"
logger.error(message)
signal_alert(message)
return False
if dry_run:
logger.info(f"would create {name} with {properties}")
return False
logger.info(f"creating {name} with {properties}")
if error := create_dataset(name, properties):
logger.error(error)
signal_alert(error)
return False
return True
def reconcile_dataset(name: str, properties: dict[str, str], *, dry_run: bool) -> None:
"""Bring an existing dataset in line with its declared properties.
Args:
name (str): The name of the dataset.
properties (dict[str, str]): The declared properties.
dry_run (bool): Log the changes without making them.
"""
current = get_properties(name)
for key, wanted in sorted(properties.items()):
current_value, _ = current.get(key, ("-", "-"))
if values_match(key, wanted, current_value):
continue
if key in CREATE_ONLY_PROPERTIES:
message = (
f"{name} {key} is {current_value} but {wanted} is declared, "
f"{key} can only be set when the dataset is created"
)
logger.error(message)
signal_alert(message)
continue
if dry_run:
logger.info(f"would set {key}={wanted} on {name}, currently {current_value}")
continue
logger.info(f"setting {key}={wanted} on {name}, was {current_value}")
if error := set_property(name, key, wanted):
logger.error(error)
signal_alert(error)
report_undeclared_properties(name, properties, current)
def report_undeclared_properties(name: str, properties: dict[str, str], current: dict[str, tuple[str, str]]) -> None:
"""Warn about properties set on the dataset but absent from the declaration.
Inherited and default values are silent, they are not drift. A locally set
value that nix does not know about was changed outside of this tool and
will be lost the next time the dataset is recreated, so it is worth saying.
Args:
name (str): The name of the dataset.
properties (dict[str, str]): The declared properties.
current (dict[str, tuple[str, str]]): The live properties keyed to (value, source).
"""
for key, (value, source) in sorted(current.items()):
# User properties such as nixos:shutdown-time are written by other
# tools and are not something a dataset declaration should own.
if key in properties or ":" in key or source not in LOCAL_SOURCES:
continue
logger.warning(f"{name} has {key}={value} set outside of nix")
signal_alert(f"{name} has {key}={value} set outside of nix")
def report_undeclared_datasets(existing: set[str], declared: dict[str, dict]) -> None:
"""Warn about datasets that exist but are not declared.
These are left completely alone. They still get snapshots through the
default retention table.
Args:
existing (set[str]): The names of every live dataset.
declared (dict[str, dict]): The declaration.
"""
for name in sorted(existing - set(declared)):
logger.warning(f"{name} exists but is not declared in nix")
def values_match(key: str, wanted: str, current: str) -> bool:
"""Compare a declared property value against the live one.
Args:
key (str): The property name.
wanted (str): The declared value.
current (str): The live value.
Returns:
bool: True if the two values mean the same thing.
"""
if key in SIZE_PROPERTIES:
wanted_size = parse_size(wanted)
current_size = parse_size(current)
if wanted_size is not None and current_size is not None:
return wanted_size == current_size
return wanted == current
def parse_size(value: str) -> int | None:
"""Convert a zfs size such as 16k or 1M into bytes.
Args:
value (str): The size to convert.
Returns:
int | None: The size in bytes, or None if it is not a size.
"""
value = value.strip()
if value.isdigit():
return int(value)
number, suffix = value[:-1], value[-1:].lower()
if suffix in SIZE_SUFFIXES and number.isdigit():
return int(number) * SIZE_SUFFIXES[suffix]
return None
def cli() -> None:
"""CLI."""
typer.run(main)
if __name__ == "__main__":
cli()
+13 -1
View File
@@ -1,11 +1,23 @@
"""init."""
from python.zfs.dataset import Dataset, Snapshot, get_datasets
from python.zfs.dataset import (
Dataset,
Snapshot,
create_dataset,
get_datasets,
get_properties,
list_dataset_names,
set_property,
)
from python.zfs.zpool import Zpool
__all__ = [
"Dataset",
"Snapshot",
"Zpool",
"create_dataset",
"get_datasets",
"get_properties",
"list_dataset_names",
"set_property",
]
+78 -3
View File
@@ -207,8 +207,83 @@ def get_datasets() -> list[Dataset]:
"""
logger.info("Getting zfs list")
return [Dataset(dataset_name) for dataset_name in list_dataset_names() if "/" in dataset_name]
def get_properties(name: str) -> dict[str, tuple[str, str]]:
"""Get every property of a dataset along with where its value came from.
The source is what distinguishes a property that was deliberately set on
this dataset from one that is merely inherited or left at its default.
Args:
name (str): The name of the dataset.
Returns:
dict[str, tuple[str, str]]: A mapping of property name to (value, source).
"""
raw_properties, return_code = bash_wrapper(f"zfs get -Hp -o property,value,source all {name}")
if return_code != 0:
error = f"Failed to get properties for {name}: {raw_properties}"
raise RuntimeError(error)
properties = {}
for line in raw_properties.strip().split("\n"):
if not line:
continue
prop, value, source = line.split("\t")
properties[prop] = (value, source)
return properties
def create_dataset(name: str, properties: dict[str, str]) -> str | None:
"""Create a dataset with the given properties.
Args:
name (str): The name of the dataset.
properties (dict[str, str]): The properties to create the dataset with.
Returns:
str | None: An error message on failure, None on success.
"""
options = " ".join(f"-o {key}={value}" for key, value in sorted(properties.items()))
logger.debug(f"creating {name} with {options}")
msg, return_code = bash_wrapper(f"zfs create {options} {name}")
if return_code != 0:
return f"Failed to create {name}: {msg.strip()}"
return None
def set_property(name: str, key: str, value: str) -> str | None:
"""Set a single property on a dataset.
Args:
name (str): The name of the dataset.
key (str): The property to set.
value (str): The value to set the property to.
Returns:
str | None: An error message on failure, None on success.
"""
logger.debug(f"setting {key}={value} on {name}")
msg, return_code = bash_wrapper(f"zfs set {key}={value} {name}")
if return_code != 0:
return f"Failed to set {key}={value} on {name}: {msg.strip()}"
return None
def list_dataset_names() -> list[str]:
"""List every zfs filesystem name, including pool root filesystems.
Unlike get_datasets this does not build Dataset objects and does not filter
out pool roots, which makes it usable for existence checks.
Returns:
list[str]: The names of every zfs filesystem.
"""
dataset_names, _ = bash_wrapper("zfs list -Hp -t filesystem -o name")
cleaned_datasets = dataset_names.strip().split("\n")
return [Dataset(dataset_name) for dataset_name in cleaned_datasets if "/" in dataset_name]
return [name for name in dataset_names.strip().split("\n") if name]
+356
View File
@@ -0,0 +1,356 @@
# Dataset declarations for jeeves, kept as plain data rather than inside
# zfs.nix so that vars.nix can derive its paths from the same source.
#
# Datasets are nested the way zfs nests them: a pool holds datasets, which can
# hold datasets of their own. The tree is flattened into "pool/parent/child"
# names below, which is what zfs and services.zfs_manager work in.
#
# Consumed by ./zfs.nix (which feeds it to services.zfs_manager) and by
# ./vars.nix (which resolves the mountpoints).
let
# Every pool on jeeves was created with the same -O options.
poolDefaults = mountpoint: {
inherit mountpoint;
acltype = "posix"; # zfs reports posixacl back as posix
atime = "off";
compression = "zstd";
dnodesize = "auto";
xattr = "sa";
};
zfsKey = "file:///root/zfs.key";
# What a dataset gets when it is not called out below, kept identical to the
# "default" table so the datasets that used to fall through are unchanged.
standard = {
"15_min" = 8;
hourly = 24;
};
pools = {
# root_pool: retention only, its properties are not managed yet.
root_pool = {
manageProperties = false;
datasets = {
home = {
manageProperties = false;
snapshots = {
"15_min" = 8;
hourly = 24;
daily = 14;
};
};
root = {
manageProperties = false;
snapshots = standard;
};
nix = {
manageProperties = false;
snapshots."15_min" = 4;
};
var = {
manageProperties = false;
snapshots = {
"15_min" = 8;
hourly = 24;
daily = 30;
monthly = 6;
};
};
};
};
media = {
properties = poolDefaults "/zfs/media";
datasets = {
temp = {
properties = {
redundant_metadata = "none";
sync = "disabled";
};
snapshots."15_min" = 2;
};
secure = {
properties.keylocation = zfsKey;
snapshots = { };
datasets = {
docker = {
properties = {
mountpoint = "/zfs/media/docker";
compression = "zstd-9";
};
snapshots = {
"15_min" = 3;
hourly = 12;
daily = 14;
monthly = 2;
};
};
"github-runners" = {
properties = {
mountpoint = "/zfs/media/github-runners";
compression = "zstd-9";
sync = "disabled";
};
snapshots = {
"15_min" = 6;
hourly = 2;
daily = 1;
};
};
home_assistant = {
properties = {
mountpoint = "/zfs/media/home_assistant";
compression = "zstd-19";
};
snapshots = standard;
};
important = {
properties = {
compression = "zstd-9";
copies = "2";
};
snapshots = standard;
};
notes = {
properties = {
mountpoint = "/zfs/media/notes";
copies = "2";
};
snapshots = {
"15_min" = 8;
hourly = 24;
daily = 30;
monthly = 12;
};
};
postgres = {
properties = {
mountpoint = "/zfs/media/database/postgres";
primarycache = "metadata";
recordsize = "16K";
};
snapshots = {
"15_min" = 8;
hourly = 24;
daily = 7;
};
};
"postgres-wal" = {
properties = {
compression = "lz4";
logbias = "latency";
mountpoint = "/zfs/media/database/postgres-wal";
primarycache = "metadata";
recordsize = "32K";
secondarycache = "none";
special_small_blocks = "32K";
};
snapshots = {
"15_min" = 4;
hourly = 2;
};
};
prometheus = {
properties = {
mountpoint = "/zfs/media/database/prometheus";
compression = "lz4";
};
snapshots = standard;
};
services = {
properties = {
mountpoint = "/zfs/media/services";
compression = "zstd-9";
};
snapshots = standard;
};
share = {
properties = {
mountpoint = "/zfs/media/share";
exec = "off";
};
snapshots."15_min" = 4;
};
};
};
};
};
storage = {
properties = poolDefaults "/zfs/storage";
datasets = {
nomad = {
properties = {
mountpoint = "/zfs/storage/nomad";
compression = "zstd-9";
};
snapshots = standard;
};
ollama = {
properties = {
compression = "zstd-19";
recordsize = "1M";
sync = "disabled";
};
snapshots."15_min" = 2;
};
secure = {
properties.keylocation = zfsKey;
snapshots = { };
datasets = {
archive = {
properties = {
compression = "zstd-19";
mountpoint = "/zfs/storage/archive";
recordsize = "1M";
};
snapshots = standard;
};
important = {
properties = {
compression = "zstd-19";
copies = "2";
mountpoint = "/zfs/storage/important";
};
snapshots = standard;
};
library = {
properties = {
compression = "zstd-19";
mountpoint = "/zfs/storage/library";
recordsize = "1M";
};
snapshots = standard;
};
main = {
properties = {
compression = "zstd-19";
mountpoint = "/zfs/storage/main";
};
snapshots = standard;
};
photos = {
properties = {
compression = "zstd-19";
copies = "2";
mountpoint = "/zfs/storage/photos";
recordsize = "16K";
};
snapshots = standard;
};
plex = {
properties = {
compression = "zstd-19";
mountpoint = "/zfs/storage/plex";
recordsize = "1M";
};
snapshots = {
"15_min" = 6;
hourly = 2;
daily = 1;
};
};
secrets = {
properties = {
compression = "zstd-19";
copies = "3";
mountpoint = "/zfs/storage/secrets";
};
snapshots = {
"15_min" = 8;
hourly = 24;
daily = 30;
monthly = 12;
};
};
syncthing = {
properties = {
compression = "zstd-19";
mountpoint = "/zfs/storage/syncthing";
};
snapshots = standard;
};
transmission = {
properties = {
compression = "zstd-9";
exec = "off";
mountpoint = "/zfs/storage/transmission";
recordsize = "1M";
sync = "disabled";
};
snapshots."15_min" = 4;
};
};
};
};
};
scratch = {
properties = poolDefaults "/zfs/scratch" // {
keylocation = zfsKey;
};
datasets = {
kafka = {
properties = {
mountpoint = "/zfs/scratch/kafka";
recordsize = "1M";
};
snapshots = standard;
};
kestra = {
properties = {
mountpoint = "/zfs/scratch/kestra";
sync = "disabled";
};
snapshots = standard;
};
transmission = {
properties = {
mountpoint = "/zfs/scratch/transmission";
recordsize = "16K";
sync = "disabled";
};
snapshots."15_min" = 2;
};
uv_cache = {
properties.mountpoint = "/zfs/scratch/uv_cache";
snapshots."15_min" = 2;
};
};
};
};
# Collapse the tree into the flat "pool/parent/child" names zfs uses. Each
# node keeps everything except its children.
flatten =
name: node:
builtins.foldl' (result: child: result // flatten "${name}/${child}" node.datasets.${child}) {
${name} = builtins.removeAttrs node [ "datasets" ];
} (builtins.attrNames (node.datasets or { }));
datasets = builtins.foldl' (result: pool: result // flatten pool pools.${pool}) { } (
builtins.attrNames pools
);
# zfs gives a dataset with no mountpoint of its own its parent's mountpoint
# plus its final name component, so resolve it the same way. Pool roots all
# declare a mountpoint, which terminates the recursion.
mountpointOf =
name:
let
properties = datasets.${name}.properties or { };
in
if properties ? mountpoint then
properties.mountpoint
else if builtins.match ".*/.*" name != null then
"${mountpointOf (builtins.dirOf name)}/${builtins.baseNameOf name}"
else
throw "jeeves: ${name} has no mountpoint and no parent to inherit one from";
mountpoints = builtins.mapAttrs (name: _: mountpointOf name) datasets;
in
{
inherit datasets mountpoints;
defaultSnapshots = standard;
}
+4 -4
View File
@@ -15,6 +15,7 @@ in
"${inputs.self}/common/optional/syncthing_base.nix"
"${inputs.self}/common/optional/update.nix"
"${inputs.self}/common/optional/zerotier.nix"
"${inputs.self}/common/optional/zfs_manager.nix"
./monitoring
./docker
./services
@@ -24,6 +25,7 @@ in
./programs.nix
./runners
./syncthing.nix
./zfs.nix
];
services = {
@@ -31,10 +33,8 @@ in
smartd.enable = true;
snapshot_manager = {
path = ./snapshot_config.toml;
EnvironmentFile = "${vars.secrets}/services/snapshot_manager";
};
# path is generated from ./zfs.nix by common/optional/zfs_manager.nix
snapshot_manager.EnvironmentFile = "${vars.secrets}/services/snapshot_manager";
zerotierone.joinNetworks = [ "a09acf02330d37b9" ];
};
+9 -29
View File
@@ -1,5 +1,10 @@
#!/bin/bash
# Pool and vdev creation only. This is run by hand once per pool.
#
# Datasets and their properties are declared in systems/jeeves/zfs.nix and
# reconciled by the zfs_manager service. Do not add zfs create lines here.
# zpools
# media
@@ -12,35 +17,10 @@ sudo zpool add storage -o ashift=12 special mirror
sudo zpool add storage -o ashift=12 logs mirror
# scratch
sudo zpool create scratch -o ashift=12 -O acltype=posixacl -O atime=off -O dnodesize=auto -O xattr=sa -O compression=zstd -O encryption=aes-256-gcm -O keyformat=hex -O keylocation=file:///key -m /zfs/scratch
sudo zpool create scratch -o ashift=12 -O acltype=posixacl -O atime=off -O dnodesize=auto -O xattr=sa -O compression=zstd -O encryption=aes-256-gcm -O keyformat=hex -O keylocation=file:///root/zfs.key -m /zfs/scratch
# media datasets
sudo zfs create media/temp -o sync=disabled -o redundant_metadata=none
# The two encrypted parent datasets have to exist before zfs_manager can create
# anything under them, since encryption cannot be set after creation.
# These will be removed if/when the media and storage pools are encrypted in the future.
sudo zfs create media/secure -o encryption=aes-256-gcm -o keyformat=hex -o keylocation=file:///root/zfs.key
sudo zfs create media/secure/docker -o compression=zstd-9
sudo zfs create media/secure/github-runners -o compression=zstd-9 -o sync=disabled
sudo zfs create media/secure/home_assistant -o compression=zstd-19
sudo zfs create media/secure/notes -o copies=2
sudo zfs create media/secure/postgres -o mountpoint=/zfs/media/database/postgres -o recordsize=16k -o primarycache=metadata
sudo zfs create media/secure/postgres-wal -o mountpoint=/zfs/media/database/postgres-wal -o recordsize=32k -o primarycache=metadata -o special_small_blocks=32K -o compression=lz4 -o secondarycache=none -o logbias=latency
sudo zfs create media/secure/prometheus -o mountpoint=/zfs/media/database/prometheus -o compression=lz4
sudo zfs create media/secure/services -o compression=zstd-9
sudo zfs create media/secure/share -o mountpoint=/zfs/media/share -o exec=off
# scratch datasets
sudo zfs create scratch/kafka -o mountpoint=/zfs/scratch/kafka -o recordsize=1M
sudo zfs create scratch/transmission -o mountpoint=/zfs/scratch/transmission -o recordsize=16k -o sync=disabled -o redundant_metadata=none
sudo zfs create scratch/uv_cache -o mountpoint=/zfs/scratch/uv_cache
# storage datasets
sudo zfs create storage/ollama -o recordsize=1M -o compression=zstd-19 -o sync=disabled
sudo zfs create storage/secure -o encryption=aes-256-gcm -o keyformat=hex -o keylocation=file:///root/zfs.key
sudo zfs create storage/secure/archive -o recordsize=1M -o compression=zstd-19
sudo zfs create storage/secure/library -o recordsize=1M -o compression=zstd-19
sudo zfs create storage/secure/main -o compression=zstd-19
sudo zfs create storage/secure/photos -o recordsize=16K -o compression=zstd-19 -o copies=2
sudo zfs create storage/secure/plex -o recordsize=1M -o compression=zstd-19
sudo zfs create storage/secure/secrets -o compression=zstd-19 -o copies=3
sudo zfs create storage/secure/syncthing -o compression=zstd-19
sudo zfs create storage/secure/transmission -o recordsize=1M -o compression=zstd-9 -o exec=off -o sync=disabled
sudo zfs create storage/secure/important -o compression=zstd-19 -o copies=2 -o mountpoint=/zfs/storage/important
-129
View File
@@ -1,129 +0,0 @@
["default"]
15_min = 8
hourly = 24
daily = 0
monthly = 0
# root_pool
["root_pool/home"]
15_min = 8
hourly = 24
daily = 14
monthly = 0
["root_pool/root"]
15_min = 8
hourly = 24
daily = 0
monthly = 0
["root_pool/nix"]
15_min = 4
hourly = 0
daily = 0
monthly = 0
["root_pool/var"]
15_min = 8
hourly = 24
daily = 30
monthly = 6
# storage
["storage/ollama"]
15_min = 2
hourly = 0
daily = 0
monthly = 0
["storage/secure"]
15_min = 0
hourly = 0
daily = 0
monthly = 0
["storage/secure/plex"]
15_min = 6
hourly = 2
daily = 1
monthly = 0
["storage/secure/transmission"]
15_min = 4
hourly = 0
daily = 0
monthly = 0
["storage/secure/secrets"]
15_min = 8
hourly = 24
daily = 30
monthly = 12
# media
["media/temp"]
15_min = 2
hourly = 0
daily = 0
monthly = 0
["media/secure"]
15_min = 0
hourly = 0
daily = 0
monthly = 0
["media/secure/plex"]
15_min = 6
hourly = 2
daily = 1
monthly = 0
["media/secure/postgres-wal"]
15_min = 4
hourly = 2
daily = 0
monthly = 0
["media/secure/postgres"]
15_min = 8
hourly = 24
daily = 7
monthly = 0
["media/secure/share"]
15_min = 4
hourly = 0
daily = 0
monthly = 0
["media/secure/github-runners"]
15_min = 6
hourly = 2
daily = 1
monthly = 0
["media/secure/notes"]
15_min = 8
hourly = 24
daily = 30
monthly = 12
["media/secure/docker"]
15_min = 3
hourly = 12
daily = 14
monthly = 2
# scratch
["scratch/transmission"]
15_min = 2
hourly = 0
daily = 0
monthly = 0
["scratch/uv_cache"]
15_min = 2
hourly = 0
daily = 0
monthly = 0
+18 -18
View File
@@ -1,22 +1,22 @@
# Paths are derived from the dataset declarations in ./datasets.nix so that a
# mountpoint only ever has to change in one place.
let
zfs_media = "/zfs/media";
zfs_storage = "/zfs/storage";
zfs_scratch = "/zfs/scratch";
inherit (import ./datasets.nix) mountpoints;
in
{
inherit zfs_media zfs_storage zfs_scratch;
database = "${zfs_media}/database";
docker = "${zfs_media}/docker";
docker_configs = "${zfs_media}/docker/configs";
home_assistant = "${zfs_media}/home_assistant";
notes = "${zfs_media}/notes";
secrets = "${zfs_storage}/secrets";
services = "${zfs_media}/services";
share = "${zfs_media}/share";
syncthing = "${zfs_storage}/syncthing";
transmission = "${zfs_storage}/transmission";
ollama = "${zfs_storage}/ollama";
transmission_scratch = "${zfs_scratch}/transmission";
uv_cache = "${zfs_scratch}/uv_cache";
kafka = "${zfs_scratch}/kafka";
# Not a dataset of its own, it is the directory the postgres datasets share.
database = "/zfs/media/database";
docker = mountpoints."media/secure/docker";
docker_configs = "${mountpoints."media/secure/docker"}/configs";
home_assistant = mountpoints."media/secure/home_assistant";
notes = mountpoints."media/secure/notes";
secrets = mountpoints."storage/secure/secrets";
services = mountpoints."media/secure/services";
share = mountpoints."media/secure/share";
syncthing = mountpoints."storage/secure/syncthing";
transmission = mountpoints."storage/secure/transmission";
ollama = mountpoints."storage/ollama";
transmission_scratch = mountpoints."scratch/transmission";
uv_cache = mountpoints."scratch/uv_cache";
kafka = mountpoints."scratch/kafka";
}
+14
View File
@@ -0,0 +1,14 @@
{ inputs, ... }:
let
vars = import ./vars.nix;
jeeves_zfs = import ./datasets.nix;
in
{
services.zfs_manager = {
enable = true;
PYTHONPATH = "${inputs.self}/";
EnvironmentFile = "${vars.secrets}/services/snapshot_manager";
inherit (jeeves_zfs) datasets defaultSnapshots;
};
}
+242
View File
@@ -0,0 +1,242 @@
"""test_zfs_manager."""
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from python.tools.zfs_manager import main, parse_size, values_match
if TYPE_CHECKING:
from pyfakefs.fake_filesystem import FakeFilesystem
from pytest_mock import MockerFixture
ZFS_MANAGER = "python.tools.zfs_manager"
CONFIG_PATH = "/mock_zfs_datasets.json"
def write_config(
fs: FakeFilesystem,
datasets: dict[str, dict[str, str]],
unmanaged: list[str] | None = None,
) -> Path:
"""Write a dataset declaration to the fake filesystem."""
contents = {
"datasets": {name: {"manageProperties": True, "properties": props} for name, props in datasets.items()}
| {name: {"manageProperties": False, "properties": {}} for name in unmanaged or []},
}
fs.create_file(CONFIG_PATH, contents=json.dumps(contents))
return Path(CONFIG_PATH)
def patch_zfs(
mocker: MockerFixture,
existing: list[str],
properties: dict[str, dict[str, tuple[str, str]]] | None = None,
) -> dict[str, object]:
"""Patch every zfs call zfs_manager makes."""
return {
"list": mocker.patch(f"{ZFS_MANAGER}.list_dataset_names", return_value=existing),
"get": mocker.patch(f"{ZFS_MANAGER}.get_properties", side_effect=lambda name: (properties or {}).get(name, {})),
"create": mocker.patch(f"{ZFS_MANAGER}.create_dataset", return_value=None),
"set": mocker.patch(f"{ZFS_MANAGER}.set_property", return_value=None),
"alert": mocker.patch(f"{ZFS_MANAGER}.signal_alert"),
}
def test_creates_missing_dataset(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(mocker, existing=["media", "media/secure"])
config = write_config(fs, {"media/secure/new": {"compression": "zstd-9"}})
main(config)
zfs["create"].assert_called_once_with("media/secure/new", {"compression": "zstd-9"})
zfs["set"].assert_not_called()
zfs["alert"].assert_not_called()
def test_sets_drifted_property(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(
mocker,
existing=["media", "media/temp"],
properties={"media/temp": {"compression": ("zstd", "inherited from media")}},
)
config = write_config(fs, {"media/temp": {"compression": "zstd-9"}})
main(config)
zfs["set"].assert_called_once_with("media/temp", "compression", "zstd-9")
zfs["create"].assert_not_called()
def test_no_op_when_in_sync(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(
mocker,
existing=["media", "media/temp"],
properties={"media/temp": {"sync": ("disabled", "local")}},
)
config = write_config(fs, {"media/temp": {"sync": "disabled"}})
main(config)
zfs["set"].assert_not_called()
zfs["create"].assert_not_called()
zfs["alert"].assert_not_called()
def test_size_property_does_not_churn(mocker: MockerFixture, fs: FakeFilesystem) -> None:
"""zfs get -p reports recordsize in bytes, the declaration uses a suffix."""
zfs = patch_zfs(
mocker,
existing=["media", "media/db"],
properties={"media/db": {"recordsize": ("16384", "local"), "special_small_blocks": ("32768", "local")}},
)
config = write_config(fs, {"media/db": {"recordsize": "16k", "special_small_blocks": "32K"}})
main(config)
zfs["set"].assert_not_called()
def test_create_only_property_alerts_instead_of_setting(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(
mocker,
existing=["media", "media/secure"],
properties={"media/secure": {"encryption": ("aes-256-gcm", "local")}},
)
config = write_config(fs, {"media/secure": {"encryption": "off"}})
main(config)
zfs["set"].assert_not_called()
assert zfs["alert"].call_count == 1
assert "can only be set when the dataset is created" in zfs["alert"].call_args.args[0]
def test_undeclared_local_property_warns(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(
mocker,
existing=["media", "media/temp"],
properties={"media/temp": {"exec": ("off", "local")}},
)
config = write_config(fs, {"media/temp": {}})
main(config)
zfs["alert"].assert_called_once_with("media/temp has exec=off set outside of nix")
def test_undeclared_inherited_property_is_silent(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(
mocker,
existing=["media", "media/temp"],
properties={
"media/temp": {
"compression": ("zstd", "inherited from media"),
"exec": ("on", "default"),
"nixos:shutdown-time": ("whenever", "local"),
},
},
)
config = write_config(fs, {"media/temp": {}})
main(config)
zfs["alert"].assert_not_called()
def test_dry_run_makes_no_changes(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(
mocker,
existing=["media", "media/temp"],
properties={"media/temp": {"compression": ("zstd", "local")}},
)
config = write_config(fs, {"media/temp": {"compression": "zstd-9"}, "media/new": {}})
main(config, dry_run=True)
zfs["set"].assert_not_called()
zfs["create"].assert_not_called()
def test_pool_root_is_never_created(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(mocker, existing=[])
config = write_config(fs, {"media": {"atime": "off"}})
main(config)
zfs["create"].assert_not_called()
assert "does not create pools" in zfs["alert"].call_args.args[0]
def test_children_skipped_when_parent_creation_fails(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(mocker, existing=["media"])
zfs["create"].return_value = "Failed to create media/secure: key not loaded"
config = write_config(fs, {"media/secure": {}, "media/secure/child": {}})
main(config)
zfs["create"].assert_called_once_with("media/secure", {})
def test_unmanaged_dataset_properties_are_untouched(mocker: MockerFixture, fs: FakeFilesystem) -> None:
"""A snapshots-only dataset is neither reconciled nor reported as unknown."""
zfs = patch_zfs(
mocker,
existing=["root_pool", "root_pool/var"],
properties={"root_pool/var": {"compression": ("lz4", "local")}},
)
config = write_config(fs, {}, unmanaged=["root_pool", "root_pool/var"])
main(config)
zfs["get"].assert_not_called()
zfs["set"].assert_not_called()
zfs["alert"].assert_not_called()
def test_undeclared_dataset_is_left_alone(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(mocker, existing=["media", "media/undeclared"])
config = write_config(fs, {})
main(config)
zfs["create"].assert_not_called()
zfs["set"].assert_not_called()
zfs["alert"].assert_not_called()
def test_main_exception(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(mocker, existing=[])
zfs["list"].side_effect = Exception("test")
config = write_config(fs, {})
with pytest.raises(SystemExit) as pytest_wrapped_e:
main(config)
assert pytest_wrapped_e.value.code == 1
zfs["alert"].assert_called_once_with("zfs_manager failed")
@pytest.mark.parametrize(
("value", "expected"),
[
("16384", 16384),
("16k", 16384),
("16K", 16384),
("1M", 1048576),
("none", None),
("", None),
],
)
def test_parse_size(value, expected) -> None:
assert parse_size(value) == expected
def test_values_match_falls_back_to_string_for_unparsable_sizes() -> None:
assert not values_match("recordsize", "none", "16384")
assert values_match("recordsize", "none", "none")
assert not values_match("compression", "zstd", "zstd-9")