Files
computer-playbook/tests/unit/python/utils/cache/test_applications.py
T
kevinveenbirkenbachandClaude Opus 5 575a5a87ac refactor(layout): sort shipped scripts by language and split the unit suite
Every script a role ships now lives under `files/<language>/`, and the unit
suite is split the same way: `tests/unit/python/` beside new sibling trees for
the other languages. `tests/lint/filesystem/test_role_files_layout.py` keeps it
that way; directories whose shape is dictated from outside carry a `.nocheck`
naming the constraint (Docker build contexts, WordPress mu-plugins, the Joomla
extension, Odoo addons, Playwright suites, the `files/test/test.sh` harness
that `test-e2e-cli` resolves generically).

This cannot be applied in halves. Moving a file and repointing what names it
are the same change: 14 references resolve only at deploy time, so the tree is
green either before or after, never between.

Those 14 were found by static resolution over every reference class, not by the
test suite -- a full `make quality-high` was green while all of them were
broken:

  * 7 `src:` values built from a role variable holding a bare filename
    (svc-bkp-remote-2-local, decidim, espocrm, friendica, joomla, pixelfed x2).
    The variable doubles as the destination name inside the container, so the
    language segment belongs at the call site, not in the variable.
  * `sys-service/tasks/04_base.yml` resolved every consumer's systemd script
    through `first_found(..., errors='strict')` over `files/script.{sh,py}`.
    18 roles ship exactly that and have no template fallback: the play would
    have died on each.
  * `sys-front-inj-javascript` fell back to `<role>/files/javascript.js`; the
    interface now reads `files/javascript/javascript.js`.
  * `sys-front-inj-logout/templates/head_sub.j2` built the cache-busting mtime
    path through `path_absolute(... 'files', ...)`.
  * 4 `ansible.builtin.script:` invocations naming a bare command
    (pull.py, prune.sh, set_postgres_superuser_password.sh, ganesha-serve.sh).
  * `dev-python/files/shell/install.sh` pointed at a sibling role's script.

Depth-dependent paths inside the moved trees were bumped accordingly: 59
`parents[N]` computations, 12 relative climbs, and the per-role recover CLI,
whose path `cli/administration/recover/recoverers.py` still assembled from the
pre-move location.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:31:09 +02:00

454 lines
16 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)
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)
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)
self.assertEqual(overrides["web-app-foo"][0], {})
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()