Files
computer-playbook/tests/unit/python/utils/recovery/test_base.py
T
kevinveenbirkenbachandClaude Opus 5 b033aab42b fix(backup): carry extended attributes through the restore
A recover mirrored a snapshot with rsync -a, which transfers no extended attributes, so a restored volume came back byte-identical while every application that keeps identity in xattrs read the tree as unknown. OpenCloud posixfs is the case that surfaced it: after the DR drill its storage log reports node.Xattrs on the platform administrator space as no data available, the showcase files are assimilated as new objects, and the graph call that lists drives returns no personal drive, so the design spec dereferences undefined. Measured here, source tagged with user.oc.name, on tmpfs and on ext4 alike: rsync -a returns 0 and drops the attribute, rsync -aHX and rsync -aHAX return 0 and carry it. The shared restore flow and the NFS half of this repo now use -aHAX, the form ensure_subject_subvolume.sh already uses, continuing what 69321d4135 started for numeric ownership.

The backup half still has the same gap: backup-docker-to-local 7.0.2 builds its command as rsync -aP --no-D --delete --delete-excluded with no flag passthrough, so until it carries -X and the pin moves, a snapshot holds no attributes for this restore to put back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-17 14:16:29 +02:00

117 lines
4.2 KiB
Python

from __future__ import annotations
import tempfile
import unittest
import unittest.mock as mock
from pathlib import Path
from utils.recovery.base import DirectoryRecovery
class _Recovery(DirectoryRecovery):
unit_pattern = "svc-bkp-test*.service"
class TestDirectoryRecovery(unittest.TestCase):
def _mk(self, td: str) -> tuple[Path, Path]:
root = Path(td)
source = root / "source"
target = root / "target"
source.mkdir()
target.mkdir()
(source / "restored.txt").write_text("from-snapshot")
(target / "live.txt").write_text("pre-recover")
return source, target
def test_run_starts_backup_unit_then_mirrors_source(self) -> None:
with tempfile.TemporaryDirectory() as td:
source, target = self._mk(td)
recovery = _Recovery(str(source), str(target))
calls: list[list[str]] = []
def fake_run(cmd, **kwargs):
calls.append(cmd)
result = mock.Mock()
if cmd[:2] == ["systemctl", "list-unit-files"]:
result.stdout = "svc-bkp-test.1.example.service enabled\n"
return result
if cmd[:2] == ["systemctl", "start"]:
return result
return mock.DEFAULT
with mock.patch(
"utils.recovery.base.subprocess.run", side_effect=fake_run
) as run:
run.side_effect = fake_run
recovery.backup_target()
self.assertEqual(
calls[1], ["systemctl", "start", "svc-bkp-test.1.example.service"]
)
recovery.restore()
restored = target / "restored.txt"
text = restored.read_text() # nocheck: cache-read - tempdir fixture
self.assertEqual(text, "from-snapshot")
self.assertFalse((target / "live.txt").exists())
def test_restore_carries_extended_attributes(self) -> None:
with tempfile.TemporaryDirectory() as td:
source, target = self._mk(td)
recovery = _Recovery(str(source), str(target))
with mock.patch("utils.recovery.base.subprocess.run") as run:
recovery.restore()
self.assertEqual(
run.call_args.args[0][:2],
["rsync", "-aHAX"],
"a restore without -X returns the payload byte-identical while "
"every xattr-backed application (OpenCloud posixfs) reads the "
"tree as unknown",
)
def test_missing_unit_refuses(self) -> None:
with tempfile.TemporaryDirectory() as td:
source, target = self._mk(td)
recovery = _Recovery(str(source), str(target))
def fake_run(cmd, **kwargs):
result = mock.Mock()
result.stdout = ""
return result
with (
mock.patch("utils.recovery.base.subprocess.run", side_effect=fake_run),
self.assertRaises(SystemExit),
):
recovery.backup_target()
def test_no_safety_backup_skips_unit_run(self) -> None:
with tempfile.TemporaryDirectory() as td:
source, target = self._mk(td)
recovery = _Recovery(str(source), str(target), service_backup=False)
with mock.patch.object(recovery, "backup_target") as backup:
recovery.run()
backup.assert_not_called()
restored = target / "restored.txt"
text = restored.read_text() # nocheck: cache-read - tempdir fixture
self.assertEqual(text, "from-snapshot")
def test_missing_target_refuses(self) -> None:
with tempfile.TemporaryDirectory() as td:
source, target = self._mk(td)
with self.assertRaises(SystemExit):
_Recovery(str(source), str(target / "absent"))
def test_unit_pattern_is_mandatory(self) -> None:
class _NoUnit(DirectoryRecovery):
pass
with tempfile.TemporaryDirectory() as td:
source, target = self._mk(td)
with self.assertRaises(ValueError):
_NoUnit(str(source), str(target))
if __name__ == "__main__":
unittest.main()