Files
computer-playbook/tests/unit/python/plugins/lookup/test_docker_cards.py
T
kevinveenbirkenbachandClaude Opus 5.5 3ef937a9d8 feat(web-app-dashboard): move the tile switch to services.dashboard.display
The dashboard read `display` from the role's meta/info.yml, which no inventory can override, so a host could not hide a tile. docker_cards now skips a card when `services.dashboard.display` in the merged applications is false, and absent still means shown; a non-bool value fails loudly. roles_with_service drops the same role from the dashboard tile-reachability targets so the Playwright spec does not wait for a hidden tile, and the inventory validator accepts `services.dashboard.display` for every role that declares `services.dashboard`, since no role default carries it. `display` leaves the meta/info.yml allowlist, web-app-jellyfin drops its redundant `display: true`, and base.md and layout.md document the new switch.

Real lookups with web-app-gitea set to display false render the Dashboard and Jellyfin cards and only jellyfin as a tile target, and all three without the override; the nine tests for the new behaviour fail against the old code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 17:46:03 +01:00

459 lines
16 KiB
Python

import shutil
import sys
import tempfile
import unittest
import unittest.mock as mock
from pathlib import Path
from ansible.errors import AnsibleError
from jinja2 import Environment, StrictUndefined, select_autoescape
sys.path.insert(
0,
str(
Path(str(Path(__file__).parent))
/ "../../../../../roles/web-app-dashboard/lookup_plugins"
),
)
import docker_cards as docker_cards_module
from docker_cards import LookupModule
from utils.roles.mapping import (
ROLE_FILE_META_INFO,
ROLE_FILE_META_MAIN,
ROLE_FILE_VARS_MAIN,
)
def _ansible_bool(value):
"""
Minimal Ansible-like bool filter for unit tests.
Mirrors common Ansible truthy/falsey handling for strings.
"""
if isinstance(value, bool):
return value
if value is None:
return False
if isinstance(value, (int, float)):
return value != 0
if isinstance(value, str):
v = value.strip().lower()
if v in {"y", "yes", "true", "on", "1"}:
return True
if v in {"n", "no", "false", "off", "0", ""}:
return False
return bool(value)
class DummyTemplar:
"""
Small, deterministic templating stub for unit tests.
It is intentionally minimal: only supports rendering Jinja strings
and provides an Ansible-like `bool` filter.
"""
def __init__(self, variables):
self._vars = variables
self._env = Environment(
undefined=StrictUndefined, autoescape=select_autoescape()
)
self._env.filters["bool"] = _ansible_bool
def template(self, value):
if value is None:
return value
if not isinstance(value, str):
return value
if "{{" in value and "}}" in value:
tmpl = self._env.from_string(value)
return tmpl.render(**self._vars)
return value
class DummyTlsResolveLookup:
"""
Deterministic tls stub.
Emulates: lookup('tls', app_id, 'url.base') -> 'http(s)://domain/'
"""
def __init__(self, templar):
self._templar = templar
def run(self, terms, variables=None, **kwargs):
variables = variables or {}
if not terms or len(terms) not in (1, 2):
raise AnsibleError(
"dummy tls: terms must be [app_id] or [app_id, want_path]"
)
app_id = str(terms[0]).strip()
if not app_id:
raise AnsibleError("dummy tls: empty term")
want = ""
if len(terms) == 2:
want = str(terms[1]).strip()
domains = variables.get("domains", {})
if app_id not in domains:
raise AnsibleError(f"dummy tls: app_id '{app_id}' not in domains")
domain_val = domains[app_id]
if isinstance(domain_val, list):
domain = domain_val[0] if domain_val else ""
elif isinstance(domain_val, dict):
domain = next(iter(domain_val.values()), "")
else:
domain = domain_val
domain = self._templar.template(domain).strip() if domain else ""
if not domain:
raise AnsibleError(f"dummy tls: empty domain for '{app_id}'")
tls_enabled = _ansible_bool(variables.get("TLS_ENABLED", True))
scheme = "https" if tls_enabled else "http"
base_url = f"{scheme}://{domain}/"
if want and want != "url.base":
raise AnsibleError(f"dummy tls: unsupported want_path='{want}'")
return [base_url]
class DummyCanonicalUrlLookup:
"""
Deterministic canonical_url stub.
Emulates: lookup('canonical_url', app_id, key) -> 'http(s)://<domains[app_id][key]>'
"""
def run(self, terms, variables=None, **kwargs):
variables = variables or {}
app_id, key = terms
app_domains = variables.get("domains", {}).get(app_id)
if not isinstance(app_domains, dict) or key not in app_domains:
raise AnsibleError(
f"dummy canonical_url: '{app_id}' has no canonical '{key}'"
)
scheme = (
"https" if _ansible_bool(variables.get("TLS_ENABLED", True)) else "http"
)
return [f"{scheme}://{app_domains[key]}"]
class TestDockerCardsLookup(unittest.TestCase):
def setUp(self):
self.test_roles_dir = tempfile.mkdtemp(prefix="test_roles_")
self.role_name = "web-app-dashboard"
self.role_dir = str(Path(self.test_roles_dir) / self.role_name)
Path(str(Path(self.role_dir) / "meta")).mkdir(parents=True)
Path(str(Path(self.role_dir) / "vars")).mkdir(parents=True)
vars_main = str(Path(self.role_dir) / ROLE_FILE_VARS_MAIN)
with Path(vars_main).open("w", encoding="utf-8") as f:
f.write("application_id: portfolio\n")
readme_path = str(Path(self.role_dir) / "README.md")
with Path(readme_path).open("w", encoding="utf-8") as f:
f.write("# Portfolio Application\nThis is a sample portfolio role.")
meta_main_path = str(Path(self.role_dir) / ROLE_FILE_META_MAIN)
meta_yaml = """
galaxy_info:
description: "A role for deploying a portfolio application."
"""
with Path(meta_main_path).open("w", encoding="utf-8") as f:
f.write(meta_yaml)
meta_info_path = str(Path(self.role_dir) / ROLE_FILE_META_INFO)
info_yaml = """
logo:
class: fa-solid fa-briefcase
"""
with Path(meta_info_path).open("w", encoding="utf-8") as f:
f.write(info_yaml)
self._orig_lookup_get = docker_cards_module.lookup_loader.get
class _VarsLookup:
def __init__(self, key):
self._key = key
def run(self, terms, variables=None, **kwargs):
return [(variables or {}).get(self._key, {})]
def _patched_get(name, loader=None, templar=None):
if name == "applications":
return _VarsLookup("applications")
if name == "domains":
return _VarsLookup("domains")
if name == "tls":
return DummyTlsResolveLookup(templar)
if name == "canonical_url":
return DummyCanonicalUrlLookup()
raise AnsibleError(f"Unexpected lookup requested: {name}")
docker_cards_module.lookup_loader.get = _patched_get
def tearDown(self):
docker_cards_module.lookup_loader.get = self._orig_lookup_get
shutil.rmtree(self.test_roles_dir)
def _base_fake_variables(self):
return {
"domains": {"portfolio": "myportfolio.com"},
"applications": {
"portfolio": {"services": {"dashboard": {"enabled": True}}}
},
"group_names": ["portfolio"],
"TLS_ENABLED": True,
"TLS_MODE": "letsencrypt",
"LETSENCRYPT_BASE_PATH": "/etc/letsencrypt",
"LETSENCRYPT_LIVE_PATH": "/etc/letsencrypt/live",
}
def _run_lookup(self, lookup_module, fake_variables):
lookup_module._templar = DummyTemplar(fake_variables)
lookup_module._loader = mock.MagicMock()
return lookup_module.run([self.test_roles_dir], variables=fake_variables)
def test_lookup_when_group_includes_application_id(self):
lookup_module = LookupModule()
fake_variables = self._base_fake_variables()
fake_variables["TLS_ENABLED"] = True
result = self._run_lookup(lookup_module, fake_variables)
self.assertIsInstance(result, list)
self.assertEqual(len(result), 1)
cards = result[0]
self.assertIsInstance(cards, list)
self.assertEqual(len(cards), 1)
card = cards[0]
self.assertEqual(card["title"], "Portfolio Application")
self.assertEqual(card["text"], "A role for deploying a portfolio application.")
self.assertEqual(card["icon"]["class"], "fa-solid fa-briefcase")
self.assertEqual(card["url"], "https://myportfolio.com")
self.assertTrue(card["iframe"])
def test_iframe_flag_defaults_to_enabled(self):
lookup_module = LookupModule()
fake_variables = self._base_fake_variables()
result = self._run_lookup(lookup_module, fake_variables)
self.assertTrue(result[0][0]["iframe"])
def test_iframe_flag_false_decouples_from_enabled(self):
lookup_module = LookupModule()
fake_variables = self._base_fake_variables()
fake_variables["applications"]["portfolio"]["services"]["dashboard"][
"iframe"
] = False
result = self._run_lookup(lookup_module, fake_variables)
cards = result[0]
self.assertEqual(len(cards), 1)
self.assertFalse(cards[0]["iframe"])
def test_lookup_url_uses_https_when_tls_enabled_true(self):
lookup_module = LookupModule()
fake_variables = self._base_fake_variables()
fake_variables["TLS_ENABLED"] = True
result = self._run_lookup(lookup_module, fake_variables)
self.assertEqual(result[0][0]["url"], "https://myportfolio.com")
def test_lookup_url_uses_http_when_tls_enabled_false(self):
lookup_module = LookupModule()
fake_variables = self._base_fake_variables()
fake_variables["TLS_ENABLED"] = False
result = self._run_lookup(lookup_module, fake_variables)
self.assertEqual(result[0][0]["url"], "http://myportfolio.com")
def test_lookup_when_group_excludes_application_id(self):
lookup_module = LookupModule()
fake_variables = self._base_fake_variables()
fake_variables["group_names"] = []
result = self._run_lookup(lookup_module, fake_variables)
self.assertEqual(len(result), 1)
self.assertEqual(len(result[0]), 0)
def test_lookup_url_renders_domain_url_jinja(self):
lookup_module = LookupModule()
fake_variables = self._base_fake_variables()
fake_variables["domains"] = {"portfolio": "{{ DOMAIN_PRIMARY }}"}
fake_variables["DOMAIN_PRIMARY"] = "myportfolio.com"
fake_variables["TLS_ENABLED"] = True
result = self._run_lookup(lookup_module, fake_variables)
self.assertEqual(result[0][0]["url"], "https://myportfolio.com")
def test_lookup_url_https_when_tls_enabled_is_string(self):
lookup_module = LookupModule()
fake_variables = self._base_fake_variables()
fake_variables["TLS_ENABLED"] = "true"
result = self._run_lookup(lookup_module, fake_variables)
self.assertEqual(result[0][0]["url"], "https://myportfolio.com")
def test_lookup_url_http_when_tls_enabled_is_string(self):
lookup_module = LookupModule()
fake_variables = self._base_fake_variables()
fake_variables["TLS_ENABLED"] = "false"
result = self._run_lookup(lookup_module, fake_variables)
self.assertEqual(result[0][0]["url"], "http://myportfolio.com")
def _card_target_variables(self, dashboard):
fake_variables = self._base_fake_variables()
fake_variables["domains"] = {
"portfolio": {
"www": "myportfolio.com",
"client": "client.myportfolio.com",
}
}
fake_variables["applications"]["portfolio"]["services"]["dashboard"] = dashboard
return fake_variables
def test_card_url_targets_the_card_key(self):
fake_variables = self._card_target_variables(
{"enabled": True, "card": "client"}
)
result = self._run_lookup(LookupModule(), fake_variables)
self.assertEqual(result[0][0]["url"], "https://client.myportfolio.com")
def test_card_url_without_card_key_targets_the_primary_domain(self):
fake_variables = self._card_target_variables({"enabled": True})
result = self._run_lookup(LookupModule(), fake_variables)
self.assertEqual(result[0][0]["url"], "https://myportfolio.com")
def test_unknown_card_key_fails_loudly(self):
fake_variables = self._card_target_variables({"enabled": True, "card": "nope"})
with self.assertRaisesRegex(AnsibleError, "nope"):
self._run_lookup(LookupModule(), fake_variables)
def _add_role(self, role_name, application_id, info_yaml):
role_dir = Path(self.test_roles_dir) / role_name
(role_dir / "meta").mkdir(parents=True)
(role_dir / "vars").mkdir(parents=True)
(role_dir / ROLE_FILE_VARS_MAIN).write_text(
f"application_id: {application_id}\n", encoding="utf-8"
)
(role_dir / "README.md").write_text(f"# {role_name}\n", encoding="utf-8")
(role_dir / ROLE_FILE_META_MAIN).write_text(
'galaxy_info:\n description: "Second role."\n', encoding="utf-8"
)
(role_dir / ROLE_FILE_META_INFO).write_text(info_yaml, encoding="utf-8")
def _two_role_variables(self):
self._add_role("web-app-other", "other", "logo:\n class: fa-solid fa-cube\n")
fake_variables = self._base_fake_variables()
fake_variables["domains"]["other"] = "other.example.com"
fake_variables["applications"]["other"] = {
"services": {"dashboard": {"enabled": True}}
}
fake_variables["group_names"] = ["portfolio", "other"]
return fake_variables
def test_display_false_skips_the_card(self):
fake_variables = self._base_fake_variables()
fake_variables["applications"]["portfolio"]["services"]["dashboard"][
"display"
] = False
result = self._run_lookup(LookupModule(), fake_variables)
self.assertEqual(result, [[]])
def test_absent_display_shows_the_card(self):
fake_variables = self._base_fake_variables()
self.assertNotIn(
"display",
fake_variables["applications"]["portfolio"]["services"]["dashboard"],
)
result = self._run_lookup(LookupModule(), fake_variables)
self.assertEqual([c["title"] for c in result[0]], ["Portfolio Application"])
def test_display_true_shows_the_card(self):
fake_variables = self._base_fake_variables()
fake_variables["applications"]["portfolio"]["services"]["dashboard"][
"display"
] = True
result = self._run_lookup(LookupModule(), fake_variables)
self.assertEqual([c["title"] for c in result[0]], ["Portfolio Application"])
def test_display_false_hides_only_its_own_card(self):
fake_variables = self._two_role_variables()
fake_variables["applications"]["other"]["services"]["dashboard"]["display"] = (
False
)
result = self._run_lookup(LookupModule(), fake_variables)
self.assertEqual([c["title"] for c in result[0]], ["Portfolio Application"])
def test_both_cards_render_without_display(self):
fake_variables = self._two_role_variables()
result = self._run_lookup(LookupModule(), fake_variables)
self.assertEqual(
[c["title"] for c in result[0]],
["Portfolio Application", "web-app-other"],
)
def test_info_display_false_no_longer_hides_the_card(self):
meta_info_path = Path(self.role_dir) / ROLE_FILE_META_INFO
meta_info_path.write_text(
"logo:\n class: fa-solid fa-briefcase\ndisplay: false\n",
encoding="utf-8",
)
result = self._run_lookup(LookupModule(), self._base_fake_variables())
self.assertEqual([c["title"] for c in result[0]], ["Portfolio Application"])
def test_non_bool_display_fails_loudly(self):
fake_variables = self._base_fake_variables()
fake_variables["applications"]["portfolio"]["services"]["dashboard"][
"display"
] = "false"
with self.assertRaisesRegex(AnsibleError, "portfolio"):
self._run_lookup(LookupModule(), fake_variables)
if __name__ == "__main__":
unittest.main()