Files
computer-playbook/tests/unit/python/plugins/lookup/test_compose_volumes_render.py
T
kevinveenbirkenbachandClaude Opus 5.5 a23500a270 fix(compose-volumes): keep the meta nfs flag on an injected volume of the same key
compose_volumes merged extra_volumes first and then skipped every meta/volumes.yml entry whose key was already present, so the meta nfs value was lost with it. Nextcloud injects talk_recording_tmp through nextcloud_extra_volumes without an nfs key, and 5728502a19 marked that volume nfs: false. On swarm with the NFS backend the stack therefore bound it to /var/lib/infinito/nextcloud_talk_recording_tmp, while nfs_prep.yml renders the same lookup without extras, saw nfs: false and never created the subdir; every node rejected the task with 'no such file or directory'. The meta nfs value now carries over onto the injected or derived spec unless that spec sets nfs itself, so both renders agree and the scratch volume stays node-local.

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

517 lines
18 KiB
Python

from __future__ import annotations
import os
import sys
import tempfile
import unittest
import unittest.mock as mock
from pathlib import Path
from typing import Any
from ansible.errors import AnsibleFilterError
from . import PROJECT_ROOT
def _ensure_repo_root_on_syspath() -> None:
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
_ensure_repo_root_on_syspath()
from plugins.lookup.compose_volumes import compose_volumes # noqa: E402
from utils.cache.yaml import load_yaml_str # noqa: E402
_DIR_VAR_LIB = os.environ["INFINITO_DIR_VAR_LIB"]
def _call(applications: Any, application_id: str, **kwargs: Any) -> str:
kwargs.setdefault("dir_var_lib", _DIR_VAR_LIB)
return compose_volumes(applications, application_id, **kwargs)
class TestComposeVolumes(unittest.TestCase):
def _parse_yaml(self, rendered: str) -> dict[str, Any]:
self.assertIsInstance(rendered, str)
data = load_yaml_str(rendered) if rendered.strip() else {}
self.assertIsInstance(data, dict)
self.assertIn("volumes", data)
self.assertIsInstance(data["volumes"], dict)
return data
def _base_apps(self) -> dict[str, Any]:
return {
"app": {
"services": {
"mariadb": {"enabled": False, "shared": False},
"redis": {"enabled": False},
"sso": {"enabled": False, "flavor": "oauth2"},
}
}
}
def test_none_applications_raises(self):
with self.assertRaises(AnsibleFilterError):
_call(None, "app") # type: ignore[arg-type]
def test_non_dict_applications_raises(self):
with self.assertRaises(AnsibleFilterError):
_call(["not-a-dict"], "app") # type: ignore[arg-type]
def test_empty_application_id_raises(self):
apps = self._base_apps()
with self.assertRaises(AnsibleFilterError):
_call(apps, "") # type: ignore[arg-type]
def test_unknown_application_id_raises(self):
apps = self._base_apps()
with self.assertRaises(AnsibleFilterError):
_call(apps, "missing-app")
def test_renders_volumes_key_even_when_empty(self):
apps = self._base_apps()
rendered = _call(apps, "app")
data = self._parse_yaml(rendered)
self.assertEqual(data["volumes"], {})
def test_database_enabled_not_shared_derives_database_volume(self):
apps = self._base_apps()
apps["app"]["services"]["mariadb"]["enabled"] = True
apps["app"]["services"]["mariadb"]["shared"] = False
rendered = _call(apps, "app")
data = self._parse_yaml(rendered)
self.assertEqual(data["volumes"]["database"]["name"], "app_database")
def test_database_enabled_shared_true_does_not_add_database_volume(self):
apps = self._base_apps()
apps["app"]["services"]["mariadb"]["enabled"] = True
apps["app"]["services"]["mariadb"]["shared"] = True
rendered = _call(apps, "app")
data = self._parse_yaml(rendered)
self.assertNotIn("database", data["volumes"])
def test_database_enabled_shared_null_treated_as_not_shared(self):
apps = self._base_apps()
apps["app"]["services"]["mariadb"]["enabled"] = True
apps["app"]["services"]["mariadb"]["shared"] = None
rendered = _call(apps, "app")
data = self._parse_yaml(rendered)
self.assertIn("database", data["volumes"])
self.assertEqual(data["volumes"]["database"]["name"], "app_database")
def test_redis_enabled_adds_redis_volume(self):
apps = self._base_apps()
apps["app"]["services"]["redis"]["enabled"] = True
rendered = _call(apps, "app")
data = self._parse_yaml(rendered)
self.assertIn("redis", data["volumes"])
self.assertEqual(data["volumes"]["redis"]["name"], "app_redis")
def test_sso_oauth2_flavor_enabled_adds_redis_volume_when_redis_disabled(self):
apps = self._base_apps()
apps["app"]["services"]["redis"]["enabled"] = False
apps["app"]["services"]["sso"]["enabled"] = True
rendered = _call(apps, "app")
data = self._parse_yaml(rendered)
self.assertIn("redis", data["volumes"])
self.assertEqual(data["volumes"]["redis"]["name"], "app_redis")
def test_sso_oidc_flavor_does_not_add_redis_if_redis_disabled(self):
apps = self._base_apps()
apps["app"]["services"]["redis"]["enabled"] = False
apps["app"]["services"]["sso"]["enabled"] = True
apps["app"]["services"]["sso"]["flavor"] = "oidc"
rendered = _call(apps, "app")
data = self._parse_yaml(rendered)
self.assertNotIn("redis", data["volumes"])
def test_sso_null_does_not_add_redis_if_redis_disabled(self):
apps = self._base_apps()
apps["app"]["services"]["redis"]["enabled"] = False
apps["app"]["services"]["sso"]["enabled"] = None
rendered = _call(apps, "app")
data = self._parse_yaml(rendered)
self.assertNotIn("redis", data["volumes"])
def test_extra_volumes_are_added(self):
apps = self._base_apps()
rendered = _call(
apps,
"app",
extra_volumes={"data": {"name": "pg_data_vol"}},
)
data = self._parse_yaml(rendered)
self.assertIn("data", data["volumes"])
self.assertEqual(data["volumes"]["data"]["name"], "pg_data_vol")
def test_extra_volumes_override_auto(self):
apps = self._base_apps()
apps["app"]["services"]["redis"]["enabled"] = True
rendered = _call(
apps,
"app",
extra_volumes={"redis": {"name": "custom_redis"}},
)
data = self._parse_yaml(rendered)
self.assertEqual(data["volumes"]["redis"]["name"], "custom_redis")
def test_extra_secret_without_name_gets_the_content_hashed_name(self):
with tempfile.TemporaryDirectory() as tmp:
one, two = Path(tmp) / "one.yaml", Path(tmp) / "two.yaml"
one.write_text("as_token: one\n", encoding="utf-8")
two.write_text("as_token: two\n", encoding="utf-8")
secrets = self._parse_yaml(
_call(
self._base_apps(),
"app",
extra_secrets={
"cfg": {"file": str(one)},
"other": {"file": str(two)},
"named": {"name": "kept", "file": str(one)},
},
)
)["secrets"]
self.assertEqual(secrets["cfg"]["file"], str(one))
self.assertRegex(secrets["cfg"]["name"], r"^app_cfg_[0-9a-f]{8}$")
self.assertRegex(secrets["other"]["name"], r"^app_other_[0-9a-f]{8}$")
self.assertNotEqual(secrets["cfg"]["name"][-8:], secrets["other"]["name"][-8:])
self.assertEqual(secrets["named"]["name"], "kept")
def test_database_enabled_not_shared_shared_provider_name_used_when_present(self):
apps = self._base_apps()
apps["app"]["services"]["mariadb"]["enabled"] = True
apps["app"]["services"]["mariadb"]["shared"] = True
apps["svc-db-mariadb"] = {"services": {"mariadb": {"name": "mariadb-central"}}}
rendered = _call(apps, "app")
data = self._parse_yaml(rendered)
self.assertNotIn("database", data["volumes"])
def test_database_simultaneous_postgres_and_mariadb_raises(self):
apps = self._base_apps()
apps["app"]["services"]["mariadb"] = {"enabled": True, "shared": False}
apps["app"]["services"]["postgres"] = {"enabled": True, "shared": False}
with self.assertRaisesRegex(
AnsibleFilterError,
"Simultaneous postgres \\+ mariadb",
):
_call(apps, "app")
def test_seaweedfs_enabled_not_shared_adds_seaweedfs_volume(self):
apps = self._base_apps()
apps["app"]["services"]["seaweedfs"] = {"enabled": True, "shared": False}
rendered = _call(apps, "app")
data = self._parse_yaml(rendered)
self.assertIn("seaweedfs", data["volumes"])
self.assertEqual(data["volumes"]["seaweedfs"]["name"], "app_seaweedfs")
def test_seaweedfs_enabled_shared_true_does_not_add_seaweedfs_volume(self):
apps = self._base_apps()
apps["app"]["services"]["seaweedfs"] = {"enabled": True, "shared": True}
rendered = _call(apps, "app")
data = self._parse_yaml(rendered)
self.assertNotIn("seaweedfs", data["volumes"])
def test_minio_enabled_not_shared_adds_minio_volume(self):
apps = self._base_apps()
apps["app"]["services"]["minio"] = {"enabled": True, "shared": False}
rendered = _call(apps, "app")
data = self._parse_yaml(rendered)
self.assertIn("minio", data["volumes"])
self.assertEqual(data["volumes"]["minio"]["name"], "app_minio")
def test_objstore_engines_disabled_add_no_volumes(self):
apps = self._base_apps()
apps["app"]["services"]["seaweedfs"] = {"enabled": False, "shared": False}
apps["app"]["services"]["minio"] = {"enabled": False, "shared": False}
rendered = _call(apps, "app")
data = self._parse_yaml(rendered)
self.assertEqual(data["volumes"], {})
def test_extra_volume_with_none_name_serializes_to_null(self):
apps = self._base_apps()
rendered = _call(
apps,
"app",
extra_volumes={"data": {"name": None}},
)
data = self._parse_yaml(rendered)
self.assertIsNone(data["volumes"]["data"]["name"])
def test_swarm_nfs_opt_in_renders_driver_opts(self):
apps = self._base_apps()
rendered = _call(
apps,
"app",
extra_volumes={"images": {"name": "app_images", "nfs": True}},
deployment_mode="swarm",
storage={
"backend": "nfs",
"nfs": {"server": "10.0.0.20", "export_base": "/srv/nfs"},
},
)
data = self._parse_yaml(rendered)
vol = data["volumes"]["images"]
self.assertEqual(vol["name"], "app_images")
self.assertEqual(vol["driver"], "local")
self.assertEqual(vol["driver_opts"]["type"], "none")
self.assertEqual(vol["driver_opts"]["o"], "bind")
self.assertEqual(vol["driver_opts"]["device"], f"{_DIR_VAR_LIB}/app_images")
self.assertNotIn("nfs", vol)
def test_swarm_nfs_false_opt_out_stays_plain_local_volume(self):
apps = self._base_apps()
rendered = _call(
apps,
"app",
extra_volumes={
"repositories": {"name": "app_repositories", "nfs": False},
"shared": {"name": "app_shared"},
},
deployment_mode="swarm",
storage={
"backend": "nfs",
"nfs": {"server": "10.0.0.20", "export_base": "/srv/nfs"},
},
)
data = self._parse_yaml(rendered)
repositories = data["volumes"]["repositories"]
self.assertEqual(repositories, {"name": "app_repositories"})
shared = data["volumes"]["shared"]
self.assertEqual(shared["driver"], "local")
self.assertEqual(shared["driver_opts"]["device"], f"{_DIR_VAR_LIB}/app_shared")
def test_swarm_nfs_false_opt_out_from_meta_volumes(self):
apps = self._base_apps()
apps["app"]["volumes"] = {
"repositories": {
"type": "volume",
"name": "app_repositories",
"nfs": False,
"mounts": [{"service": "gitaly", "target": "/home/git/repositories"}],
},
"shared": {
"type": "volume",
"name": "app_shared",
"mounts": [{"service": "web", "target": "/srv/app/shared"}],
},
}
rendered = _call(
apps,
"app",
deployment_mode="swarm",
storage={
"backend": "nfs",
"nfs": {"server": "10.0.0.20", "export_base": "/srv/nfs"},
},
)
data = self._parse_yaml(rendered)
self.assertEqual(data["volumes"]["repositories"], {"name": "app_repositories"})
self.assertIn("driver_opts", data["volumes"]["shared"])
def test_swarm_meta_nfs_false_survives_extra_volume_with_same_key(self):
apps = self._base_apps()
apps["app"]["volumes"] = {
"scratch": {
"type": "volume",
"name": "app_scratch",
"nfs": False,
"mounts": [{"service": "recorder", "target": "/tmp"}],
},
"shared_tmp": {
"type": "volume",
"name": "app_shared_tmp",
"mounts": [{"service": "board", "target": "/tmp"}],
},
}
swarm_nfs = {
"deployment_mode": "swarm",
"storage": {
"backend": "nfs",
"nfs": {"server": "10.0.0.20", "export_base": "/srv/nfs"},
},
}
stack = self._parse_yaml(
_call(
apps,
"app",
extra_volumes={
"scratch": {"name": "app_scratch"},
"shared_tmp": {"name": "app_shared_tmp"},
},
**swarm_nfs,
)
)
prep = self._parse_yaml(_call(apps, "app", **swarm_nfs))
self.assertEqual(stack["volumes"]["scratch"], {"name": "app_scratch"})
self.assertEqual(stack["volumes"]["scratch"], prep["volumes"]["scratch"])
self.assertEqual(
stack["volumes"]["shared_tmp"]["driver_opts"]["device"],
f"{_DIR_VAR_LIB}/app_shared_tmp",
)
self.assertEqual(stack["volumes"]["shared_tmp"], prep["volumes"]["shared_tmp"])
def test_swarm_nfs_dict_value_keeps_rewrite(self):
apps = self._base_apps()
apps["app"]["volumes"] = {
"data": {
"type": "volume",
"name": "app_data",
"nfs": {"uid": 1000, "gid": 1000, "mode": "0750"},
"mounts": [{"service": "web", "target": "/data"}],
},
}
rendered = _call(
apps,
"app",
deployment_mode="swarm",
storage={
"backend": "nfs",
"nfs": {"server": "10.0.0.20", "export_base": "/srv/nfs"},
},
)
data = self._parse_yaml(rendered)
vol = data["volumes"]["data"]
self.assertEqual(vol["driver_opts"]["device"], f"{_DIR_VAR_LIB}/app_data")
self.assertNotIn("nfs", vol)
self.assertEqual(
vol["x-infinito-nfs"], {"uid": 1000, "gid": 1000, "mode": "0750"}
)
def test_compose_mode_nfs_false_leaves_no_nfs_key(self):
apps = self._base_apps()
rendered = _call(
apps,
"app",
extra_volumes={"repositories": {"name": "app_repositories", "nfs": False}},
deployment_mode="compose",
storage={"backend": "local"},
)
data = self._parse_yaml(rendered)
self.assertEqual(data["volumes"]["repositories"], {"name": "app_repositories"})
def test_swarm_pinned_role_stays_node_local(self):
apps = self._base_apps()
with mock.patch(
"utils.roles.meta_lookup.get_role_placement",
return_value="manager",
):
rendered = _call(
apps,
"app",
extra_volumes={
"images": {"name": "app_images"},
"data": {"name": "app_data"},
},
deployment_mode="swarm",
storage={
"backend": "nfs",
"nfs": {"server": "10.0.0.20", "export_base": "/srv/nfs"},
},
)
data = self._parse_yaml(rendered)
self.assertNotIn("driver_opts", data["volumes"]["images"])
self.assertNotIn("driver_opts", data["volumes"]["data"])
def test_compose_mode_ignores_nfs_flag(self):
apps = self._base_apps()
rendered = _call(
apps,
"app",
extra_volumes={"images": {"name": "app_images", "nfs": True}},
deployment_mode="compose",
storage={
"backend": "nfs",
"nfs": {"server": "10.0.0.20", "export_base": "/srv/nfs"},
},
)
data = self._parse_yaml(rendered)
self.assertNotIn("driver_opts", data["volumes"]["images"])
self.assertNotIn("nfs", data["volumes"]["images"])
def test_swarm_local_backend_ignores_nfs_flag(self):
apps = self._base_apps()
rendered = _call(
apps,
"app",
extra_volumes={"images": {"name": "app_images", "nfs": True}},
deployment_mode="swarm",
storage={"backend": "local"},
)
data = self._parse_yaml(rendered)
self.assertNotIn("driver_opts", data["volumes"]["images"])
self.assertNotIn("nfs", data["volumes"]["images"])
def test_config_with_all_mounts_gated_off_is_not_declared(self):
apps = self._base_apps()
apps["app"]["volumes"] = {
"auth_ldap": {
"type": "config",
"source": "/opt/compose/app/volumes/auth_ldap.xml",
"mounts": [
{
"service": "backend",
"target": "/etc/auth_ldap.xml",
"when": False,
}
],
},
"proxy_conf": {
"type": "config",
"source": "/opt/compose/app/volumes/default.conf",
"mounts": [
{"service": "proxy", "target": "/etc/nginx/conf.d/default.conf"}
],
},
}
rendered = _call(apps, "app")
data = self._parse_yaml(rendered)
self.assertIn("configs", data)
self.assertNotIn("auth_ldap", data["configs"])
self.assertIn("proxy_conf", data["configs"])
if __name__ == "__main__":
unittest.main()