feat(zfs): manage jeeves datasets declaratively from nix #47
@@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -1,11 +1,23 @@
|
|||||||
"""init."""
|
"""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
|
from python.zfs.zpool import Zpool
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Dataset",
|
"Dataset",
|
||||||
"Snapshot",
|
"Snapshot",
|
||||||
"Zpool",
|
"Zpool",
|
||||||
|
"create_dataset",
|
||||||
"get_datasets",
|
"get_datasets",
|
||||||
|
"get_properties",
|
||||||
|
"list_dataset_names",
|
||||||
|
"set_property",
|
||||||
]
|
]
|
||||||
|
|||||||
+78
-3
@@ -207,8 +207,83 @@ def get_datasets() -> list[Dataset]:
|
|||||||
"""
|
"""
|
||||||
logger.info("Getting zfs list")
|
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")
|
dataset_names, _ = bash_wrapper("zfs list -Hp -t filesystem -o name")
|
||||||
|
|
||||||
cleaned_datasets = dataset_names.strip().split("\n")
|
return [name for name in dataset_names.strip().split("\n") if name]
|
||||||
|
|
||||||
return [Dataset(dataset_name) for dataset_name in cleaned_datasets if "/" in dataset_name]
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ in
|
|||||||
"${inputs.self}/common/optional/syncthing_base.nix"
|
"${inputs.self}/common/optional/syncthing_base.nix"
|
||||||
"${inputs.self}/common/optional/update.nix"
|
"${inputs.self}/common/optional/update.nix"
|
||||||
"${inputs.self}/common/optional/zerotier.nix"
|
"${inputs.self}/common/optional/zerotier.nix"
|
||||||
|
"${inputs.self}/common/optional/zfs_manager.nix"
|
||||||
./monitoring
|
./monitoring
|
||||||
./docker
|
./docker
|
||||||
./services
|
./services
|
||||||
@@ -24,6 +25,7 @@ in
|
|||||||
./programs.nix
|
./programs.nix
|
||||||
./runners
|
./runners
|
||||||
./syncthing.nix
|
./syncthing.nix
|
||||||
|
./zfs.nix
|
||||||
];
|
];
|
||||||
|
|
||||||
services = {
|
services = {
|
||||||
@@ -31,10 +33,8 @@ in
|
|||||||
|
|
||||||
smartd.enable = true;
|
smartd.enable = true;
|
||||||
|
|
||||||
snapshot_manager = {
|
# path is generated from ./zfs.nix by common/optional/zfs_manager.nix
|
||||||
path = ./snapshot_config.toml;
|
snapshot_manager.EnvironmentFile = "${vars.secrets}/services/snapshot_manager";
|
||||||
EnvironmentFile = "${vars.secrets}/services/snapshot_manager";
|
|
||||||
};
|
|
||||||
|
|
||||||
zerotierone.joinNetworks = [ "a09acf02330d37b9" ];
|
zerotierone.joinNetworks = [ "a09acf02330d37b9" ];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
#!/bin/bash
|
#!/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
|
# zpools
|
||||||
|
|
||||||
# media
|
# media
|
||||||
@@ -12,35 +17,10 @@ sudo zpool add storage -o ashift=12 special mirror
|
|||||||
sudo zpool add storage -o ashift=12 logs mirror
|
sudo zpool add storage -o ashift=12 logs mirror
|
||||||
|
|
||||||
# scratch
|
# 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
|
# The two encrypted parent datasets have to exist before zfs_manager can create
|
||||||
sudo zfs create media/temp -o sync=disabled -o redundant_metadata=none
|
# 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 -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 -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
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
let
|
||||||
zfs_media = "/zfs/media";
|
inherit (import ./datasets.nix) mountpoints;
|
||||||
zfs_storage = "/zfs/storage";
|
|
||||||
zfs_scratch = "/zfs/scratch";
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
inherit zfs_media zfs_storage zfs_scratch;
|
# Not a dataset of its own, it is the directory the postgres datasets share.
|
||||||
database = "${zfs_media}/database";
|
database = "/zfs/media/database";
|
||||||
docker = "${zfs_media}/docker";
|
docker = mountpoints."media/secure/docker";
|
||||||
docker_configs = "${zfs_media}/docker/configs";
|
docker_configs = "${mountpoints."media/secure/docker"}/configs";
|
||||||
home_assistant = "${zfs_media}/home_assistant";
|
home_assistant = mountpoints."media/secure/home_assistant";
|
||||||
notes = "${zfs_media}/notes";
|
notes = mountpoints."media/secure/notes";
|
||||||
secrets = "${zfs_storage}/secrets";
|
secrets = mountpoints."storage/secure/secrets";
|
||||||
services = "${zfs_media}/services";
|
services = mountpoints."media/secure/services";
|
||||||
share = "${zfs_media}/share";
|
share = mountpoints."media/secure/share";
|
||||||
syncthing = "${zfs_storage}/syncthing";
|
syncthing = mountpoints."storage/secure/syncthing";
|
||||||
transmission = "${zfs_storage}/transmission";
|
transmission = mountpoints."storage/secure/transmission";
|
||||||
ollama = "${zfs_storage}/ollama";
|
ollama = mountpoints."storage/ollama";
|
||||||
transmission_scratch = "${zfs_scratch}/transmission";
|
transmission_scratch = mountpoints."scratch/transmission";
|
||||||
uv_cache = "${zfs_scratch}/uv_cache";
|
uv_cache = mountpoints."scratch/uv_cache";
|
||||||
kafka = "${zfs_scratch}/kafka";
|
kafka = mountpoints."scratch/kafka";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user