Files
computer-playbook/tests/unit/python/cli/administration/recover/test_full.py
T
kevinveenbirkenbachandClaude Opus 5.5 21eba1e88f fix(backup): carry hard links, ACLs and xattrs on every backup and recover hop
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>
2026-10-20 18:20:46 +02:00

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])