- 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.
383 lines
13 KiB
Python
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()
|