mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 19:56:53 +00:00
baudolo 7.1.x writes volume generations with `rsync -aHAX`, so the producing host keeps `user.*` extended attributes (OpenCloud keeps its storage metadata in `user.oc.*`). Every later copy dropped them again, because none of the other rsync calls passed `-X` (nor `-A`/`-H`): - NFS export snapshots (svc-bkp-nfs-2-local `script.sh`), - the backup host's pull (svc-bkp-remote-2-local `pull_specific_host.py`), - the device sync (svc-bkp-local-2-device `script.py`), - every recover into a live target (`utils/recovery/base.py`, used by the volume, NFS and device recover scripts), - the `recover full` and `recover remote` tree pulls. A disaster recovery through the documented chain therefore restored OpenCloud without `user.oc.*`, silently: the copies succeeded and the dirval stamp does not cover attributes. All of these now run `rsync -aHAX` (`-aHAXP` for the device sync). A target filesystem without xattr support makes rsync exit non-zero as soon as a source file carries attributes, so the hop fails loudly instead of dropping them; there is no per-device opt-out. The single-image pull in `recover remote` keeps `-a`. The provider's ssh-wrapper needs no change: its glob accepts any server flags and its character guard allows them. Two tests write a `user.oc.id` attribute and assert it survives the NFS snapshot and the recover; with the old `-a` both fail with `OSError: [Errno 61] No data available`. Closes #1021 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
117 lines
4.2 KiB
Python
117 lines
4.2 KiB
Python
"""Unit tests for cli.administration.recover.full discovery + pull."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from cli.administration.recover import full, paths
|
|
|
|
|
|
class PlanTest(unittest.TestCase):
|
|
def _tree(self, root: Path) -> None:
|
|
base = root / "HASH"
|
|
(base / "backup-nfs-to-local" / "20260101000000" / "files").mkdir(parents=True)
|
|
(
|
|
base / "backup-docker-to-local" / "20260101000000" / "pg_data" / "files"
|
|
).mkdir(parents=True)
|
|
(base / "backup-docker-to-local" / "20260101000000" / "redis" / "files").mkdir(
|
|
parents=True
|
|
)
|
|
(base / "backup-secrets-to-local" / "20260101000000" / "files").mkdir(
|
|
parents=True
|
|
)
|
|
|
|
def test_plan_order_and_volume_expansion(self):
|
|
with tempfile.TemporaryDirectory() as td:
|
|
root = Path(td)
|
|
self._tree(root)
|
|
steps, _unreadable = full.plan(root)
|
|
types = [t for t, _ in steps]
|
|
self.assertEqual(types, ["nfs", "volume", "volume", "secrets"])
|
|
self.assertTrue(
|
|
steps[0][1].endswith("backup-nfs-to-local/20260101000000/files")
|
|
)
|
|
self.assertTrue(any(s.endswith("pg_data/files") for _, s in steps))
|
|
self.assertTrue(any(s.endswith("redis/files") for _, s in steps))
|
|
|
|
def test_plan_picks_newest_generation(self):
|
|
with tempfile.TemporaryDirectory() as td:
|
|
root = Path(td)
|
|
repo = root / "HASH" / "backup-nfs-to-local"
|
|
(repo / "20260101000000" / "files").mkdir(parents=True)
|
|
(repo / "20260709120000" / "files").mkdir(parents=True)
|
|
steps, _unreadable = full.plan(root)
|
|
self.assertEqual(len(steps), 1)
|
|
self.assertIn("20260709120000", steps[0][1])
|
|
|
|
def test_plan_empty_root(self):
|
|
with tempfile.TemporaryDirectory() as td:
|
|
self.assertEqual(full.plan(Path(td)), ([], []))
|
|
|
|
|
|
class DeviceDetectTest(unittest.TestCase):
|
|
def test_file_is_device(self):
|
|
with tempfile.NamedTemporaryFile() as tf:
|
|
self.assertTrue(full._is_device(tf.name))
|
|
self.assertTrue(full._is_device(f"{tf.name}:usb:/tmp/r"))
|
|
|
|
def test_directory_is_not_device(self):
|
|
with tempfile.TemporaryDirectory() as td:
|
|
self.assertFalse(full._is_device(td))
|
|
|
|
def test_restore_root_from_absolute_segment(self):
|
|
self.assertEqual(full._device_restore_root("/dev/sdb1:usb:/tmp/r"), "/tmp/r")
|
|
|
|
def test_restore_root_defaults_to_backup_root(self):
|
|
self.assertEqual(full._device_restore_root("/dev/sdb1:usb"), paths.BACKUP_ROOT)
|
|
|
|
|
|
class PullCmdTest(unittest.TestCase):
|
|
def test_bare_host_uses_standard_root(self):
|
|
self.assertEqual(
|
|
full._pull_cmd("user@srchost"),
|
|
[
|
|
"rsync",
|
|
"-aHAX",
|
|
f"user@srchost:{paths.BACKUP_ROOT}/",
|
|
f"{full.PULL_STAGE}/",
|
|
],
|
|
)
|
|
|
|
def test_host_with_explicit_path(self):
|
|
self.assertEqual(
|
|
full._pull_cmd("host:/custom/root"),
|
|
["rsync", "-aHAX", "host:/custom/root/", f"{full.PULL_STAGE}/"],
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|
|
|
|
|
|
class TestUnreadableManifest(unittest.TestCase):
|
|
"""One corrupt manifest must not block the repos that are intact."""
|
|
|
|
def _root(self) -> Path:
|
|
root = Path(tempfile.mkdtemp())
|
|
bad = root / "hash" / "backup-nfs-to-local" / "20260101000000"
|
|
bad.mkdir(parents=True)
|
|
(bad / "manifest.json").write_text(
|
|
'{"schema": 99, "layout": {}, "volumes": {}}', encoding="utf-8"
|
|
)
|
|
(
|
|
root / "hash" / "backup-docker-to-local" / "20260101000000" / "v" / "files"
|
|
).mkdir(parents=True)
|
|
return root
|
|
|
|
def test_the_intact_repo_is_still_planned(self) -> None:
|
|
steps, _unreadable = full.plan(self._root())
|
|
self.assertEqual([rtype for rtype, _src in steps], ["volume"])
|
|
|
|
def test_the_skipped_repo_is_named(self) -> None:
|
|
_steps, unreadable = full.plan(self._root())
|
|
self.assertEqual(len(unreadable), 1)
|
|
self.assertIn("backup-nfs-to-local", unreadable[0])
|