refactor(addons): emit playwright addon gate flags via addon_env_flags lookup

Replace the per-template Jinja loop (which mis-unpacked dict2items and rendered every flag as KEY_ADDON_ENABLED=false, silently skipping every addon spec) with a single lookup('addon_env_flags', application_id). The plugin is the single source of truth for the <ADDON_ID>_ADDON_ENABLED flags consumed by tests/addon-gating.js: env-key derivation matches addon-gating.js exactly, and a flag is true only when the addon is both enabled AND required (required:false addons skip). Switch all six role templates to the lookup, extend the env-file schema lint to recognise whole-line {{ ... }} expressions, and add the plugin unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 4.8 committed 2026-06-21 06:03:06 +02:00
1 parent c277eaff8e
commit 70786aa126
9 files changed
+208 -21

No files matched your search

+86
View File
@@ -0,0 +1,86 @@
from __future__ import annotations
import re
from typing import Any
from ansible.errors import AnsibleError
from ansible.plugins.lookup import LookupBase
from utils.cache.applications import get_merged_applications
from utils.cache.base import _render_with_templar
from utils.roles.applications.config import get
ENV_SUFFIX = "_ADDON_ENABLED"
_TRUE_TOKENS = {"true", "1", "yes", "on", "t", "y"}
def env_key(addon_id: str) -> str:
"""Mirror addon-gating.js envKey(): upper-case, non-alphanumeric runs -> '_'."""
return re.sub(r"[^A-Z0-9]+", "_", str(addon_id).upper()) + ENV_SUFFIX
def _is_enabled(value: Any) -> bool:
if isinstance(value, bool):
return value
return str(value).strip().lower() in _TRUE_TOKENS
class LookupModule(LookupBase):
"""
lookup('addon_env_flags', application_id)
Single source of truth for the per-addon enable flags consumed by the
Playwright addon-gating helper (``tests/addon-gating.js`` ->
``skipUnlessAddonEnabled``). Replaces the error-prone per-template Jinja
loop: it resolves the materialised ``addons`` mapping for ``application_id``
and returns one ``<ADDON_ID>_ADDON_ENABLED=<true|false>`` line per declared
addon (sorted by env key, newline-joined), where the env-key derivation
matches addon-gating.js exactly.
A flag is ``true`` only when the addon is both ``enabled`` AND
``required``. Optional addons (``required: false``) are intentionally
skipped by the spec suite — they are a variant axis, not a guaranteed
surface — so their gate flag is ``false`` even when enabled.
"""
def run(self, terms, variables: dict[str, Any] | None = None, **kwargs):
if not terms or len(terms) != 1:
raise AnsibleError(
"lookup('addon_env_flags', application_id) expects exactly one term."
)
application_id = terms[0]
templar = getattr(self, "_templar", None)
variables = variables or getattr(self._templar, "available_variables", {}) or {}
applications = get_merged_applications(
variables=variables,
roles_dir=kwargs.get("roles_dir"),
templar=templar,
)
addons = get(
applications=applications,
application_id=application_id,
config_path="addons",
strict=False,
default={},
skip_missing_app=True,
)
addons = _render_with_templar(
addons,
templar=templar,
variables=variables,
raw_applications=applications,
)
if not isinstance(addons, dict):
addons = {}
lines = []
for addon_id in sorted(addons, key=env_key):
spec = addons.get(addon_id) if isinstance(addons.get(addon_id), dict) else {}
active = _is_enabled(spec.get("enabled", False)) and _is_enabled(
spec.get("required", False)
)
lines.append(f"{env_key(addon_id)}={'true' if active else 'false'}")
return ["\n".join(lines)]
@@ -31,7 +31,5 @@ OIDC_ISSUER_URL={{ OIDC.CLIENT.ISSUER_URL | dotenv_quote }}
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
{% for _aid, _aval in (lookup("config", application_id, "addons", {}) | dict2items) %}
{{ _aid | upper | regex_replace("[^A-Z0-9]+", "_") }}_ADDON_ENABLED={{ (_aval.enabled | default(false)) | bool | lower }}
{% endfor %}
{{ lookup('addon_env_flags', application_id) }}
@@ -36,7 +36,5 @@ PERSONA_BIBER_BLOCKED=true
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
{% for _aid, _aval in (lookup("config", application_id, "addons", {}) | dict2items) %}
{{ _aid | upper | regex_replace("[^A-Z0-9]+", "_") }}_ADDON_ENABLED={{ (_aval.enabled | default(false)) | bool | lower }}
{% endfor %}
{{ lookup('addon_env_flags', application_id) }}
@@ -36,7 +36,5 @@ PERSONA_BIBER_BLOCKED=true
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
{% for _aid, _aval in (lookup("config", application_id, "addons", {}) | dict2items) %}
{{ _aid | upper | regex_replace("[^A-Z0-9]+", "_") }}_ADDON_ENABLED={{ (_aval.enabled | default(false)) | bool | lower }}
{% endfor %}
{{ lookup('addon_env_flags', application_id) }}
@@ -58,7 +58,5 @@ SEAWEEDFS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.seaweedf
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
{% for _aid, _aval in (lookup("config", application_id, "addons", {}) | dict2items) %}
{{ _aid | upper | regex_replace("[^A-Z0-9]+", "_") }}_ADDON_ENABLED={{ (_aval.enabled | default(false)) | bool | lower }}
{% endfor %}
{{ lookup('addon_env_flags', application_id) }}
@@ -36,7 +36,5 @@ PERSONA_BIBER_BLOCKED=true
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
{% for _aid, _aval in (lookup("config", application_id, "addons", {}) | dict2items) %}
{{ _aid | upper | regex_replace("[^A-Z0-9]+", "_") }}_ADDON_ENABLED={{ (_aval.enabled | default(false)) | bool | lower }}
{% endfor %}
{{ lookup('addon_env_flags', application_id) }}
@@ -45,9 +45,7 @@ RBAC_GROUP_PATH_PREFIX={{ ('/' ~ (RBAC.GROUP.NAME | trim('/')) ~ '/' ~ applicati
SSO_SERVICE_ENABLED={{ lookup('service', 'sso').required | string | lower }}
# Addon gate flags. `tests/addon-gating.js` reads these; one line per declared addon.
{% for _aid, _aval in (lookup("config", application_id, "addons", {}) | dict2items) %}
{{ _aid | upper | regex_replace("[^A-Z0-9]+", "_") }}_ADDON_ENABLED={{ (_aval.enabled | default(false)) | bool | lower }}
{% endfor %}
{{ lookup('addon_env_flags', application_id) }}
SUPER_ADMIN_PASSWORD={{ lookup('config', 'web-app-keycloak', 'credentials.administrator_password') | dotenv_quote }}
+10 -3
View File
@@ -2,8 +2,10 @@
Scope: every committed ``*.env`` and ``*.env.j2`` file, plus the
generated repo-root ``.env`` if present. ``.env.j2`` files are treated
as Jinja templates -- ``{% ... %}`` blocks and ``{# ... #}`` comments
are recognised but not validated as env-file syntax.
as Jinja templates -- ``{% ... %}`` blocks, ``{# ... #}`` comments,
and whole-line ``{{ ... }}`` expressions (which render dynamic
``KEY=value`` content, e.g. ``lookup('addon_env_flags', ...)``) are
recognised but not validated as env-file syntax.
Rules (apply to every line that resolves to a KEY/value pair):
@@ -62,6 +64,7 @@ _KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_ENTRY_RE = re.compile(r"^(?P<key>[A-Za-z_][A-Za-z0-9_]*)=(?P<value>.*)$")
_JINJA_STMT_RE = re.compile(r"^\s*\{%.*%\}\s*$")
_JINJA_COMMENT_RE = re.compile(r"^\s*\{#.*#\}\s*$")
_JINJA_EXPR_RE = re.compile(r"^\s*\{\{.*\}\}\s*$")
_NOCHECK_RE = re.compile(r"^#\s*nocheck\b")
_MAX_COMMENT_LEN = 140
@@ -128,7 +131,11 @@ def validate_env_file(path: Path, *, strict: bool) -> list[Violation]:
# Jinja constructs in templates: reset pending and skip the line.
if is_jinja:
if _JINJA_STMT_RE.match(raw) or _JINJA_COMMENT_RE.match(raw):
if (
_JINJA_STMT_RE.match(raw)
or _JINJA_COMMENT_RE.match(raw)
or _JINJA_EXPR_RE.match(raw)
):
pending = []
continue
# Multi-line Jinja comment opener.
@@ -0,0 +1,106 @@
import unittest
from unittest.mock import patch
from ansible.errors import AnsibleError
from plugins.lookup.addon_env_flags import LookupModule, env_key
class TestEnvKey(unittest.TestCase):
"""env_key() must match addon-gating.js envKey() exactly."""
def test_simple(self):
self.assertEqual(env_key("collectives"), "COLLECTIVES_ADDON_ENABLED")
def test_underscores_preserved(self):
self.assertEqual(env_key("files_bpm"), "FILES_BPM_ADDON_ENABLED")
self.assertEqual(
env_key("integration_gitlab"), "INTEGRATION_GITLAB_ADDON_ENABLED"
)
def test_non_alnum_runs_collapse(self):
self.assertEqual(
env_key("ldap-authenticator"), "LDAP_AUTHENTICATOR_ADDON_ENABLED"
)
self.assertEqual(env_key("a.b-c"), "A_B_C_ADDON_ENABLED")
class TestAddonEnvFlagsLookup(unittest.TestCase):
def setUp(self):
self.lookup = LookupModule()
self.addons = {
"collectives": {"enabled": True, "required": True},
"contacts": {"enabled": True, "required": True},
"optional_on": {"enabled": True, "required": False},
"required_off": {"enabled": False, "required": True},
"off_optional": {"enabled": False, "required": False},
"str_flags": {"enabled": "true", "required": "true"},
"no_required": {"enabled": True},
}
self._patchers = [
patch(
"plugins.lookup.addon_env_flags.get_merged_applications",
return_value={},
),
patch("plugins.lookup.addon_env_flags.get", return_value=self.addons),
patch(
"plugins.lookup.addon_env_flags._render_with_templar",
side_effect=lambda v, **k: v,
),
]
for p in self._patchers:
p.start()
self.addCleanup(lambda: [p.stop() for p in self._patchers])
def _flags(self):
out = self.lookup.run(["web-app-x"], variables={})[0]
return dict(line.split("=", 1) for line in out.splitlines())
def test_enabled_and_required_true(self):
f = self._flags()
self.assertEqual(f["COLLECTIVES_ADDON_ENABLED"], "true")
self.assertEqual(f["CONTACTS_ADDON_ENABLED"], "true")
def test_required_false_skipped_even_if_enabled(self):
self.assertEqual(self._flags()["OPTIONAL_ON_ADDON_ENABLED"], "false")
def test_disabled_is_false(self):
f = self._flags()
self.assertEqual(f["REQUIRED_OFF_ADDON_ENABLED"], "false")
self.assertEqual(f["OFF_OPTIONAL_ADDON_ENABLED"], "false")
def test_missing_required_defaults_false(self):
self.assertEqual(self._flags()["NO_REQUIRED_ADDON_ENABLED"], "false")
def test_string_flags_coerced(self):
self.assertEqual(self._flags()["STR_FLAGS_ADDON_ENABLED"], "true")
def test_one_line_per_addon_sorted_by_key(self):
lines = self.lookup.run(["web-app-x"], variables={})[0].splitlines()
self.assertEqual(len(lines), len(self.addons))
keys = [ln.split("=", 1)[0] for ln in lines]
self.assertEqual(keys, sorted(keys))
def test_no_terms_raises(self):
with self.assertRaises(AnsibleError):
self.lookup.run([], variables={})
def test_too_many_terms_raises(self):
with self.assertRaises(AnsibleError):
self.lookup.run(["a", "b"], variables={})
class TestNoAddons(unittest.TestCase):
def test_empty_addons_yields_empty_string(self):
lookup = LookupModule()
with patch(
"plugins.lookup.addon_env_flags.get_merged_applications", return_value={}
), patch("plugins.lookup.addon_env_flags.get", return_value={}), patch(
"plugins.lookup.addon_env_flags._render_with_templar",
side_effect=lambda v, **k: v,
):
self.assertEqual(lookup.run(["web-app-x"], variables={}), [""])
if __name__ == "__main__":
unittest.main()