Files
computer-playbook/tests/unit/python/utils/storage/test_nfs.py
T
kevinveenbirkenbachandClaude Opus 5.5 4fcf097e5b 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>
2026-10-28 17:43:31 +01:00

160 lines
5.4 KiB
Python

import unittest
from utils.storage.nfs import (
client_src,
fstype,
mount_opts,
state_path,
swarm_nfs_backed,
volume_dir,
)
class TestNfsHelpers(unittest.TestCase):
def test_state_path(self):
self.assertEqual(
state_path("/srv/nfs", "infinito-state"), "/srv/nfs/infinito-state"
)
def test_fstype(self):
self.assertEqual(fstype(4), "nfs4")
self.assertEqual(fstype("4"), "nfs4")
self.assertEqual(fstype(3), "nfs")
def test_mount_opts_lab_runtimes_soft(self):
for rt in ("dev", "act", "github"):
self.assertEqual(
mount_opts(4, rt), "vers=4,rw,noresvport,soft,timeo=50,retrans=3"
)
def test_mount_opts_prod_hard(self):
self.assertEqual(mount_opts(4, "host"), "vers=4,rw,noresvport,hard,timeo=600")
def test_mount_opts_keep_the_client_off_published_privileged_ports(self):
"""A reserved source port can land on imaps/993 or pop3s/995 and then
blocks the whole ingress port set of the mail stack."""
for version in (3, 4):
for runtime in ("dev", "act", "github", "host"):
self.assertIn("noresvport", mount_opts(version, runtime))
def test_mount_opts_v4_carries_no_v3_locking_token(self):
self.assertNotIn("local_lock", mount_opts(4, "host"))
self.assertNotIn("nolock", mount_opts(4, "host"))
def test_mount_opts_v3_still_disables_nlm(self):
self.assertEqual(
mount_opts(3, "host"), "vers=3,rw,noresvport,hard,timeo=600,nolock"
)
def test_client_src_kernel_v4_is_root(self):
self.assertEqual(
client_src("1.2.3.4", 4, "kernel", "/srv/nfs/infinito-state"), "1.2.3.4:/"
)
def test_client_src_ganesha_is_full_path(self):
self.assertEqual(
client_src("1.2.3.4", 4, "ganesha", "/srv/nfs/infinito-state"),
"1.2.3.4:/srv/nfs/infinito-state",
)
def test_client_src_v3_is_full_path(self):
self.assertEqual(
client_src("1.2.3.4", 3, "kernel", "/srv/nfs/infinito-state"),
"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
with an nfs backend backs every volume of a non-manager-pinned role unless
the entry opts out with ``nfs: false``. web-app-nextcloud carries no
placement pin; web-svc-seaweedfs is pinned to the manager on purpose."""
def test_backed_in_swarm_with_nfs(self):
self.assertTrue(
swarm_nfs_backed(
{"name": "nextcloud_data"},
application_id="web-app-nextcloud",
deployment_mode="swarm",
storage_backend="nfs",
)
)
def test_compose_mode_never_backs(self):
self.assertFalse(
swarm_nfs_backed(
{"name": "nextcloud_data"},
application_id="web-app-nextcloud",
deployment_mode="compose",
storage_backend="nfs",
)
)
def test_local_backend_never_backs(self):
self.assertFalse(
swarm_nfs_backed(
{"name": "nextcloud_data"},
application_id="web-app-nextcloud",
deployment_mode="swarm",
storage_backend="local",
)
)
def test_a_manager_pinned_role_stays_node_local(self):
self.assertFalse(
swarm_nfs_backed(
{"name": "seaweedfs_data"},
application_id="web-svc-seaweedfs",
deployment_mode="swarm",
storage_backend="nfs",
)
)
def test_nfs_false_opts_out(self):
self.assertFalse(
swarm_nfs_backed(
{"name": "gitaly_repos", "nfs": False},
application_id="web-app-gitlab",
deployment_mode="swarm",
storage_backend="nfs",
)
)
def test_a_role_forcing_compose_is_not_backed(self):
"""web-app-bigbluebutton pins compose_mode_force: compose in its vars,
so the rewrite leaves its volumes node-local even in a swarm cluster."""
self.assertFalse(
swarm_nfs_backed(
{"name": "bigbluebutton_database"},
application_id="web-app-bigbluebutton",
deployment_mode="swarm",
storage_backend="nfs",
)
)
def test_an_unresolvable_force_expression_is_not_backed(self):
"""web-app-matrix computes compose_mode_force from a config lookup. A
mode this predicate cannot resolve statically must keep the volume in
the volume backup rather than drop it from both capture paths."""
self.assertFalse(
swarm_nfs_backed(
{"name": "matrix_mdad_matrix"},
application_id="web-app-matrix",
deployment_mode="swarm",
storage_backend="nfs",
)
)
if __name__ == "__main__":
unittest.main()