Files
computer-playbook/tests/unit/utils/cache/test_applications.py
T
kevinveenbirkenbachandClaude Opus 4.8 238bc285c7 refactor(lookups): reach merged config via the lookup-loader SPOT
Consumer lookup plugins now reach the merged config through the Ansible
lookup loader instead of importing get_merged_applications /
get_merged_domains from utils.cache. The merge stays one cached
implementation surfaced via the applications/config/domains/users lookups;
consumers call lookup_loader.get(...).run() (the compose_networks idiom).

- get_merged_applications walks the whole meta/ tree recursively
  (meta/**/*.yml to applications.<app>.<path>, addons and any depth) and
  folds the raw meta/volumes.yml subtree onto applications.<app>.volumes
  after the templar render so the Jinja source: strings stay unrendered.
- 31 consumer lookups rerouted; providers (applications/config/
  applications_current_play/users/domains) and the raw-volume accessors
  (volume/nginx) keep the cache import behind # nocheck: lookup-cache-import.
- New lint test_no_cache_imports_in_lookups enforces the contract.
- nfs_flavor is now the flavor SPOT (svc-storage-nfs-server + nfs_client_src
  resolve via lookup('nfs_flavor')); removed the unused STORAGE_NFS_FLAVOR
  group_var. Extracted extract_default_credentials to
  utils/cache/schema_credentials.py to keep applications.py under the cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:35:06 +02:00

461 lines
17 KiB
Python

