Files
dotfiles/tests/test_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

383 lines
13 KiB
Python

"""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,
never_create: list[str] | None = None,
) -> Path:
"""Write a dataset declaration to the fake filesystem."""
contents = {
"datasets": {
name: {
"manageProperties": True,
"createIfMissing": name not in (never_create or []),
"properties": props,
}
for name, props in datasets.items()
}
| {name: {"manageProperties": False, "createIfMissing": True, "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"}})
# Nothing can fix a create-only mismatch at runtime, so it fails the run.
with pytest.raises(SystemExit) as exit_info:
main(config)
assert exit_info.value.code == 1
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"}})
with pytest.raises(SystemExit) as exit_info:
main(config)
assert exit_info.value.code == 1
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": {}})
with pytest.raises(SystemExit) as exit_info:
main(config)
assert exit_info.value.code == 1
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")
# -- failure handling: every one of these must exit non-zero -------------------
def test_dataset_listing_failure_exits_nonzero(mocker: MockerFixture, fs: FakeFilesystem) -> None:
"""A failed zfs list must abort, never be read as an empty system.
bash_wrapper hands back stderr as though it were output, so without the
return code check the reconciler would treat the error text as the dataset
list and conclude every declared dataset was missing.
"""
zfs = patch_zfs(mocker, existing=[])
zfs["list"].side_effect = RuntimeError("Failed to list ZFS datasets: pool is busy")
config = write_config(fs, {"media/temp": {}})
with pytest.raises(SystemExit) as exit_info:
main(config)
assert exit_info.value.code == 1
zfs["create"].assert_not_called()
zfs["alert"].assert_called_once_with("zfs_manager failed")
def test_create_failure_exits_nonzero(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(mocker, existing=["media"])
zfs["create"].return_value = "Failed to create media/temp: out of space"
config = write_config(fs, {"media/temp": {}})
with pytest.raises(SystemExit) as exit_info:
main(config)
assert exit_info.value.code == 1
assert "out of space" in zfs["alert"].call_args.args[0]
def test_set_failure_exits_nonzero(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(
mocker,
existing=["media", "media/temp"],
properties={"media/temp": {"compression": ("zstd", "local")}},
)
zfs["set"].return_value = "Failed to set compression=zstd-9 on media/temp: permission denied"
config = write_config(fs, {"media/temp": {"compression": "zstd-9"}})
with pytest.raises(SystemExit) as exit_info:
main(config)
assert exit_info.value.code == 1
assert "permission denied" in zfs["alert"].call_args.args[0]
def test_every_dataset_is_checked_before_failing(mocker: MockerFixture, fs: FakeFilesystem) -> None:
"""One broken dataset must not hide the state of the others."""
zfs = patch_zfs(
mocker,
existing=["media", "media/one", "media/two", "media/three"],
properties={
"media/one": {"compression": ("zstd", "local")},
"media/two": {"compression": ("zstd", "local")},
"media/three": {"compression": ("zstd", "local")},
},
)
zfs["set"].return_value = "Failed to set compression: permission denied"
config = write_config(
fs,
{name: {"compression": "zstd-9"} for name in ("media/one", "media/two", "media/three")},
)
with pytest.raises(SystemExit) as exit_info:
main(config)
assert exit_info.value.code == 1
# All three were attempted and all three were reported, not just the first.
assert zfs["set"].call_count == 3
assert zfs["alert"].call_count == 3
def test_dataset_marked_as_externally_created_is_never_created(mocker: MockerFixture, fs: FakeFilesystem) -> None:
"""An encryption root must be reported as missing, not silently recreated.
Recreating it from this declaration would produce an unencrypted dataset,
since encryption is fixed at creation and is not declared here.
"""
zfs = patch_zfs(mocker, existing=["media"])
config = write_config(fs, {"media/secure": {}}, never_create=["media/secure"])
with pytest.raises(SystemExit) as exit_info:
main(config)
assert exit_info.value.code == 1
zfs["create"].assert_not_called()
assert "created outside of nix" in zfs["alert"].call_args.args[0]
def test_externally_created_dataset_is_still_property_checked(mocker: MockerFixture, fs: FakeFilesystem) -> None:
"""When it does exist, it is reconciled like anything else."""
zfs = patch_zfs(
mocker,
existing=["media", "media/secure"],
properties={"media/secure": {"keylocation": ("prompt", "local")}},
)
config = write_config(
fs,
{"media/secure": {"keylocation": "file:///root/zfs.key"}},
never_create=["media/secure"],
)
main(config)
zfs["set"].assert_called_once_with("media/secure", "keylocation", "file:///root/zfs.key")
def test_success_exits_cleanly(mocker: MockerFixture, fs: FakeFilesystem) -> None:
"""The happy path must not raise SystemExit at all."""
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["alert"].assert_not_called()