Files
computer-playbook/tests/unit/python/plugins/lookup/test_nfs_lookups.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

157 lines
5.5 KiB
Python

import importlib.util
import unittest
import unittest.mock as mock
from ansible.parsing.dataloader import DataLoader
from ansible.template import Templar
from utils.storage.nfs import client_src, state_path
def _run(name, variables):
"""Lookups must read resolved values from `variables`; templar.template('{{..}}')
no-ops on untrusted strings in ansible 2.19+ and emits literal Jinja into exports."""
spec = importlib.util.spec_from_file_location(name, f"plugins/lookup/{name}.py")
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
lm = m.LookupModule()
lm._templar = Templar(loader=DataLoader(), variables=variables)
return lm.run([], variables=variables)[0]
BASE = {
"storage": {"nfs": {"server": "192.168.244.2"}},
"RUNTIME": "github",
}
class TestNfsLookups(unittest.TestCase):
def test_state_path_renders(self):
self.assertEqual(_run("nfs_state_path", BASE), "/srv/nfs/infinito-state")
def test_fstype_renders(self):
self.assertEqual(_run("nfs_fstype", BASE), "nfs4")
def test_mount_opts_github_soft(self):
self.assertEqual(
_run("nfs_mount_opts", BASE),
"vers=4,rw,noresvport,soft,timeo=50,retrans=3",
)
def test_mount_opts_host_hard(self):
self.assertEqual(
_run("nfs_mount_opts", {**BASE, "RUNTIME": "host"}),
"vers=4,rw,noresvport,hard,timeo=600",
)
def test_mount_opts_act_soft(self):
self.assertEqual(
_run("nfs_mount_opts", {**BASE, "RUNTIME": "act"}),
"vers=4,rw,noresvport,soft,timeo=50,retrans=3",
)
def _load(name, variables):
spec = importlib.util.spec_from_file_location(name, f"plugins/lookup/{name}.py")
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
lm = m.LookupModule()
lm._templar = Templar(loader=DataLoader(), variables=variables)
lm._loader = DataLoader()
return m, lm
class TestNfsConfigBackedLookups(unittest.TestCase):
"""nfs_flavor / nfs_client_src reach the merged-config SPOT through
lookup('config') via lookup_loader, NOT a utils.cache import."""
def test_flavor_returns_config_lookup_result(self):
m, lm = _load("nfs_flavor", {})
with mock.patch.object(m, "lookup_loader") as loader:
loader.get.return_value = mock.MagicMock(run=lambda *a, **k: ["ganesha"])
self.assertEqual(lm.run([], variables={}), ["ganesha"])
def test_flavor_config_call_shape(self):
m, lm = _load("nfs_flavor", {})
calls = []
def _run(args, **kwargs):
calls.append((tuple(args), kwargs))
return ["kernel"]
with mock.patch.object(m, "lookup_loader") as loader:
loader.get.return_value = mock.MagicMock(run=_run)
lm.run([], variables={"RUNTIME": "github"})
(args, kwargs) = calls[0]
self.assertEqual(
args,
("svc-storage-nfs-server", "services.nfs-server.flavor", "kernel"),
)
self.assertEqual(kwargs.get("variables"), {"RUNTIME": "github"})
def test_client_src_kernel_v4_uses_root_mount(self):
nfs = {"server": "10.0.0.2"}
vars_ = {"storage": {"nfs": nfs}}
m, lm = _load("nfs_client_src", vars_)
with mock.patch.object(m, "lookup_loader") as loader:
loader.get.return_value = mock.MagicMock(run=lambda *a, **k: ["kernel"])
result = lm.run([], variables=vars_)
expected = client_src(
"10.0.0.2",
4,
"kernel",
state_path("/srv/nfs", "infinito-state"),
)
self.assertEqual(result, [expected])
class TestNfsTmpfsMountpointsLookup(unittest.TestCase):
def test_only_nfs_backed_volumes_yield_octal_string_modes(self):
vars_ = {"application_id": "web-app-demo"}
applications = {
"web-app-demo": {
"volumes": {
"cache": {
"type": "volume",
"name": "demo_cache",
"mounts": [{"service": "app", "target": "/app/cache"}],
},
"local": {
"type": "volume",
"mounts": [{"service": "app", "target": "/app/local"}],
},
"static": {
"type": "tmpfs",
"mounts": [
{
"service": "app",
"target": "/app/cache/static",
"mode": "1777",
}
],
},
"scratch": {
"type": "tmpfs",
"mounts": [{"service": "app", "target": "/app/local/tmp"}],
},
}
}
}
compose_volumes = {
"cache": {
"name": "demo_cache",
"driver": "local",
"driver_opts": {"type": "none", "o": "bind"},
},
"local": {},
}
m, lm = _load("nfs_tmpfs_mountpoints", vars_)
with mock.patch.object(m, "lookup_loader") as loader:
loader.get.return_value = mock.MagicMock(run=lambda *a, **k: [applications])
result = lm.run([], variables=vars_, volumes=compose_volumes)
self.assertEqual(result, [[{"path": "demo_cache/static", "mode": "01777"}]])
if __name__ == "__main__":
unittest.main()