mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 04:06:42 +00:00
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>
117 lines
4.2 KiB
Python
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()
|