Files
kevinveenbirkenbachandClaude Opus 5.5 764b69267c fix(recover): restore an NFS generation onto the export base it was taken from
svc-bkp-nfs-2-local snapshots the export base (/srv/nfs), so a
generation's files/ root already holds infinito-state/. Without an
explicit target, `recover nfs` and `recover full` restored it onto
/srv/nfs/infinito-state: the tree came out as
infinito-state/infinito-state/<volume>, and `rsync --delete` wiped every
live volume directory and the backup root under the old target.

- paths.NFS_EXPORT_BASE = get_export_base(), the function behind
  lookup('nfs_export_base') that also feeds the backup unit's source,
  replaces NFS_EXPORT_STATE. NfsRecoverer defaults to it; an explicit
  `snapshot:/abs` target still wins, a remote target is still prefixed
  with its host. `recover full` inherits the default.
- recover_drill.sh seeds its marker at files/infinito-state/<volume>/
  and asserts it under the export base, so the drill can no longer pass
  on a nested layout.
- The help examples, schema.yml and the CLI README show the export base;
  the documented subtree example would have nested too and now names the
  matching snapshot subtree.

Reproduced on a temp tree with the role's real script.sh and recover.py
and the CLI's argv: the old default loses every live volume and the
backup root, the new one restores in place. The new tests fail 6 of 6
against the old default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:56:26 +01:00

143 lines
5.1 KiB
Python

"""Unit tests for cli.administration.recover.full discovery + pull."""
from __future__ import annotations
import contextlib
import io
import shlex
import tempfile
import unittest
from pathlib import Path
from cli.administration.recover import full, paths
from utils.storage.nfs import get_export_base
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 NfsStepTargetTest(unittest.TestCase):
def test_nfs_generation_files_restore_onto_the_export_base(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
files = root / "HASH" / "backup-nfs-to-local" / "20260101000000" / "files"
files.mkdir(parents=True)
out = io.StringIO()
with contextlib.redirect_stdout(out):
full._recover_under(
root, "localhost", preview=True, service_backup=False
)
line = next(
entry
for entry in out.getvalue().splitlines()
if entry.startswith("# full nfs")
)
argv = shlex.split(line.split(": ", 1)[1])
self.assertEqual(
argv[2:], [str(files), get_export_base(), "--no-safety-backup"]
)
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])