Files
computer-playbook/tests/unit/python/utils/recovery/test_role_recover_scripts.py
T
kevinveenbirkenbachandClaude Opus 5.5 b894db0a75 fix(svc-bkp-volume-2-local): restore into the resolved volume mountpoint
rsync 3.5.0 (Debian security update 3.5.0+ds1-0+deb13u1) refuses a
destination symlink owned by neither root nor the calling user.
svc-opt-ssd-hdd turns a volume's `_data` into a symlink to /mnt/hdd or
/mnt/ssd. The restore drill empties the volume and runs `compose create`;
docker repopulates it and lchowns the `_data` symlink to the image
directory's owner (e.g. www-data). The restore then failed with
"refusing to follow a symlink owned by an untrusted user" and
"change_dir#1 .../_data/ failed: Too many levels of symbolic links",
the drill left its subjects down, and the dependent checks timed out
(CI run 40979252521, ssd-hdd#0, on feature/store-i18n; recover.py is
identical on main).

VolumeRecovery now resolves the mountpoint before handing it to rsync:
os.path.realpath locally, `ssh <host> realpath -e -- <mountpoint>` for
--docker-host, so rsync writes into the real directory and never meets
the symlink. `--insecure-links` was not used because older rsync builds
on other distributions lack it. Two new unit tests (local symlinked
mountpoint, remote resolution); both fail against the previous code.

Root cause and fix by the i18n session (feature/store-i18n triage of
ssd-hdd#0), ported to main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:51:44 +01:00

413 lines
16 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_restores_into_the_target_of_a_symlinked_mountpoint(self):
mod = _load("svc-bkp-volume-2-local", "volume_recover")
with tempfile.TemporaryDirectory() as td:
source = Path(td) / "source"
source.mkdir()
moved = Path(td) / "hdd" / "myvol"
moved.mkdir(parents=True)
mount = Path(td) / "volumes" / "myvol" / "_data"
mount.parent.mkdir(parents=True)
mount.symlink_to(moved)
with mock.patch.object(
mod.subprocess,
"run",
side_effect=[
mock.Mock(stdout=f"{mount}\n"),
mock.Mock(stdout="null\n"),
],
):
rec = mod.VolumeRecovery(str(source), "myvol", service_backup=False)
self.assertEqual(rec.target_dir, moved.resolve())
with mock.patch.object(mod.subprocess, "run") as run:
rec.restore()
self.assertEqual(run.call_args.args[0][-1], f"{moved.resolve()}/")
def test_resolves_a_remote_mountpoint_on_the_remote_host(self):
mod = _load("svc-bkp-volume-2-local", "volume_recover")
with tempfile.TemporaryDirectory() as td:
source = Path(td) / "source"
source.mkdir()
with mock.patch.object(
mod.subprocess,
"run",
side_effect=[
mock.Mock(stdout="/var/lib/docker/volumes/x/_data\n"),
mock.Mock(stdout="null\n"),
mock.Mock(stdout="/mnt/hdd/data/docker/volumes/x\n"),
],
) as run:
rec = 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",
"realpath",
"-e",
"--",
"/var/lib/docker/volumes/x/_data",
],
)
self.assertEqual(
str(rec.target_dir), "root@node-1:/mnt/hdd/data/docker/volumes/x"
)
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()