Files
computer-playbook/utils/cache/users/placeholders.py
T
kevinveenbirkenbachandClaude Opus 4.7 02cf3c5e9e fix(users-cache): substitute ORGANIZATION + SOFTWARE_NAME placeholders
Two scalar group-vars are referenced verbatim in
`roles/user-*/meta/users.yml` (`lastname: "{{ ORGANIZATION }}"`,
`description: "owned by {{ SOFTWARE_NAME }}"`). Ansible 2.19's
TrustedAsTemplate gate silently leaves those untagged Jinja
literals unrendered, so downstream consumers received the raw
`{{ ORGANIZATION }}` string. Keycloak rejected the resulting user
updates with `error-person-name-invalid-character` because `{` /
`}` are not valid in a person name.

Add `substitute_scalar_placeholders` next to the existing
`substitute_primary_domain_placeholder`. Both helpers run from
`get_merged_users` immediately before `_render_with_templar`, so the
cache returns fully-resolved scalars regardless of the templar's
trust gate.

Also restructure: move the cache into a `utils.cache.users` package
so the placeholder helpers no longer need a redundant `users_`
prefix:

  utils/cache/
    users/
      __init__.py     was utils/cache/users.py (public API
                      `get_user_defaults` / `get_merged_users` is
                      unchanged; consumers using
                      `from utils.cache.users import ...` keep
                      working via package __init__).
      placeholders.py the substitution helpers, dropping the
                      redundant `users_` prefix.

  tests/unit/utils/cache/
    users/
      __init__.py
      test_module.py        was test_users_module.py
      test_placeholders.py  new: 15 unit tests covering both
                            helpers (nested-structure walk, URL /
                            port handling for DOMAIN_PRIMARY,
                            Jinja-in-raw-value resolution, empty /
                            missing skip paths, and a guardrail
                            pinning the current
                            `_SCALAR_USER_PLACEHOLDERS` tuple).

Update README links under `utils/cache/` and
`docs/contributing/artefact/files/plugins/lookup/users.md` to point
at the new package path.

Placeholders covered today: ORGANIZATION, SOFTWARE_NAME. Extend the
tuple in `utils/cache/users/placeholders.py` when a new scalar
group-var appears in user metas.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 13:34:06 +02:00

108 lines
3.4 KiB
Python

"""Eager placeholder substitution for cached user dicts.
Ansible 2.19's TrustedAsTemplate gate leaves untagged Jinja silently
unrendered, so values like `lastname: "{{ ORGANIZATION }}"` reach
downstream tasks as their literal string. These helpers substitute a
small set of group-vars scalars into the merged users dict before the
templar render pass, so consumers always see resolved values.
Extend `_SCALAR_USER_PLACEHOLDERS` (or call sites) when new scalar
group-vars appear in `roles/user-*/meta/users.yml` fields like
`firstname`, `lastname`, or `description`.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from urllib.parse import urlparse
# Group-vars scalars that the users cache embeds as `{{ VAR }}` placeholders.
_SCALAR_USER_PLACEHOLDERS: tuple[str, ...] = (
"ORGANIZATION",
"SOFTWARE_NAME",
)
def substitute_primary_domain_placeholder(
users: dict[str, Any],
variables: dict[str, Any],
*,
templar: Any,
) -> dict[str, Any]:
raw = variables.get("DOMAIN_PRIMARY")
if not raw:
return users
text = str(raw).strip()
if not text:
return users
if "{{" in text or "{%" in text:
from utils.templating.ansible import _templar_render_best_effort
text = str(_templar_render_best_effort(templar, text, dict(variables))).strip()
if "://" in text:
parsed = urlparse(text)
text = parsed.hostname or text
text = text.split("/", 1)[0].split(":", 1)[0].strip()
if not text or "{{" in text or "{%" in text:
return users
placeholder = "{{ DOMAIN_PRIMARY }}"
def _walk(value: Any) -> Any:
if isinstance(value, str):
return value.replace(placeholder, text) if placeholder in value else value
if isinstance(value, Mapping):
return {k: _walk(v) for k, v in value.items()}
if isinstance(value, list):
return [_walk(v) for v in value]
if isinstance(value, tuple):
return tuple(_walk(v) for v in value)
return value
return _walk(users)
def substitute_scalar_placeholders(
users: dict[str, Any],
variables: dict[str, Any],
*,
templar: Any,
) -> dict[str, Any]:
from utils.templating.ansible import _templar_render_best_effort
resolved: dict[str, str] = {}
for var in _SCALAR_USER_PLACEHOLDERS:
raw = variables.get(var)
if raw is None:
continue
text = str(raw).strip()
if not text:
continue
if "{{" in text or "{%" in text:
text = str(
_templar_render_best_effort(templar, text, dict(variables))
).strip()
if "{{" in text or "{%" in text:
continue
resolved[f"{{{{ {var} }}}}"] = text
if not resolved:
return users
def _walk(value: Any) -> Any:
if isinstance(value, str):
out = value
for placeholder, replacement in resolved.items():
if placeholder in out:
out = out.replace(placeholder, replacement)
return out
if isinstance(value, Mapping):
return {k: _walk(v) for k, v in value.items()}
if isinstance(value, list):
return [_walk(v) for v in value]
if isinstance(value, tuple):
return tuple(_walk(v) for v in value)
return value
return _walk(users)