Files
computer-playbook/tests/unit/python/utils/storage/test_nfs.py
T
kevinveenbirkenbachandClaude Opus 5.5 ae40a573c2 fix(web-app-weblate): give the static tmpfs mountpoint its mode on swarm NFS volumes
CI run 40979252521 Weblate#1 (swarm) never converged: every task after
the first died in collectstatic with "PermissionError: [Errno 13]
Permission denied: '/app/cache/static/weblate_fonts'". The tmpfs at
/app/cache/static sits inside the shared weblate_cache volume; the first
task's runc creates the mountpoint as root 0755, and runc gives a tmpfs
on an existing mountpoint that directory's mode. 99a7d28067 declared
mode 1777, which compose honours, but docker stack deploy keeps only the
tmpfs size (docker/cli 29.9.2 convert/volume.go), so swarm still got a
root 0755 tmpfs.

- utils/storage/nfs.py: nested_tmpfs_mountpoints() derives every tmpfs
  mountpoint whose innermost enclosing volume of the same service is
  NFS-backed, with its declared mode; a nested tmpfs without a mode, or
  one mountpoint declared with two modes, raises.
- plugins/lookup/nfs_tmpfs_mountpoints.py feeds it the rendered compose
  volumes, so compose_volumes stays the source of which volumes live on
  NFS.
- sys-svc-compose nfs_prep.yml creates those mountpoints on the NFS
  server with the declared mode before every stack deploy, which also
  repairs a mountpoint an earlier deploy left at 0755.
- web-app-weblate: the cache volume declares nfs uid 1000, gid 0,
  mode 0770; a pre-created mountpoint makes the volume non-empty, so
  Docker no longer copies the image's /app/cache root in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 02:05:59 +01:00

268 lines
9.4 KiB
Python

import unittest
from utils.cache.files import PROJECT_ROOT, read_text
from utils.cache.yaml import load_yaml_str
from utils.roles.mapping import ROLE_FILE_META_VOLUMES
from utils.storage.nfs import (
client_src,
fstype,
mount_opts,
nested_tmpfs_mountpoints,
state_path,
swarm_nfs_backed,
volume_dir,
)
def _role_volumes(application_id):
"""Return a role's ``meta/volumes.yml`` mapping.
Args:
application_id: the role whose file is read.
"""
path = PROJECT_ROOT / "roles" / application_id / ROLE_FILE_META_VOLUMES
return load_yaml_str(read_text(str(path)))
def _every_volume_on_nfs(volumes_meta):
"""Return the NFS directory of every volume that does not opt out of NFS.
Args:
volumes_meta: a ``meta/volumes.yml`` mapping.
"""
return {
name: volume_dir(name, entry)
for name, entry in volumes_meta.items()
if entry.get("type", "volume") == "volume" and entry.get("nfs") is not False
}
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",
)
)
class TestNestedTmpfsMountpoints(unittest.TestCase):
def test_weblate_static_mountpoint_carries_the_tmpfs_mode(self):
volumes = _role_volumes("web-app-weblate")
self.assertEqual(
nested_tmpfs_mountpoints(volumes, _every_volume_on_nfs(volumes)),
[{"path": "weblate_cache/static", "mode": 0o1777}],
"the static tmpfs of weblate, beat and worker sits inside the "
"weblate_cache volume; its mountpoint directory must exist with mode "
"1777 before the stack deploys, or uid 1000 cannot collectstatic",
)
def test_matrix_mautrix_workdir_is_not_nested_in_its_own_services_volume(self):
volumes = _role_volumes("web-app-matrix")
self.assertEqual(
nested_tmpfs_mountpoints(volumes, _every_volume_on_nfs(volumes)),
[],
"the mautrix tmpfs at /data shares its target with synapse_data, but "
"only synapse mounts that volume",
)
def test_a_sibling_path_is_not_nested(self):
volumes = {
"cache": {
"type": "volume",
"mounts": [{"service": "app", "target": "/app/cache"}],
},
"scratch": {
"type": "tmpfs",
"mounts": [{"service": "app", "target": "/app/cachex"}],
},
}
self.assertEqual(nested_tmpfs_mountpoints(volumes, {"cache": "app_cache"}), [])
def test_the_innermost_volume_holds_the_mountpoint(self):
volumes = {
"app": {
"type": "volume",
"mounts": [{"service": "app", "target": "/app"}],
},
"cache": {
"type": "volume",
"mounts": [{"service": "app", "target": "/app/cache/"}],
},
"static": {
"type": "tmpfs",
"mounts": [
{"service": "app", "target": "/app/cache/static", "mode": "0700"}
],
},
}
nfs_dirs = {"app": "app_app", "cache": "app_cache"}
self.assertEqual(
nested_tmpfs_mountpoints(volumes, nfs_dirs),
[{"path": "app_cache/static", "mode": 0o700}],
)
def test_a_nested_tmpfs_without_mode_fails_loudly(self):
volumes = {
"cache": {
"type": "volume",
"mounts": [{"service": "app", "target": "/app/cache"}],
},
"static": {
"type": "tmpfs",
"mounts": [{"service": "app", "target": "/app/cache/static"}],
},
}
with self.assertRaisesRegex(ValueError, "declares no 'mode'"):
nested_tmpfs_mountpoints(volumes, {"cache": "app_cache"})
def test_every_role_declares_a_mode_on_its_nested_tmpfs_mounts(self):
for path in sorted(
(PROJECT_ROOT / "roles").glob(f"*/{ROLE_FILE_META_VOLUMES}")
):
volumes = load_yaml_str(read_text(str(path)))
if not isinstance(volumes, dict):
continue
with self.subTest(role=path.parent.parent.name):
nested_tmpfs_mountpoints(volumes, _every_volume_on_nfs(volumes))
if __name__ == "__main__":
unittest.main()