Files
computer-playbook/tests/unit/python/utils/cache/test_domains.py
T
kevinveenbirkenbachandClaude Opus 5 0af8ee5992 Merge branch 'feature/updater' into feature/store-i18n
Five conflicts, resolved by what each side is for rather than by taking a
side:

- sys-service/01_prune.yml and sys-service-terminator/02_unlock.yml take
  the incoming single `systemctl` invocation over four module loops. The
  unlock side also drops an unmask step: the list it runs on is built with
  `reject('search', ' masked')`, so it never held a masked unit and the
  step did nothing.
- sys-stk-front-proxy/tasks/main.yml keeps this branch's version. The block
  the other side still carries there moved into vhost.yml, which runs it
  once per network sibling; taking theirs would have duplicated it.
  vhost.yml holds every task of that block, the global injection and the
  TLS layer included.
- web-app-matomo/08_reinject_consumers.yml likewise keeps the per-vhost
  loop, which re-renders the injection for every network sibling and passes
  matomo_token_override.
- utils/cache/domains.py combines both: the incoming side rendered
  DOMAIN_PRIMARY with the real templar and variables, which is right, but
  added a second renderer beside the module's own. _render_plain now takes
  that context optionally, so the other two call sites stay as they were.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-24 01:57:56 +02:00

464 lines
17 KiB
Python

