mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-10 12:47:04 +00:00
fix(svc-bkp-nfs-2-local): leave backup-disabled volumes out of the export snapshot
The NFS backup rsynced the whole export and honoured only one exclude, the shared backup root. Volumes declared `backup: false` were copied anyway and then pulled a second time by remote-2-local; in the swarm DR drill of Magento#0 (run 40625679270) docs_sites alone (a full repo clone plus its prepared and work trees) took about 15.5 GiB per copy and filled the runner. The volume backup already skipped these through the backup_volume lookup; only the NFS path ignored the declaration. - utils/storage/nfs.py: volume_dir() names the state directory a volume binds to (pinned name or key), shared with compose_volumes; backup_disabled_dirs() returns every NFS-backed `backup: false` volume dir that no backed-up volume also binds to. - The role-local lookup backup_nfs_excludes feeds them into BKP_NFS_2_LOCAL_EXCLUDES next to the backup root; script.sh takes any number of excludes. Compose installs still pass only the backup root. - recover.py protects every live state directory the snapshot does not hold, so `rsync --delete` keeps the excluded volumes (model weights, the docs tree, caches) and volumes created after the snapshot instead of wiping them. - The swarm DR drill restores BACKUP_NFS_VOLUME, the first NFS volume not declared `backup: false`; PRIMARY_NFS_VOLUME stays for the drain tests. BookWyrm now drills bookwyrm_media; decidim, fusiondirectory, meta, coturn, svc-registry-cache and svc-registry-docker have nothing backed up on NFS and skip the drill. Measured: compose_volumes renders identically before and after for 291 roles across compose/swarm x local/nfs (1940 of 1940); the 15 excluded directories are exactly the backup-disabled device dirs; the systemd unit renders shell-safe on ansible-core 2.20 and 2.21 and stays byte-identical in compose and swarm-local. A new unit test runs the real script.sh over two generations and the real recover.py back into the export, and fails without the restore protection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
39df0967d7
commit
4fcf097e5b
16 files changed
+428
-48
No files matched your search
@@ -6,6 +6,7 @@ into older generations."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
@@ -15,6 +16,9 @@ from . import PROJECT_ROOT
|
||||
SCRIPT = (
|
||||
PROJECT_ROOT / "roles" / "svc-bkp-nfs-2-local" / "files" / "shell" / "script.sh"
|
||||
)
|
||||
RECOVER = (
|
||||
PROJECT_ROOT / "roles" / "svc-bkp-nfs-2-local" / "files" / "python" / "recover.py"
|
||||
)
|
||||
REPO_NAME = "backup-nfs-to-local"
|
||||
|
||||
|
||||
@@ -30,7 +34,7 @@ class NfsBackupScriptTests(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _run(self, source=None, generation=None, exclude=None):
|
||||
def _run(self, source=None, generation=None, excludes=()):
|
||||
env = os.environ.copy()
|
||||
if generation is not None:
|
||||
env["BKP_NFS_2_LOCAL_GENERATION"] = generation
|
||||
@@ -40,9 +44,8 @@ class NfsBackupScriptTests(unittest.TestCase):
|
||||
str(source or self.source),
|
||||
str(self.backups),
|
||||
REPO_NAME,
|
||||
*excludes,
|
||||
]
|
||||
if exclude is not None:
|
||||
cmd.append(exclude)
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
@@ -132,12 +135,12 @@ class NfsBackupScriptTests(unittest.TestCase):
|
||||
(decoy / "decoy.txt").write_text("decoy")
|
||||
(self.source / "infinito-state" / "state.txt").write_text("state")
|
||||
|
||||
exclude = "infinito-state/backup"
|
||||
excludes = ["infinito-state/backup"]
|
||||
self.assertEqual(
|
||||
self._run(generation="20240101000000", exclude=exclude).returncode, 0
|
||||
self._run(generation="20240101000000", excludes=excludes).returncode, 0
|
||||
)
|
||||
self.assertEqual(
|
||||
self._run(generation="20240101000001", exclude=exclude).returncode, 0
|
||||
self._run(generation="20240101000001", excludes=excludes).returncode, 0
|
||||
)
|
||||
|
||||
for generation in self._generations():
|
||||
@@ -148,6 +151,73 @@ class NfsBackupScriptTests(unittest.TestCase):
|
||||
(files / "app" / "infinito-state" / "backup" / "decoy.txt").is_file()
|
||||
)
|
||||
|
||||
def test_every_excluded_volume_dir_stays_out_of_the_snapshot(self):
|
||||
state = self.source / "infinito-state"
|
||||
for name in ("backup", "docs_sites", "nextcloud_whiteboard_tmp"):
|
||||
(state / name).mkdir(parents=True)
|
||||
(state / name / "blob").write_text(name)
|
||||
(state / "nextcloud_data").mkdir()
|
||||
(state / "nextcloud_data" / "user.txt").write_text("user")
|
||||
|
||||
result = self._run(
|
||||
excludes=[
|
||||
"infinito-state/backup",
|
||||
"infinito-state/docs_sites",
|
||||
"infinito-state/nextcloud_whiteboard_tmp",
|
||||
]
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
files = self._generations()[0] / "files" / "infinito-state"
|
||||
self.assertEqual(sorted(p.name for p in files.iterdir()), ["nextcloud_data"])
|
||||
self.assertTrue((files / "nextcloud_data" / "user.txt").is_file())
|
||||
|
||||
def test_restoring_a_generation_spares_excluded_volumes_and_the_backup_root(self):
|
||||
state = self.source / "infinito-state"
|
||||
self.backups = state / "backup"
|
||||
self.backups.mkdir(parents=True)
|
||||
db_file = state / "app_data" / "db"
|
||||
site_file = state / "docs_sites" / "site"
|
||||
db_file.parent.mkdir()
|
||||
site_file.parent.mkdir()
|
||||
db_file.write_text("v1")
|
||||
site_file.write_text("regenerable")
|
||||
excludes = ["infinito-state/backup", "infinito-state/docs_sites"]
|
||||
for generation in ("20240101000000", "20240101000001"):
|
||||
result = self._run(generation=generation, excludes=excludes)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
first, second = (g / "files" / "infinito-state" for g in self._generations())
|
||||
self.assertEqual(sorted(p.name for p in second.iterdir()), ["app_data"])
|
||||
self.assertEqual(
|
||||
(first / "app_data" / "db").stat().st_ino,
|
||||
(second / "app_data" / "db").stat().st_ino,
|
||||
)
|
||||
|
||||
db_file.write_text("corrupted")
|
||||
(state / "app_data" / "stale").write_text("stale")
|
||||
site_file.write_text("rebuilt")
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(RECOVER),
|
||||
str(second.parent),
|
||||
str(self.source),
|
||||
"--no-safety-backup",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
db = db_file.read_text() # nocheck: cache-read tempdir fixture
|
||||
site = site_file.read_text() # nocheck: cache-read tempdir fixture
|
||||
self.assertEqual(db, "v1")
|
||||
self.assertEqual(site, "rebuilt")
|
||||
self.assertFalse((state / "app_data" / "stale").exists())
|
||||
self.assertTrue((first / "app_data" / "db").is_file())
|
||||
self.assertTrue((second / "app_data" / "db").is_file())
|
||||
|
||||
def test_deleted_file_vanishes_only_from_new_generation(self):
|
||||
(self.source / "keep.txt").write_text("keep")
|
||||
(self.source / "gone.txt").write_text("gone")
|
||||
|
||||
Whitespace-only changes.
+138
@@ -0,0 +1,138 @@
|
||||
import importlib.util
|
||||
import unittest
|
||||
import unittest.mock as mock
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import ClassVar
|
||||
|
||||
PLUGIN = (
|
||||
Path(__file__).resolve().parents[6]
|
||||
/ "roles"
|
||||
/ "svc-bkp-nfs-2-local"
|
||||
/ "lookup_plugins"
|
||||
/ "backup_nfs_excludes.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_module() -> ModuleType:
|
||||
spec = importlib.util.spec_from_file_location("backup_nfs_excludes", PLUGIN)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _run(
|
||||
module: ModuleType,
|
||||
applications: dict,
|
||||
variables: dict,
|
||||
templar: object | None = None,
|
||||
) -> list[str]:
|
||||
loader = mock.MagicMock()
|
||||
loader.get.return_value.run.return_value = [applications]
|
||||
with mock.patch.object(module, "lookup_loader", loader):
|
||||
plugin = module.LookupModule()
|
||||
if templar is not None:
|
||||
plugin._templar = templar
|
||||
return plugin.run(None, variables=variables)[0]
|
||||
|
||||
|
||||
class TestBackupNfsExcludes(unittest.TestCase):
|
||||
SWARM_NFS: ClassVar[dict] = {
|
||||
"DEPLOYMENT_MODE": "swarm",
|
||||
"storage": {"backend": "nfs"},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls._module = _load_module()
|
||||
|
||||
def _excludes(self, applications, variables=None, templar=None):
|
||||
return _run(
|
||||
type(self)._module,
|
||||
applications,
|
||||
dict(self.SWARM_NFS) if variables is None else variables,
|
||||
templar,
|
||||
)
|
||||
|
||||
def test_backup_disabled_volume_dirs_are_excluded(self) -> None:
|
||||
apps = {
|
||||
"web-app-docs": {
|
||||
"volumes": {"docs_sites": {"type": "volume", "backup": False}}
|
||||
},
|
||||
"web-app-nextcloud": {
|
||||
"volumes": {
|
||||
"data": {"type": "volume", "name": "nextcloud_data"},
|
||||
"whiteboard_tmp": {
|
||||
"type": "volume",
|
||||
"name": "nextcloud_whiteboard_tmp",
|
||||
"backup": False,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
self.assertEqual(
|
||||
self._excludes(apps),
|
||||
[
|
||||
"infinito-state/docs_sites",
|
||||
"infinito-state/nextcloud_whiteboard_tmp",
|
||||
],
|
||||
)
|
||||
|
||||
def test_the_export_dir_is_the_semantic_key_not_the_docker_name(self) -> None:
|
||||
apps = {"web-app-docs": {"volumes": {"docs_sites": {"backup": False}}}}
|
||||
self.assertNotIn("infinito-state/docs_docs_sites", self._excludes(apps))
|
||||
|
||||
def test_compose_mode_excludes_nothing(self) -> None:
|
||||
apps = {"web-app-docs": {"volumes": {"docs_sites": {"backup": False}}}}
|
||||
variables = {"DEPLOYMENT_MODE": "compose", "storage": {"backend": "nfs"}}
|
||||
self.assertEqual(self._excludes(apps, variables), [])
|
||||
|
||||
def test_a_volume_off_the_export_is_not_excluded(self) -> None:
|
||||
apps = {
|
||||
"web-svc-xmpp": {
|
||||
"volumes": {
|
||||
"home": {"name": "ejabberd_home", "backup": False, "nfs": False}
|
||||
}
|
||||
},
|
||||
"web-svc-seaweedfs": {
|
||||
"volumes": {"data": {"name": "seaweedfs_data", "backup": False}}
|
||||
},
|
||||
}
|
||||
self.assertEqual(self._excludes(apps), [])
|
||||
|
||||
def test_a_dir_a_backed_up_volume_also_binds_stays_captured(self) -> None:
|
||||
apps = {
|
||||
"web-app-docs": {"volumes": {"shared": {"backup": False}}},
|
||||
"web-app-nextcloud": {"volumes": {"shared": {"type": "volume"}}},
|
||||
}
|
||||
self.assertEqual(self._excludes(apps), [])
|
||||
|
||||
def test_a_config_entry_is_never_excluded(self) -> None:
|
||||
apps = {
|
||||
"web-app-nextcloud": {
|
||||
"volumes": {"loader": {"type": "config", "backup": False}}
|
||||
}
|
||||
}
|
||||
self.assertEqual(self._excludes(apps), [])
|
||||
|
||||
def test_the_deployment_mode_expression_is_templated(self) -> None:
|
||||
expression = (
|
||||
"{{ 'swarm' if (groups['svc-swarm-node'] | default([]) | length) > 1"
|
||||
" else 'compose' }}"
|
||||
)
|
||||
templar = mock.Mock()
|
||||
templar.template.side_effect = lambda value: (
|
||||
"swarm" if value == expression else value
|
||||
)
|
||||
apps = {"web-app-docs": {"volumes": {"docs_sites": {"backup": False}}}}
|
||||
self.assertEqual(
|
||||
self._excludes(
|
||||
apps, {**self.SWARM_NFS, "DEPLOYMENT_MODE": expression}, templar
|
||||
),
|
||||
["infinito-state/docs_sites"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -188,6 +188,27 @@ class NfsRecoverTests(unittest.TestCase):
|
||||
)
|
||||
self.assertFalse((target / "stale.txt").exists())
|
||||
|
||||
def test_a_volume_the_snapshot_does_not_hold_survives_the_restore(self):
|
||||
mod = _load("svc-bkp-nfs-2-local", "nfs_recover")
|
||||
state = mod.STATE_SUBDIR
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
source = Path(td) / "source"
|
||||
target = Path(td) / "target"
|
||||
(source / state / "app_data").mkdir(parents=True)
|
||||
(source / state / "app_data" / "db").write_text("from-snapshot")
|
||||
(target / state / "app_data").mkdir(parents=True)
|
||||
(target / state / "app_data" / "stale").write_text("pre-recover")
|
||||
(target / state / "docs_sites").mkdir(parents=True)
|
||||
(target / state / "docs_sites" / "site").write_text("regenerable")
|
||||
recovery = mod.NfsExportRecovery(
|
||||
str(source), str(target), service_backup=False
|
||||
)
|
||||
self.assertEqual(recovery.volumes_missing_from_snapshot(), ["docs_sites"])
|
||||
recovery.restore()
|
||||
self.assertTrue((target / state / "docs_sites" / "site").exists())
|
||||
self.assertTrue((target / state / "app_data" / "db").exists())
|
||||
self.assertFalse((target / state / "app_data" / "stale").exists())
|
||||
|
||||
def test_main_wires_argparse_to_recovery(self):
|
||||
mod = _load("svc-bkp-nfs-2-local", "nfs_recover")
|
||||
for argv, expect_backup in (
|
||||
|
||||
@@ -6,6 +6,7 @@ from utils.storage.nfs import (
|
||||
mount_opts,
|
||||
state_path,
|
||||
swarm_nfs_backed,
|
||||
volume_dir,
|
||||
)
|
||||
|
||||
|
||||
@@ -62,6 +63,15 @@ class TestNfsHelpers(unittest.TestCase):
|
||||
"1.2.3.4:/srv/nfs/infinito-state",
|
||||
)
|
||||
|
||||
def test_volume_dir_is_the_pinned_name(self):
|
||||
self.assertEqual(
|
||||
volume_dir("whiteboard_tmp", {"name": "nextcloud_whiteboard_tmp"}),
|
||||
"nextcloud_whiteboard_tmp",
|
||||
)
|
||||
|
||||
def test_volume_dir_falls_back_to_the_key_without_a_pin(self):
|
||||
self.assertEqual(volume_dir("docs_sites", {"backup": False}), "docs_sites")
|
||||
|
||||
|
||||
class TestSwarmNfsBacked(unittest.TestCase):
|
||||
"""The predicate mirrors the compose_volumes rewrite decision: swarm mode
|
||||
|
||||
Reference in new issue
Block a user