Files
computer-playbook/tests/unit/python/utils/recovery/test_role_recover_scripts.py
T
kevinveenbirkenbachandClaude Opus 5 575a5a87ac refactor(layout): sort shipped scripts by language and split the unit suite
Every script a role ships now lives under `files/<language>/`, and the unit
suite is split the same way: `tests/unit/python/` beside new sibling trees for
the other languages. `tests/lint/filesystem/test_role_files_layout.py` keeps it
that way; directories whose shape is dictated from outside carry a `.nocheck`
naming the constraint (Docker build contexts, WordPress mu-plugins, the Joomla
extension, Odoo addons, Playwright suites, the `files/test/test.sh` harness
that `test-e2e-cli` resolves generically).

This cannot be applied in halves. Moving a file and repointing what names it
are the same change: 14 references resolve only at deploy time, so the tree is
green either before or after, never between.

Those 14 were found by static resolution over every reference class, not by the
test suite -- a full `make quality-high` was green while all of them were
broken:

  * 7 `src:` values built from a role variable holding a bare filename
    (svc-bkp-remote-2-local, decidim, espocrm, friendica, joomla, pixelfed x2).
    The variable doubles as the destination name inside the container, so the
    language segment belongs at the call site, not in the variable.
  * `sys-service/tasks/04_base.yml` resolved every consumer's systemd script
    through `first_found(..., errors='strict')` over `files/script.{sh,py}`.
    18 roles ship exactly that and have no template fallback: the play would
    have died on each.
  * `sys-front-inj-javascript` fell back to `<role>/files/javascript.js`; the
    interface now reads `files/javascript/javascript.js`.
  * `sys-front-inj-logout/templates/head_sub.j2` built the cache-busting mtime
    path through `path_absolute(... 'files', ...)`.
  * 4 `ansible.builtin.script:` invocations naming a bare command
    (pull.py, prune.sh, set_postgres_superuser_password.sh, ganesha-serve.sh).
  * `dev-python/files/shell/install.sh` pointed at a sibling role's script.

Depth-dependent paths inside the moved trees were bumped accordingly: 59
`parents[N]` computations, 12 relative climbs, and the per-role recover CLI,
whose path `cli/administration/recover/recoverers.py` still assembled from the
pre-move location.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:31:09 +02:00

354 lines
14 KiB
Python

