Files
computer-playbook/utils/templating/jinja.py
T
kevinveenbirkenbachandClaude Opus 4.7 d3afe0bc81 test(lint): forbid a file from referencing its own path as a string
Hard-coded self-path references rot the moment the file moves: a header
comment ``# utils/foo/bar.py`` at the top of a module, a docstring
mentioning ``utils/foo/bar.py``, or a docs entry linking
``utils/foo/bar.py`` from inside ``utils/foo/bar.py`` itself all silently
desync from reality after the next ``git mv``.

The new lint walks every project file, computes its POSIX-style relative
path from PROJECT_ROOT, and reports any line whose text contains that
path as a substring. Files at the repo root (no ``/`` in their relative
path) and setuptools-generated ``*.egg-info/`` artefacts are exempt.
Per-line opt-out via ``# nocheck: self-path-reference`` on the offending
line, registered in the suppression catalog.

Existing offenders cleaned up:
- 72 redundant ``# <self-path>`` header lines removed across roles,
  cli, plugins, scripts, tests, and utils.
- 4 prometheus templates that told consumers to "update
  <self-template-path>" rewritten to drop the redundant self-reference.
- 8 docstring/comment self-references in cli, plugins, tests, utils,
  and the dashboard oidc.js.j2 header rewritten to describe the layout
  without naming the file's own path.
- 17 legitimate usage examples (shell scripts, test data lists, one
  template self-include doc-block) marked with ``nocheck:
  self-path-reference``.
- ``container.sh`` heredoc usage strings switched to ``${0}`` so the
  marker stays out of the user-visible help output.
- ``healthcheck/http.yml.j2`` doc-block rewritten to prose because
  ``{# ... #}`` Jinja comments cannot host an inner ``{# nocheck #}``
  (the inner ``#}`` closes the outer comment).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 03:53:44 +02:00

181 lines
5.1 KiB
Python

#
# Strict Jinja2 renderer for values that may contain Jinja markers.
#
# Why:
# - In some Ansible lookup evaluation contexts, templating can leave "{{ ... }}"
# untouched without failing.
# - We want hard-fail semantics to avoid leaking Jinja into generated configs.
#
# Additionally:
# - Support nested Jinja-in-vars by rendering multiple passes with a depth limit.
# Example:
# CA_TRUST.cert_host = "{{ CA_ROOT.cert_host }}"
# CA_ROOT.cert_host = "/etc/{{ SOFTWARE_DOMAIN }}/ca/root-ca.crt"
# We want the final result without any "{{ ... }}" left.
from __future__ import annotations
from typing import Any
from ansible.errors import AnsibleError
def as_str(v: Any) -> str:
return "" if v is None else str(v).strip()
def looks_like_jinja(s: str) -> bool:
return ("{{" in s) or ("{%" in s)
def build_render_context(variables: dict[str, Any]) -> tuple[dict, str]:
"""
Build a robust context for rendering Jinja-in-strings.
We intentionally do NOT rely on Ansible's Templar here because in some
lookup evaluation contexts it can silently keep Jinja markers untouched.
Context priority:
1) variables dict
2) hostvars[inventory_hostname] overlays variables (wins on conflicts)
"""
ctx: dict = {}
if isinstance(variables, dict):
ctx.update(variables)
inv_host = as_str(variables.get("inventory_hostname", "")).strip()
hostvars = variables.get("hostvars") if isinstance(variables, dict) else None
if inv_host and isinstance(hostvars, dict):
hv = hostvars.get(inv_host)
if isinstance(hv, dict):
ctx.update(hv)
return ctx, inv_host
def _jinja_env():
try:
from jinja2 import Environment, StrictUndefined, select_autoescape
except Exception as exc:
raise AnsibleError(f"jinja_strict: cannot import jinja2. Error: {exc}") from exc
return Environment(
undefined=StrictUndefined,
autoescape=select_autoescape(["html", "xml"]),
)
def render_jinja2_strict_once(
raw: str, *, ctx: dict, inv_host: str, var_name: str, err_prefix: str
) -> str:
"""
Render ONE pass using plain Jinja2 with StrictUndefined.
Missing vars fail hard.
"""
if not looks_like_jinja(raw):
return raw
env = _jinja_env()
try:
rendered = env.from_string(raw).render(ctx)
except Exception as exc:
raise AnsibleError(
f"{err_prefix}: failed to render {var_name} via strict Jinja2. "
f"inventory_hostname='{inv_host}'. Raw: {raw}. Error: {exc}"
) from exc
return as_str(rendered)
def render_jinja2_strict_recursive(
raw: str,
*,
ctx: dict,
inv_host: str,
var_name: str,
err_prefix: str,
max_passes: int = 5,
) -> str:
"""
Render using strict Jinja2 repeatedly until:
- no Jinja markers remain, OR
- output stops changing, OR
- max_passes is reached
Hard-fail if markers remain after max_passes / stabilization.
"""
if not looks_like_jinja(raw):
return raw
current = raw
for i in range(max_passes):
rendered = render_jinja2_strict_once(
current,
ctx=ctx,
inv_host=inv_host,
var_name=var_name,
err_prefix=err_prefix,
)
# Stable and no markers -> done
if not looks_like_jinja(rendered):
return rendered
# If it didn't change but still has markers -> cannot resolve further
if rendered == current:
raise AnsibleError(
f"{err_prefix}: {var_name} did not fully expand (still contains Jinja markers) "
f"after pass {i + 1}/{max_passes}. inventory_hostname='{inv_host}'. "
f"Value: {rendered}."
)
current = rendered
# max passes reached and still markers
raise AnsibleError(
f"{err_prefix}: {var_name} did not fully expand (still contains Jinja markers) "
f"after {max_passes} passes. inventory_hostname='{inv_host}'. "
f"Raw: {raw} | Rendered: {current}."
)
def render_strict(
value: Any,
*,
variables: dict[str, Any],
var_name: str,
err_prefix: str,
max_passes: int = 5,
) -> str:
"""
Convenience wrapper:
- converts to string
- if Jinja markers exist => render via strict Jinja2 using a robust context
(recursive with depth limit)
- hard-fail if Jinja markers remain
"""
raw = as_str(value)
if not raw:
return raw
if not looks_like_jinja(raw):
return raw
ctx, inv_host = build_render_context(variables)
rendered = render_jinja2_strict_recursive(
raw,
ctx=ctx,
inv_host=inv_host,
var_name=var_name,
err_prefix=err_prefix,
max_passes=max_passes,
)
if looks_like_jinja(rendered):
# Shouldn't happen (recursive renderer already fails), but keep belt+suspenders.
raise AnsibleError(
f"{err_prefix}: {var_name} did not fully expand (still contains Jinja markers). "
f"inventory_hostname='{inv_host}'. Raw: {raw} | Rendered: {rendered}."
)
return rendered