mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
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>
108 lines
3.4 KiB
Python
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)
|