"""Unit tests for the per-role ``files/python/recover.py`` CLIs that wrap
DirectoryRecovery: unit_pattern, the class behaviour (docker mountpoint
resolution, export mirror, snapshot selection across machine hashes,
service-backup-less device recovery) and the ``main()`` CLI wiring
(argparse -> Recovery construction -> run, incl. the LUKS orchestration
and its umount/luksClose cleanup)."""
from __future__ import annotations
import importlib.util
import sys
import tempfile
import unittest
import unittest.mock as mock
from pathlib import Path
from . import PROJECT_ROOT
def _load(role: str, name: str):
path = PROJECT_ROOT / "roles" / role / "files" / "python" / "recover.py"
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
class UnitPatternTests(unittest.TestCase):
def test_each_subclass_declares_its_role_unit(self):
cases = {
(
"svc-bkp-volume-2-local",
"VolumeRecovery",
): "svc-bkp-volume-2-local*.service",
(
"svc-bkp-nfs-2-local",
"NfsExportRecovery",
): "svc-bkp-nfs-2-local*.service",
(
"svc-bkp-local-2-device",
"DeviceRecovery",
): "svc-bkp-local-2-device*.service",
}
for (role, cls), pattern in cases.items():
mod = _load(role, role.replace("-", "_"))
self.assertEqual(getattr(mod, cls).unit_pattern, pattern)
class VolumeRecoverTests(unittest.TestCase):
def test_resolves_mountpoint_and_threads_flag(self):
mod = _load("svc-bkp-volume-2-local", "volume_recover")
with tempfile.TemporaryDirectory() as td:
source = Path(td) / "source"
source.mkdir()
mount = Path(td) / "mount"
mount.mkdir()
with mock.patch.object(
mod.subprocess,
"run",
side_effect=[
mock.Mock(stdout=f"{mount}\n"),
mock.Mock(stdout="null\n"),
],
) as run:
rec = mod.VolumeRecovery(str(source), "myvol", service_backup=False)
cmd = run.call_args_list[0].args[0]
self.assertEqual(cmd[:4], ["docker", "volume", "inspect", "--format"])
self.assertEqual(cmd[-1], "myvol")
self.assertEqual(rec.target_dir, mount)
self.assertFalse(rec.service_backup)
def test_refuses_unmounted_backing_store(self):
mod = _load("svc-bkp-volume-2-local", "volume_recover")
with tempfile.TemporaryDirectory() as td:
source = Path(td) / "source"
source.mkdir()
mount = Path(td) / "mount"
mount.mkdir()
options = '{"device":":/export/x","o":"addr=10.0.0.1","type":"nfs"}'
with (
mock.patch.object(
mod.subprocess,
"run",
side_effect=[
mock.Mock(stdout=f"{mount}\n"),
mock.Mock(stdout=f"{options}\n"),
mock.Mock(returncode=1),
],
) as run,
self.assertRaisesRegex(SystemExit, "not mounted"),
):
mod.VolumeRecovery(str(source), "myvol", service_backup=False)
self.assertEqual(
run.call_args_list[2].args[0], ["mountpoint", "-q", str(mount)]
)
def test_accepts_mounted_backing_store(self):
mod = _load("svc-bkp-volume-2-local", "volume_recover")
with tempfile.TemporaryDirectory() as td:
source = Path(td) / "source"
source.mkdir()
mount = Path(td) / "mount"
mount.mkdir()
options = '{"device":":/export/x","o":"addr=10.0.0.1","type":"nfs"}'
with mock.patch.object(
mod.subprocess,
"run",
side_effect=[
mock.Mock(stdout=f"{mount}\n"),
mock.Mock(stdout=f"{options}\n"),
mock.Mock(returncode=0),
],
):
rec = mod.VolumeRecovery(str(source), "myvol", service_backup=False)
self.assertEqual(rec.target_dir, mount)
def test_probes_remote_mount_over_ssh(self):
mod = _load("svc-bkp-volume-2-local", "volume_recover")
with tempfile.TemporaryDirectory() as td:
source = Path(td) / "source"
source.mkdir()
options = '{"device":":/export/x","type":"nfs"}'
with (
mock.patch.object(
mod.subprocess,
"run",
side_effect=[
mock.Mock(stdout="/var/lib/docker/volumes/x/_data\n"),
mock.Mock(stdout=f"{options}\n"),
mock.Mock(returncode=1),
],
) as run,
self.assertRaisesRegex(SystemExit, "not mounted"),
):
mod.VolumeRecovery(
str(source),
"myvol",
service_backup=False,
docker_host="ssh://root@node-1",
)
self.assertEqual(
run.call_args_list[2].args[0],
[
"ssh",
"root@node-1",
"mountpoint",
"-q",
"/var/lib/docker/volumes/x/_data",
],
)
def test_main_wires_argparse_to_recovery(self):
mod = _load("svc-bkp-volume-2-local", "volume_recover")
for argv, expect_backup in (
(["recover.py", "/snap", "vol"], True),
(["recover.py", "/snap", "vol", "--no-safety-backup"], False),
):
with (
mock.patch.object(mod, "VolumeRecovery") as mock_vol,
mock.patch.object(sys, "argv", argv),
):
mock_vol.return_value.run.return_value = 0
self.assertEqual(mod.main(), 0)
mock_vol.assert_called_once_with(
"/snap", "vol", service_backup=expect_backup, docker_host=None
)
mock_vol.return_value.run.assert_called_once_with()
class NfsRecoverTests(unittest.TestCase):
def test_restore_mirrors_snapshot_into_target(self):
mod = _load("svc-bkp-nfs-2-local", "nfs_recover")
with tempfile.TemporaryDirectory() as td:
source = Path(td) / "source"
target = Path(td) / "target"
source.mkdir()
target.mkdir()
(source / "restored.txt").write_text("from-snapshot")
(target / "stale.txt").write_text("pre-recover")
mod.NfsExportRecovery(
str(source), str(target), service_backup=False
).restore()
self.assertEqual(
(
target / "restored.txt"
).read_text(), # nocheck: cache-read - tempdir fixture
"from-snapshot",
)
self.assertFalse((target / "stale.txt").exists())
def test_main_wires_argparse_to_recovery(self):
mod = _load("svc-bkp-nfs-2-local", "nfs_recover")
for argv, expect_backup in (
(["recover.py", "/snap", "/export"], True),
(["recover.py", "/snap", "/export", "--no-safety-backup"], False),
):
with (
mock.patch.object(mod, "NfsExportRecovery") as mock_nfs,
mock.patch.object(sys, "argv", argv),
):
mock_nfs.return_value.run.return_value = 0
self.assertEqual(mod.main(), 0)
mock_nfs.assert_called_once_with(
"/snap", "/export", service_backup=expect_backup
)
mock_nfs.return_value.run.assert_called_once_with()
class DeviceRecoverTests(unittest.TestCase):
def _snap(self, mount: Path, machine_hash: str, ts: str) -> None:
(mount / machine_hash / "svc-bkp-local-2-device" / ts).mkdir(parents=True)
def test_newest_snapshot_picks_latest_across_hashes(self):
mod = _load("svc-bkp-local-2-device", "device_recover")
with tempfile.TemporaryDirectory() as td:
mount = Path(td)
self._snap(mount, "hashA", "20240101000000")
self._snap(mount, "hashA", "20240103000000")
self._snap(mount, "hashB", "20240102000000")
self.assertEqual(mod._newest_snapshot(mount, None).name, "20240103000000")
def test_newest_snapshot_honours_explicit_timestamp(self):
mod = _load("svc-bkp-local-2-device", "device_recover")
with tempfile.TemporaryDirectory() as td:
mount = Path(td)
self._snap(mount, "hashA", "20240101000000")
self._snap(mount, "hashB", "20240103000000")
self.assertEqual(
mod._newest_snapshot(mount, "20240101000000").name, "20240101000000"
)
def test_newest_snapshot_refuses_when_absent(self):
mod = _load("svc-bkp-local-2-device", "device_recover")
with tempfile.TemporaryDirectory() as td:
mount = Path(td)
with self.assertRaises(SystemExit):
mod._newest_snapshot(mount, None)
self._snap(mount, "hashA", "20240101000000")
with self.assertRaises(SystemExit):
mod._newest_snapshot(mount, "does-not-exist")
def test_device_recovery_never_service_backs_up(self):
mod = _load("svc-bkp-local-2-device", "device_recover")
with tempfile.TemporaryDirectory() as td:
source = Path(td) / "source"
target = Path(td) / "target"
source.mkdir()
target.mkdir()
self.assertFalse(
mod.DeviceRecovery(str(source), str(target)).service_backup
)
def test_main_luks_flow_stdin_passphrase_and_cleanup(self):
mod = _load("svc-bkp-local-2-device", "device_recover")
with tempfile.TemporaryDirectory() as td:
mount = Path(td) / "mnt"
target = Path(td) / "target"
target.mkdir()
snapshot = Path(td) / "snap"
calls: list[list[str]] = []
with (
mock.patch.object(
mod.subprocess,
"run",
side_effect=lambda cmd, **kw: calls.append(cmd),
),
mock.patch.object(mod, "_newest_snapshot", return_value=snapshot),
mock.patch.object(mod, "DeviceRecovery") as mock_dev,
mock.patch.object(
sys,
"argv",
[
"recover.py",
"dev.img",
str(mount),
str(target),
"--passphrase-stdin",
],
),
):
mock_dev.return_value.run.return_value = 0
self.assertEqual(mod.main(), 0)
mock_dev.assert_called_once_with(str(snapshot / "backup"), str(target))
luks_open = next(c for c in calls if c[:2] == ["cryptsetup", "luksOpen"])
self.assertIn("--key-file=-", luks_open)
self.assertTrue(any(c[0] == "mount" for c in calls))
self.assertTrue(any(c[:2] == ["umount", str(mount)] for c in calls))
self.assertTrue(any(c[:2] == ["cryptsetup", "luksClose"] for c in calls))
def test_main_device_target_scopes_the_snapshot_root(self):
mod = _load("svc-bkp-local-2-device", "device_recover")
with tempfile.TemporaryDirectory() as td:
mount = Path(td) / "mnt"
target = Path(td) / "target"
target.mkdir()
with (
mock.patch.object(mod.subprocess, "run"),
mock.patch.object(
mod, "_newest_snapshot", return_value=Path(td) / "snap"
) as newest,
mock.patch.object(mod, "DeviceRecovery") as mock_dev,
mock.patch.object(
sys,
"argv",
[
"recover.py",
"dev.img",
str(mount),
str(target),
"--device-target",
"/infinito",
],
),
):
mock_dev.return_value.run.return_value = 0
self.assertEqual(mod.main(), 0)
self.assertEqual(newest.call_args.args[0], mount / "infinito")
def test_main_cleans_up_even_when_recover_raises(self):
mod = _load("svc-bkp-local-2-device", "device_recover")
with tempfile.TemporaryDirectory() as td:
mount = Path(td) / "mnt"
target = Path(td) / "target"
target.mkdir()
calls: list[list[str]] = []
with (
mock.patch.object(
mod.subprocess,
"run",
side_effect=lambda cmd, **kw: calls.append(cmd),
),
mock.patch.object(
mod, "_newest_snapshot", return_value=Path(td) / "snap"
),
mock.patch.object(
mod, "DeviceRecovery", side_effect=RuntimeError("boom")
),
mock.patch.object(
sys, "argv", ["recover.py", "dev.img", str(mount), str(target)]
),
self.assertRaises(RuntimeError),
):
mod.main()
self.assertTrue(any(c[:2] == ["umount", str(mount)] for c in calls))
self.assertTrue(any(c[:2] == ["cryptsetup", "luksClose"] for c in calls))
if __name__ == "__main__":
unittest.main()