mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
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:
1 parent
c277eaff8e
commit
70786aa126
9 files changed
+208
-21
No files matched your search
@@ -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
@@ -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()
|
||||
Reference in new issue
Block a user