Files
dotfiles/python/tools/zfs_manager.py
T
Richie 0398e5e878 Implement ZFS dataset management and snapshot configuration
- Introduced a new `datasets.nix` file to define ZFS datasets and their properties, allowing for centralized management of dataset configurations.
- Updated `default.nix` to import and enable the `zfs_manager` service, integrating it into the system configuration.
- Refactored `zfs.sh` to remove dataset creation commands, delegating dataset management to the `zfs_manager` service.
- Removed the legacy `snapshot_config.toml` file, as snapshot configurations are now handled within `datasets.nix`.
- Modified `vars.nix` to derive mountpoint paths from `datasets.nix`, ensuring consistency across the configuration.
- Created a new `zfs.nix` file to define the `zfs_manager` service and its dependencies.
- Added comprehensive tests for the `zfs_manager` functionality, covering dataset creation, property management, and error handling.
2026-08-19 22:17:13 -04:00

341 lines
11 KiB
Python

"""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")
class ReconciliationError(RuntimeError):
"""One or more datasets could not be brought in line with the declaration."""
def __init__(self, failures: list[str]) -> None:
"""Record the individual failures behind this run's exit code."""
self.failures = failures
super().__init__(f"ZFS reconciliation failed with {len(failures)} errors")
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:
reconcile(config_file, dry_run=dry_run)
except ReconciliationError as error:
summary = error
except Exception:
logger.exception("zfs_manager failed")
signal_alert("zfs_manager failed")
sys.exit(1)
else:
logger.info("zfs_manager completed")
return
# Each failure was logged and alerted as it happened. Repeating them
# together puts the whole picture at the end of the journal, which is what
# systemctl status shows. No traceback: this is an expected outcome, not a
# crash, and a stack trace would only bury the list.
logger.error(str(summary))
for failure in summary.failures:
logger.error(f" {failure}")
sys.exit(1)
def reconcile(config_file: Path, *, dry_run: bool) -> None:
"""Bring every declared dataset in line, collecting problems as it goes.
One bad dataset must not hide the state of the others, so everything is
checked before anything is raised.
Args:
config_file (Path): The path to the generated dataset declaration.
dry_run (bool): Log the changes without making them.
Raises:
ReconciliationError: If anything could not be reconciled.
"""
declared = json.loads(config_file.read_text())["datasets"]
existing = set(list_dataset_names())
unusable: set[str] = set()
failures: list[str] = []
# 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)):
entry = declared[name]
# Declared purely to record retention, its properties belong to
# whoever set them.
if not entry.get("manageProperties", True):
logger.debug(f"{name} is declared but its properties are not managed")
continue
if has_unusable_parent(name, unusable):
failures.append(fail(f"cannot reconcile {name}, its parent is missing"))
continue
if name in existing:
failures.extend(reconcile_dataset(name, entry["properties"], dry_run=dry_run))
continue
created, failure = handle_missing_dataset(name, entry, dry_run=dry_run)
if failure is not None:
failures.append(failure)
if created:
existing.add(name)
elif not dry_run:
unusable.add(name)
report_undeclared_datasets(existing, declared)
if failures:
raise ReconciliationError(failures)
def fail(message: str) -> str:
"""Log and alert a problem, and hand it back for the failure tally.
Args:
message (str): What went wrong.
Returns:
str: The same message, so the caller can collect it.
"""
logger.error(message)
signal_alert(message)
return message
def has_unusable_parent(name: str, unusable: set[str]) -> bool:
"""Check whether an ancestor of a dataset is missing.
Args:
name (str): The name of the dataset.
unusable (set[str]): The datasets that do not exist and were not 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 handle_missing_dataset(name: str, entry: dict, *, dry_run: bool) -> tuple[bool, str | None]:
"""Deal with a declared dataset that is not on the system.
Pool roots are never created, and neither is anything the declaration marks
as provisioned outside of nix, such as an encryption root whose key
settings cannot be reproduced from the declaration.
Args:
name (str): The name of the dataset.
entry (dict): The declaration for this dataset.
dry_run (bool): Log the change without making it.
Returns:
tuple[bool, str | None]: Whether the dataset now exists, and a failure
message if there was one.
"""
properties = entry["properties"]
if "/" not in name:
return False, fail(f"pool {name} is declared but does not exist, zfs_manager does not create pools")
if not entry.get("createIfMissing", True):
return False, fail(
f"{name} is declared but does not exist, and is marked as created outside of nix. "
"It has to be made by hand, see systems/jeeves/scripts/zfs.sh.",
)
if dry_run:
logger.info(f"would create {name} with {properties}")
return False, None
logger.info(f"creating {name} with {properties}")
if error := create_dataset(name, properties):
return False, fail(error)
return True, None
def reconcile_dataset(name: str, properties: dict[str, str], *, dry_run: bool) -> list[str]:
"""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.
Returns:
list[str]: Anything that could not be put right.
"""
failures: list[str] = []
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:
# Nothing can put this right while the dataset exists, so it is a
# hard failure rather than a warning that repeats unnoticed.
failures.append(
fail(
f"{name} {key} is {current_value} but {wanted} is declared, "
f"{key} can only be set when the dataset is created",
),
)
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):
failures.append(fail(error))
report_undeclared_properties(name, properties, current)
return failures
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()