"""Focused unit tests for ``utils.cache.domains``.
`get_merged_domains` is a thin derivation on top of
`get_merged_applications` plus the
`plugins.filter.canonical_domains_map` filter. We pin: cache-keying
behaviour, the missing-DOMAIN_PRIMARY validation, and the dispatch to
the upstream applications view.
"""
from __future__ import annotations
import sys
import tempfile
import textwrap
import unittest
from pathlib import Path
from unittest.mock import patch
from utils.cache import _reset_cache_for_tests
from utils.cache import domains as cache_domains
from utils.cache.base import _RENDER_GUARD
from utils.domains.default_primary import default_domain_primary
from utils.roles.mapping import ROLE_FILE_META_SERVICES, ROLE_FILE_META_USERS
from . import PROJECT_ROOT
DOMAIN = default_domain_primary()
def _write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(textwrap.dedent(content), encoding="utf-8")
def _seed_minimal_role(tmp: Path) -> Path:
role = tmp / "roles" / "web-app-foo"
_write(
role / ROLE_FILE_META_SERVICES,
"""
server:
domains:
canonical:
- foo.{{ DOMAIN_PRIMARY }}
aliases: []
""",
)
_write(role / ROLE_FILE_META_USERS, "users: {}\n")
return tmp / "roles"
class TestMissingPrimaryDomain(unittest.TestCase):
def setUp(self) -> None:
_reset_cache_for_tests()
def test_raises_when_domain_primary_missing(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_role(Path(tmp))
with self.assertRaisesRegex(ValueError, "DOMAIN_PRIMARY"):
cache_domains.get_merged_domains(
variables={}, roles_dir=roles, templar=None
)
def test_falls_back_to_system_email_domain(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_role(Path(tmp))
with patch(
"utils.cache.applications.get_merged_applications",
return_value={},
):
result = cache_domains.get_merged_domains(
variables={"SYSTEM_EMAIL_DOMAIN": DOMAIN},
roles_dir=roles,
templar=None,
)
self.assertIsInstance(result, dict)
class TestTemplatedPrimaryDomain(unittest.TestCase):
def setUp(self) -> None:
_reset_cache_for_tests()
def test_a_templated_domain_primary_resolves_against_the_play_variables(self):
captured: list[str] = []
def _capture(_self, _apps, primary_domain):
captured.append(primary_domain)
return {}
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_role(Path(tmp))
with (
patch(
"utils.cache.applications.get_merged_applications",
return_value={},
),
patch(
"plugins.filter.canonical_domains_map.FilterModule.canonical_domains_map",
_capture,
),
):
cache_domains.get_merged_domains(
variables={
"DOMAIN_PRIMARY": "{{ SOFTWARE_NAME | lower }}",
"SOFTWARE_NAME": "Infinito.Nexus",
},
roles_dir=roles,
templar=None,
)
self.assertEqual(captured, ["infinito.nexus"])
class TestCachingPerVariablesSignature(unittest.TestCase):
def setUp(self) -> None:
_reset_cache_for_tests()
def test_caches_result_for_identical_variables(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_role(Path(tmp))
with patch(
"utils.cache.applications.get_merged_applications",
return_value={},
) as mocked:
first = cache_domains.get_merged_domains(
variables={"DOMAIN_PRIMARY": DOMAIN},
roles_dir=roles,
templar=None,
)
second = cache_domains.get_merged_domains(
variables={"DOMAIN_PRIMARY": DOMAIN},
roles_dir=roles,
templar=None,
)
self.assertEqual(mocked.call_count, 1)
self.assertEqual(first, second)
def test_different_domain_primary_misses_cache(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_role(Path(tmp))
with patch(
"utils.cache.applications.get_merged_applications",
return_value={},
) as mocked:
cache_domains.get_merged_domains(
variables={"DOMAIN_PRIMARY": "a.example"},
roles_dir=roles,
templar=None,
)
cache_domains.get_merged_domains(
variables={"DOMAIN_PRIMARY": "b.example"},
roles_dir=roles,
templar=None,
)
self.assertEqual(mocked.call_count, 2)
def test_a_map_built_during_the_render_never_becomes_the_finished_one(self):
"""Under the guard the applications view is still unrendered, so its
map must not answer a later read; it may still serve the render."""
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_role(Path(tmp))
variables = {"DOMAIN_PRIMARY": DOMAIN}
with patch(
"utils.cache.applications.get_merged_applications",
return_value={},
) as mocked:
_RENDER_GUARD.applications = True
try:
cache_domains.get_merged_domains(
variables=variables, roles_dir=roles, templar=None
)
cache_domains.get_merged_domains(
variables=variables, roles_dir=roles, templar=None
)
self.assertEqual(mocked.call_count, 1)
finally:
_RENDER_GUARD.applications = False
cache_domains.get_merged_domains(
variables=variables, roles_dir=roles, templar=None
)
self.assertEqual(mocked.call_count, 2)
self.assertEqual(len(cache_domains._MERGED_DOMAINS_CACHE), 2)
self.assertEqual(
sorted(key[-1] for key in cache_domains._MERGED_DOMAINS_CACHE),
[False, True],
)
class TestResetClearsDomainsCache(unittest.TestCase):
def test_reset_evicts_cached_entry(self):
_reset_cache_for_tests()
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_role(Path(tmp))
with patch(
"utils.cache.applications.get_merged_applications",
return_value={},
):
cache_domains.get_merged_domains(
variables={"DOMAIN_PRIMARY": DOMAIN},
roles_dir=roles,
templar=None,
)
self.assertEqual(len(cache_domains._MERGED_DOMAINS_CACHE), 1)
_reset_cache_for_tests()
self.assertEqual(len(cache_domains._MERGED_DOMAINS_CACHE), 0)
class TestImportableWithoutAnsible(unittest.TestCase):
"""`utils.cache.domains` MUST stay ansible-free at import time so
callers in CLI/runner-host paths can pull the module without
needing ansible. Calling `get_merged_domains` requires ansible
transitively (via `canonical_domains_map`) and is NOT pinned here
— the import-only invariant is what matters for the
ansible-less host. Subprocess isolation avoids the in-process
sys.modules / namespace-package hazards that bit earlier shims.
"""
def test_module_imports_without_ansible(self):
import subprocess
repo_root = PROJECT_ROOT
snippet = (
"import sys\n"
f"sys.path.insert(0, {str(repo_root)!r})\n"
"class _Block:\n"
" def find_spec(self, name, path=None, target=None):\n"
" if name == 'ansible' or name.startswith('ansible.'):\n"
" raise ImportError(f'blocked: {name}')\n"
" return None\n"
"sys.meta_path.insert(0, _Block())\n"
"from utils.cache.domains import get_merged_domains\n"
"assert callable(get_merged_domains)\n"
"print('OK')\n"
)
result = subprocess.run(
[sys.executable, "-c", snippet],
capture_output=True,
text=True,
cwd=str(repo_root),
timeout=60,
check=False,
)
self.assertEqual(
result.returncode,
0,
msg=f"stderr=\n{result.stderr}\nstdout=\n{result.stdout}",
)
self.assertIn("OK", result.stdout)
class TestNetworkSiblingInjection(unittest.TestCase):
ONION = "abc123def456ghij789klmno000pqrstuvwx111yz222abc333def444gh.onion"
def _apps(self, enabled=True, **reachability):
config = {"services": {"tor": {"enabled": enabled}}}
if reachability:
config["networks"] = {"reachability": reachability}
return {"web-app-x": config}
def _inject(self, apps, node_mode, *, domains=None, deployed=("web-app-x",)):
return cache_domains._inject_network_siblings(
{"web-app-x": domains if domains is not None else [f"x.{DOMAIN}"]},
apps,
DOMAIN,
self.ONION,
node_mode,
deployed=list(deployed),
)["web-app-x"]
def test_multi_appends_onion_after_clearnet(self):
self.assertEqual(
self._inject(self._apps(), "multi"),
[f"x.{DOMAIN}", f"x.{self.ONION}"],
)
def test_tor_mode_replaces_clearnet(self):
self.assertEqual(self._inject(self._apps(), "tor"), [f"x.{self.ONION}"])
def test_clearnet_mode_is_untouched(self):
self.assertEqual(
self._inject(self._apps(enabled=False), "clearnet"), [f"x.{DOMAIN}"]
)
def test_clearnet_mode_never_renders_the_tor_flag(self):
apps = self._apps(enabled="{{ 'svc-net-tor' in group_names }}")
self.assertEqual(self._inject(apps, "clearnet"), [f"x.{DOMAIN}"])
def test_tor_only_role_on_clearnet_node_fails_loudly(self):
with self.assertRaisesRegex(ValueError, "web-app-x"):
self._inject(self._apps(modes=["tor"]), "clearnet")
def test_single_mode_on_multi_serves_tor(self):
self.assertEqual(
self._inject(self._apps(single_mode=True), "multi"), [f"x.{self.ONION}"]
)
def test_clearnet_only_role_on_multi_stays_clearnet(self):
self.assertEqual(
self._inject(self._apps(modes=["clearnet"]), "multi"), [f"x.{DOMAIN}"]
)
def test_disabled_tor_bond_on_multi_stays_clearnet(self):
self.assertEqual(
self._inject(self._apps(enabled=False), "multi"), [f"x.{DOMAIN}"]
)
def test_role_without_tor_bond_is_exempt_on_tor_node(self):
apps = {"web-app-x": {"services": {}}}
self.assertEqual(self._inject(apps, "tor"), [f"x.{DOMAIN}"])
def test_deployed_mismatch_fails_loudly(self):
with self.assertRaisesRegex(ValueError, "web-app-x"):
self._inject(self._apps(modes=["clearnet"]), "tor")
def test_undeployed_mismatch_keeps_domains(self):
self.assertEqual(
self._inject(self._apps(modes=["clearnet"]), "tor", deployed=()),
[f"x.{DOMAIN}"],
)
def test_bare_primary_domain_maps_to_node_onion(self):
self.assertEqual(
self._inject(self._apps(), "multi", domains=[DOMAIN]),
[DOMAIN, self.ONION],
)
def test_named_canonicals_gain_onion_keys_after_clearnet(self):
self.assertEqual(
self._inject(self._apps(), "multi", domains={"api": f"api.{DOMAIN}"}),
{"api": f"api.{DOMAIN}", "api_onion": f"api.{self.ONION}"},
)
def test_named_canonicals_in_tor_mode_swap_values(self):
self.assertEqual(
self._inject(self._apps(), "tor", domains={"api": f"api.{DOMAIN}"}),
{"api": f"api.{self.ONION}"},
)
class TestNodeNetworkMode(unittest.TestCase):
def test_node_onion_requires_the_provider_on_the_host(self):
apps = {"svc-net-tor": {"services": {"tor": {"node": "n.onion"}}}}
self.assertEqual(cache_domains.node_onion_of(apps, {"group_names": []}), "")
self.assertEqual(
cache_domains.node_onion_of(apps, {"group_names": ["svc-net-tor"]}),
"n.onion",
)
def test_a_swarm_node_is_served_by_the_tor_of_its_cluster(self):
apps = {"svc-net-tor": {"services": {"tor": {"node": "n.onion"}}}}
groups = {"svc-swarm-node": ["mgr", "wrk"], "svc-net-tor": ["mgr"]}
self.assertEqual(
cache_domains.node_onion_of(
apps, {"group_names": ["svc-swarm-node"], "groups": groups}
),
"n.onion",
)
def test_a_compose_host_ignores_tor_on_other_hosts(self):
apps = {"svc-net-tor": {"services": {"tor": {"node": "n.onion"}}}}
groups = {"svc-swarm-node": ["solo"], "svc-net-tor": ["other"]}
self.assertEqual(
cache_domains.node_onion_of(apps, {"group_names": [], "groups": groups}),
"",
)
def test_empty_mode_derives_from_the_node_onion(self):
self.assertEqual(cache_domains.node_network_mode({}, "n.onion"), "multi")
self.assertEqual(cache_domains.node_network_mode({}, ""), "clearnet")
def test_declared_tor_mode_without_provider_fails(self):
with self.assertRaises(ValueError):
cache_domains.node_network_mode({"NETWORK_MODE": "tor"}, "")
class TestTorFlagsAreRendered(unittest.TestCase):
"""`services.tor.enabled` reaches this map as Jinja in almost every role
that declares it, and as its own source text whenever the applications
render is still in flight. Reading that text as a boolean answers False and
drops the onion domains for every app.
"""
ONION = TestNetworkSiblingInjection.ONION
JINJA_ON = "{{ 'svc-net-tor' in group_names }}"
def _templar(self, **variables):
from ansible.parsing.dataloader import DataLoader
from ansible.template import Templar
templar = Templar(loader=DataLoader(), variables=dict(variables))
templar.available_variables = dict(variables)
return templar
def _inject(self, templar, **variables):
return cache_domains._inject_network_siblings(
{"web-app-x": [f"x.{DOMAIN}"]},
{"web-app-x": {"services": {"tor": {"enabled": self.JINJA_ON}}}},
DOMAIN,
self.ONION,
"multi",
templar=templar,
variables=dict(variables),
)["web-app-x"]
def test_a_templated_enabled_flag_still_injects_the_onion(self):
variables = {"group_names": ["svc-net-tor", "web-app-x"]}
self.assertEqual(
self._inject(self._templar(**variables), **variables),
[f"x.{DOMAIN}", f"x.{self.ONION}"],
)
def test_a_templated_flag_resolving_false_leaves_the_clearnet_domain(self):
variables = {"group_names": ["web-app-x"]}
self.assertEqual(
self._inject(self._templar(**variables), **variables),
[f"x.{DOMAIN}"],
)
def test_a_flag_that_cannot_be_rendered_is_an_error(self):
with self.assertRaisesRegex(ValueError, "services.tor.enabled"):
self._inject(None, group_names=["svc-net-tor"])
class TestOnionNodeIsRequiredWhenTorIsDeployed(unittest.TestCase):
"""A deploy carrying `svc-net-tor` without a provisioned node used to
answer the clearnet map and cache it under its own key, because
`group_names` is part of that key. One scope then served onion while
another froze a clearnet asset URL into a stylesheet, which
`body_filter.lua`'s `onionize` returns before rewriting.
"""
ONION = TestNetworkSiblingInjection.ONION
def setUp(self) -> None:
_reset_cache_for_tests()
def _merge(self, node, group_names):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_role(Path(tmp))
with patch(
"utils.cache.applications.get_merged_applications",
return_value={"svc-net-tor": {"services": {"tor": {"node": node}}}},
):
return cache_domains.get_merged_domains(
variables={
"DOMAIN_PRIMARY": "infinito.test",
"group_names": group_names,
},
roles_dir=roles,
templar=None,
)
def test_an_empty_node_is_an_error(self):
with self.assertRaisesRegex(ValueError, "services.tor.node"):
self._merge("", ["svc-net-tor", "web-app-x"])
def test_a_provisioned_node_is_accepted(self):
self.assertIsInstance(self._merge(self.ONION, ["svc-net-tor"]), dict)
def test_an_empty_node_is_fine_without_tor_in_the_groups(self):
self.assertIsInstance(self._merge("", ["web-app-x"]), dict)
if __name__ == "__main__":
unittest.main()