"""Focused unit tests for ``utils.cache.applications``.
Pins the public API (`get_application_defaults`, `get_variants`,
`get_merged_applications`) plus the cache invariants (per-roles_dir
keying, deep-copy on read), and the strict ansible-free import-time
contract that the GitHub Actions runner-host CLI path depends on.
The matrix-deploy variants loader is exhaustively covered in
test_variants.py; this file owns the module-API contract.
"""
from __future__ import annotations
import sys
import tempfile
import textwrap
import unittest
from pathlib import Path
from utils.cache import _reset_cache_for_tests
from utils.cache import applications as cache_apps
from utils.roles.mapping import (
ROLE_DIR_META_ADDONS,
ROLE_FILE_META_MAIN,
ROLE_FILE_META_SERVICES,
ROLE_FILE_META_USERS,
ROLE_FILE_META_VARIANTS,
ROLE_FILE_META_VOLUMES,
)
from . import PROJECT_ROOT
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_roles(tmp: Path) -> Path:
"""Create a minimal `<tmp>/roles/web-app-foo/meta/...` tree
so applications/variants can resolve a real role.
"""
roles = tmp / "roles"
role = roles / "web-app-foo"
_write(
role / ROLE_FILE_META_SERVICES,
"""
foo:
image: foo
version: latest
""",
)
_write(
role / ROLE_FILE_META_USERS,
"""
administrator: {}
""",
)
return roles
class TestGetApplicationDefaults(unittest.TestCase):
def setUp(self) -> None:
_reset_cache_for_tests()
def test_returns_dict_per_role(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_roles(Path(tmp))
defaults = cache_apps.get_application_defaults(roles_dir=roles)
self.assertIn("web-app-foo", defaults)
self.assertIn("services", defaults["web-app-foo"])
def test_caches_per_roles_dir(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_roles(Path(tmp))
first = cache_apps.get_application_defaults(roles_dir=roles)
# Mutate the returned copy; the cache MUST hand back a fresh
# deep copy on the next call.
first["web-app-foo"]["mutated"] = True
second = cache_apps.get_application_defaults(roles_dir=roles)
self.assertNotIn("mutated", second["web-app-foo"])
def test_users_block_rewritten_to_lookup_jinja(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_roles(Path(tmp))
defaults = cache_apps.get_application_defaults(roles_dir=roles)
users = defaults["web-app-foo"]["users"]
self.assertEqual(
users["administrator"],
"{{ lookup('users', 'administrator') }}",
)
class TestGetCanonicalVolumes(unittest.TestCase):
def setUp(self) -> None:
_reset_cache_for_tests()
def test_forces_rebuild_when_registry_empty(self):
cache_apps._CANONICAL_VOLUMES_BY_ROLE.clear()
self.assertIn(
"ollama_models",
cache_apps.get_canonical_volumes("svc-ai-ollama"),
)
def test_warm_defaults_cache_does_not_mask_empty_registry(self):
cache_apps.get_application_defaults()
cache_apps._CANONICAL_VOLUMES_BY_ROLE.clear()
self.assertIn(
"ollama_models",
cache_apps.get_canonical_volumes("svc-ai-ollama"),
)
class TestGetVariants(unittest.TestCase):
def setUp(self) -> None:
_reset_cache_for_tests()
def test_returns_single_variant_when_no_meta_variants_yml(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_roles(Path(tmp))
variants = cache_apps.get_variants(roles_dir=roles)
self.assertEqual(len(variants["web-app-foo"]), 1)
def test_returns_multiple_variants_when_meta_variants_yml_lists_them(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_roles(Path(tmp))
_write(
roles / "web-app-foo" / ROLE_FILE_META_VARIANTS,
"""
- {}
- services:
foo:
image: foo-alt
""",
)
variants = cache_apps.get_variants(roles_dir=roles)
self.assertEqual(len(variants["web-app-foo"]), 2)
# Variant 0 (default) keeps the canonical image; variant 1 has the
# override applied.
self.assertEqual(
variants["web-app-foo"][0]["services"]["foo"]["image"],
"foo",
)
self.assertEqual(
variants["web-app-foo"][1]["services"]["foo"]["image"],
"foo-alt",
)
def test_caches_per_roles_dir(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_roles(Path(tmp))
first = cache_apps.get_variants(roles_dir=roles)
first["web-app-foo"][0]["mutated"] = True
second = cache_apps.get_variants(roles_dir=roles)
self.assertNotIn("mutated", second["web-app-foo"][0])
class TestGetVariantOverridesOnly(unittest.TestCase):
def setUp(self) -> None:
_reset_cache_for_tests()
def test_returns_single_empty_override_when_no_meta_variants_yml(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_roles(Path(tmp))
overrides = cache_apps.get_variant_overrides_only(roles_dir=roles)
self.assertEqual(overrides["web-app-foo"], [{}])
def test_returns_raw_overrides_without_base_merge(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_roles(Path(tmp))
_write(
roles / "web-app-foo" / ROLE_FILE_META_VARIANTS,
"""
- {}
- services:
foo:
image: foo-alt
""",
)
overrides = cache_apps.get_variant_overrides_only(roles_dir=roles)
# Variant 0 stays empty (no override): the role's
# `meta/services.yml` baseline (image=foo) is NOT mixed in.
self.assertEqual(overrides["web-app-foo"][0], {})
# Variant 1 carries only the override fields.
self.assertEqual(
overrides["web-app-foo"][1],
{"services": {"foo": {"image": "foo-alt"}}},
)
def test_caches_per_roles_dir(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_roles(Path(tmp))
first = cache_apps.get_variant_overrides_only(roles_dir=roles)
first["web-app-foo"][0]["mutated"] = True
second = cache_apps.get_variant_overrides_only(roles_dir=roles)
self.assertNotIn("mutated", second["web-app-foo"][0])
class TestBuildRoleBaseConfig(unittest.TestCase):
def setUp(self) -> None:
_reset_cache_for_tests()
def test_empty_meta_yields_empty_dict(self):
with tempfile.TemporaryDirectory() as tmp:
role = Path(tmp) / "roles" / "web-app-empty"
(role / "meta").mkdir(parents=True)
self.assertEqual(
cache_apps._build_role_base_config(role, role.parent),
{},
)
class TestAddonsNormalization(unittest.TestCase):
"""Each `meta/addons/<id>.yml` file (root = the addon spec) is loaded into
`applications.<role>.addons.<id>` with its enable state normalised: the
loader is the single place that resolves `required`/`enabled` defaults so
every consumer reads one already-resolved view.
"""
def setUp(self) -> None:
_reset_cache_for_tests()
def _addon_file(self, roles: Path, addon_id: str, spec_yaml: str) -> None:
_write(
roles / "web-app-foo" / ROLE_DIR_META_ADDONS / f"{addon_id}.yml",
spec_yaml,
)
def _defaults_with_addon(self, tmp: Path, addon_id: str, spec_yaml: str) -> dict:
roles = _seed_minimal_roles(tmp)
self._addon_file(roles, addon_id, spec_yaml)
defaults = cache_apps.get_application_defaults(roles_dir=roles)
return defaults["web-app-foo"]["addons"]
def test_required_true_defaults_enabled_true(self):
with tempfile.TemporaryDirectory() as tmp:
addons = self._defaults_with_addon(
Path(tmp),
"core_module",
"""
mechanism: module
source: upstream
required: true
""",
)
self.assertTrue(addons["core_module"]["enabled"])
self.assertTrue(addons["core_module"]["required"])
def test_optional_addon_defaults_enabled_false(self):
with tempfile.TemporaryDirectory() as tmp:
addons = self._defaults_with_addon(
Path(tmp),
"opt",
"""
mechanism: plugin
source: upstream
""",
)
self.assertIs(addons["opt"]["enabled"], False)
self.assertIs(addons["opt"]["required"], False)
def test_explicit_enabled_jinja_is_preserved(self):
with tempfile.TemporaryDirectory() as tmp:
expr = (
"{{ lookup('config', application_id, 'services.ldap.enabled') | bool }}"
)
addons = self._defaults_with_addon(
Path(tmp),
"ldapauth",
f"""
mechanism: addon
source: upstream
enabled: "{expr}"
bridges:
- ldap
""",
)
self.assertEqual(addons["ldapauth"]["enabled"], expr)
def test_explicit_enabled_false_on_optional_is_preserved(self):
with tempfile.TemporaryDirectory() as tmp:
addons = self._defaults_with_addon(
Path(tmp),
"bridge_net",
"""
mechanism: bridge
source: upstream
required: false
enabled: false
""",
)
self.assertIs(addons["bridge_net"]["enabled"], False)
def test_normalization_does_not_corrupt_yaml_cache(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_roles(Path(tmp))
self._addon_file(
roles,
"opt",
"""
mechanism: plugin
source: upstream
""",
)
first = cache_apps.get_application_defaults(roles_dir=roles)
first["web-app-foo"]["addons"]["opt"]["enabled"] = "mutated"
_reset_cache_for_tests()
second = cache_apps.get_application_defaults(roles_dir=roles)
self.assertIs(second["web-app-foo"]["addons"]["opt"]["enabled"], False)
class TestGetMergedApplicationsRespectsOverrides(unittest.TestCase):
"""Smoke test that `get_merged_applications` deep-merges runtime
`applications` overrides on top of the role defaults. The full
rendering pipeline (with templar) is covered in test_data.py and
integration suites; this file pins the contract from the
applications module's perspective.
"""
def setUp(self) -> None:
_reset_cache_for_tests()
def test_runtime_override_wins_over_default(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_roles(Path(tmp))
merged = cache_apps.get_merged_applications(
variables={
"applications": {
"web-app-foo": {
"services": {"foo": {"image": "override"}},
}
}
},
roles_dir=roles,
templar=None,
)
self.assertEqual(
merged["web-app-foo"]["services"]["foo"]["image"],
"override",
)
class TestRecursiveMetaWalk(unittest.TestCase):
"""`_build_role_base_config` walks the whole `meta/` tree: every
`meta/**/*.yml` lands at the matching nested path under
`applications.<app>` (the live `meta/addons/` case plus any deeper
nesting), while `main.yml`/`variants.yml` stay out as build mechanism.
"""
def setUp(self) -> None:
_reset_cache_for_tests()
def test_nested_dirs_map_to_nested_paths(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_roles(Path(tmp))
role = roles / "web-app-foo"
_write(role / "meta" / "extra" / "widget.yml", "color: blue\n")
_write(role / "meta" / "a" / "b" / "c.yml", "deep: true\n")
cfg = cache_apps.get_application_defaults(roles_dir=roles)["web-app-foo"]
self.assertEqual(cfg["extra"]["widget"], {"color": "blue"})
self.assertEqual(cfg["a"]["b"]["c"], {"deep": True})
def test_main_and_variants_yml_stay_out_of_payload(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_roles(Path(tmp))
role = roles / "web-app-foo"
_write(role / ROLE_FILE_META_MAIN, "galaxy_info:\n description: x\n")
_write(role / ROLE_FILE_META_VARIANTS, "- services: {foo: {image: alt}}\n")
cfg = cache_apps.get_application_defaults(roles_dir=roles)["web-app-foo"]
self.assertNotIn("main", cfg)
self.assertNotIn("variants", cfg)
class TestVolumesFoldedRawIntoMerged(unittest.TestCase):
"""`get_merged_applications` re-attaches the raw `meta/volumes.yml` subtree
onto `applications.<app>.volumes` AFTER the templar render, so the merged
SPOT carries volumes with their Jinja `source:` strings unrendered.
"""
def setUp(self) -> None:
_reset_cache_for_tests()
def test_volumes_present_and_raw(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_roles(Path(tmp))
_write(
roles / "web-app-foo" / ROLE_FILE_META_VOLUMES,
"""
data:
type: volume
source: "{{ lookup('container', 'web-app-foo', 'x') }}"
""",
)
merged = cache_apps.get_merged_applications(
variables={}, roles_dir=roles, templar=None
)
vols = merged["web-app-foo"]["volumes"]
self.assertEqual(
vols["data"]["source"],
"{{ lookup('container', 'web-app-foo', 'x') }}",
)
def test_volumes_absent_when_role_has_none(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_roles(Path(tmp))
merged = cache_apps.get_merged_applications(
variables={}, roles_dir=roles, templar=None
)
self.assertNotIn("volumes", merged["web-app-foo"])
class TestApplicationsImportableWithoutAnsible(unittest.TestCase):
"""The CI runner-host CLI path
(`cli.administration.deploy.development.init` -> `plan_dev_inventory_matrix` ->
`get_variants`) MUST stay ansible-free. CI run 24935979190 broke
because `_build_variants` instantiated `ApplicationGidLookup`,
pulling `ansible.plugins.lookup.LookupBase` at call time. This
test pins both the import-time AND call-time invariants by
spawning a fresh subprocess so namespace-package / sys.modules
state cannot leak across tests.
"""
def test_module_imports_and_get_variants_callable_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.applications import get_variants\n"
"assert callable(get_variants)\n"
"from utils.cache.base import ROLES_DIR\n"
"v = get_variants(roles_dir=ROLES_DIR)\n"
"assert len(v) > 0\n"
"print('OK', len(v))\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)
if __name__ == "__main__":
unittest.main()