mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 12:17:13 +00:00
Fourteen real conflicts plus the 66 generated READMEs.
utils/cache/files.py and its test: both sides added a skip-dir rule, resolved
as a union so the root `build/` prune and the per-root walk key coexist.
plugins/lookup/database.py and postgres_libraries.py: main's added lookups kept,
with get_entity_name renamed to entity_name as this branch renamed it.
roles/web-app-{mailu,matrix}/meta/services.yml: main's `covers: [email]` and its
MAIL_PROVIDER-gated email entry, plus this branch's gitlab/jira/n8n/jitsi
consumers, which the merge base carried on neither side.
group_vars/all/17_resource.yml: this branch's lookup('resource', ...) refactor;
main's single added literal is already covered by _POSTGRES_MAX_LOCKS_PER_TRANSACTION.
docs/requirements/035-mcp-proxy-expansion.md: this branch moved the design into
ADR 0003, so the requirement keeps only the user story and the criteria. Main's
delta inside the moved block was two new roles, carried into the ADR audit:
web-app-stalwart as adapter_candidate, svc-net-tor-smtp as privileged_control_plane.
The no_surface total said 99 while its own subcategories summed to 112.
inventories/development/default.yml: biber keeps no explicit email. Both sides fixed
the same CI failure; this branch fixed it in _apply_account_defaults, which renders
biber@{{ DOMAIN_PRIMARY }} (utils/cache/users/__init__.py:68) — byte-identical to
the address main hardcoded, so the inventory line is a second copy of one fact.
roles/web-app-{gitlab,keycloak}/files/playwright/playwright.spec.js and
roles/web-app-gitlab/templates/playwright.env.j2: union of both import sets, with
requireDotenvValue kept over decodeDotenvQuotedValue for CANONICAL_DOMAIN because
an empty value must fail rather than match every substring.
The 66 role READMEs take this side: aceb5295ef moved Cosmos, Quick Setup and
Credits to the documentation build, and test_readme.py now rejects them in a
committed README. Main's prose edits inside them are carried over by hand (the
Mailu deprecation banner, Mailu -> Stalwart in erpnext and zammad), and the two
READMEs main added fresh are stripped of the three generated sections.
roles/web-app-sphinx/README.md stays deleted; 6eb9a59d14 merged that role into
web-app-docs. The root README's generated role table was regenerated from the
merged metadata.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
458 lines
14 KiB
Python
458 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import os
|
|
import posixpath
|
|
import re
|
|
from collections.abc import Mapping, Sequence
|
|
from typing import Any
|
|
|
|
from ansible.errors import AnsibleError
|
|
|
|
from utils.cache.errors import is_unresolvable
|
|
from utils.manager.value_generator import ValueGenerator
|
|
from utils.templating.expr import (
|
|
find_top_level_op,
|
|
is_paren_wrapped,
|
|
split_list_items,
|
|
split_top_level,
|
|
)
|
|
|
|
try:
|
|
from ansible._internal._datatag._tags import TrustedAsTemplate
|
|
except Exception:
|
|
TrustedAsTemplate = None
|
|
|
|
|
|
def to_plain(obj: Any) -> Any:
|
|
"""Convert Ansible/Jinja proxy types into plain Python so PyYAML can serialize."""
|
|
|
|
if obj is None:
|
|
return None
|
|
|
|
if isinstance(obj, str):
|
|
return str(obj)
|
|
|
|
if isinstance(obj, bool):
|
|
return bool(obj)
|
|
|
|
if isinstance(obj, int):
|
|
return int(obj)
|
|
|
|
if isinstance(obj, float):
|
|
return float(obj)
|
|
|
|
if isinstance(obj, Mapping):
|
|
return {str(to_plain(k)): to_plain(v) for k, v in obj.items()}
|
|
|
|
if isinstance(obj, Sequence) and not isinstance(obj, (str, bytes, bytearray)):
|
|
return [to_plain(x) for x in obj]
|
|
|
|
return str(obj)
|
|
|
|
|
|
def _trust_as_template(s: str) -> Any:
|
|
"""Tag a string as trusted for Jinja templating in Ansible 2.19+.
|
|
|
|
Ansible's templar refuses to render strings that aren't tagged via
|
|
TrustedAsTemplate. YAML loaded directly with yaml.safe_load lacks this
|
|
tag, so embedded {{ ... }} returns unchanged. Tagging restores rendering.
|
|
"""
|
|
if TrustedAsTemplate is None or not isinstance(s, str):
|
|
return s
|
|
try:
|
|
return TrustedAsTemplate().tag(s)
|
|
except Exception:
|
|
return s
|
|
|
|
|
|
_RE_LOOKUP_ENV_HEAD = re.compile(
|
|
r"""^lookup\(\s*['"]env['"]\s*,\s*['"]([^'"]+)['"]\s*\)\s*""",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
_RE_ANY_LOOKUP = re.compile(r"""\blookup\s*\(""", re.IGNORECASE)
|
|
|
|
_RE_VARPATH = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)*$")
|
|
_RE_INT_LITERAL = re.compile(r"^-?\d+$")
|
|
_RE_FLOAT_LITERAL = re.compile(r"^-?\d+\.\d+$")
|
|
_RE_JINJA_BLOCK = re.compile(r"\{\{\s*(.*?)\s*\}\}", re.DOTALL)
|
|
|
|
|
|
def _eval_list_literal(head: str, variables: dict) -> list[str]:
|
|
"""
|
|
Evaluate a minimal Jinja list literal like:
|
|
[ DIR_BIN, 'ca-inject' ]
|
|
|
|
Items supported:
|
|
- quoted strings ('x' / "x")
|
|
- varpaths (FOO / FOO.bar)
|
|
|
|
This is intentionally minimal and safe for fallback rendering.
|
|
"""
|
|
h = head.strip()
|
|
if not (h.startswith("[") and h.endswith("]")):
|
|
raise ValueError("not a list literal")
|
|
|
|
inner = h[1:-1].strip()
|
|
if not inner:
|
|
return []
|
|
|
|
out: list[str] = []
|
|
for tok in split_list_items(inner):
|
|
t = tok.strip()
|
|
if not t:
|
|
continue
|
|
|
|
if t.startswith(("'", '"')) and t.endswith(t[0]) and len(t) >= 2:
|
|
out.append(t[1:-1])
|
|
continue
|
|
|
|
if not _RE_VARPATH.match(t):
|
|
raise ValueError(f"unsupported expression: {head}")
|
|
|
|
try:
|
|
v = _get_by_path(variables, t)
|
|
except KeyError:
|
|
v = None
|
|
|
|
out.append("" if v is None else str(v))
|
|
|
|
return out
|
|
|
|
|
|
def _get_by_path(variables: dict, path: str) -> Any:
|
|
cur: Any = variables
|
|
for part in path.split("."):
|
|
if not isinstance(cur, dict) or part not in cur:
|
|
raise KeyError(path)
|
|
cur = cur[part]
|
|
return cur
|
|
|
|
|
|
def _apply_filter(value: Any, filt: str) -> Any:
|
|
f = filt.strip()
|
|
|
|
if f == "lower":
|
|
return str(value).lower()
|
|
|
|
if f == "upper":
|
|
return str(value).upper()
|
|
|
|
if f == "path_join":
|
|
if not isinstance(value, (list, tuple)):
|
|
return value
|
|
parts = [str(x) for x in value if str(x) != ""]
|
|
return posixpath.join(*parts) if parts else ""
|
|
|
|
if f == "strong_password":
|
|
try:
|
|
length = int(value)
|
|
except (TypeError, ValueError):
|
|
length = 32
|
|
return ValueGenerator().generate_strong_password(length)
|
|
|
|
if f.startswith("default(") and f.endswith(")"):
|
|
inner = f[len("default(") : -1].strip()
|
|
|
|
default_val: Any = ""
|
|
if inner.startswith(("'", '"')):
|
|
q = inner[0]
|
|
end = inner.find(q, 1)
|
|
default_val = inner[1:end] if end != -1 else ""
|
|
else:
|
|
default_val = inner.split(",", 1)[0].strip()
|
|
|
|
if value is None:
|
|
return default_val
|
|
if str(value) == "":
|
|
return default_val
|
|
return value
|
|
|
|
return value
|
|
|
|
|
|
def _eval_condition(cond: str, variables: dict) -> bool:
|
|
"""Evaluate a minimal boolean condition: ``A == B`` / ``A != B`` / truthy."""
|
|
for op, test in (("==", str.__eq__), ("!=", str.__ne__)):
|
|
idx = find_top_level_op(cond, op)
|
|
if idx != -1:
|
|
left = _fallback_eval_expr(cond[:idx], variables)
|
|
right = _fallback_eval_expr(cond[idx + len(op) :], variables)
|
|
return bool(test(left, right))
|
|
val = _fallback_eval_expr(cond, variables)
|
|
return val not in ("", "False", "false", "0", "None")
|
|
|
|
|
|
def _fallback_eval_expr(expr: str, variables: dict) -> str:
|
|
"""
|
|
Evaluate a single Jinja expression (no surrounding {{ }}).
|
|
|
|
Supported subset (SAFE fallback only):
|
|
- lookup('env','NAME')
|
|
- VAR / VAR.path
|
|
- list literal: [ VAR, 'literal', ... ]
|
|
- string concatenation: 'a' ~ VAR ~ 'b'
|
|
- inline conditional: A if COND else B (COND: X == 'y' / X != 'y')
|
|
- filters: | lower, | upper, | default('x', true), | path_join
|
|
|
|
Any other lookup(...) must NOT be handled here.
|
|
"""
|
|
expr = expr.strip()
|
|
|
|
if_idx = find_top_level_op(expr, " if ")
|
|
else_idx = find_top_level_op(expr, " else ")
|
|
if if_idx != -1 and else_idx != -1 and if_idx < else_idx:
|
|
a = expr[:if_idx]
|
|
cond = expr[if_idx + len(" if ") : else_idx]
|
|
b = expr[else_idx + len(" else ") :]
|
|
chosen = a if _eval_condition(cond, variables) else b
|
|
return _fallback_eval_expr(chosen, variables)
|
|
|
|
if is_paren_wrapped(expr):
|
|
return _fallback_eval_expr(expr[1:-1], variables)
|
|
|
|
tilde_parts = split_top_level(expr, "~")
|
|
if len(tilde_parts) > 1:
|
|
return "".join(_fallback_eval_expr(p, variables) for p in tilde_parts)
|
|
|
|
if len(expr) >= 2 and expr[0] in "'\"" and expr[-1] == expr[0]:
|
|
return expr[1:-1]
|
|
|
|
parts = [p.strip() for p in expr.split("|")]
|
|
head = parts[0].strip()
|
|
|
|
m = _RE_LOOKUP_ENV_HEAD.match(head)
|
|
if m:
|
|
key = m.group(1)
|
|
val: Any = os.environ.get(key)
|
|
if val is None:
|
|
val = os.environ.get(key.lower(), os.environ.get(key.upper()))
|
|
elif head.startswith("[") and head.endswith("]"):
|
|
val = _eval_list_literal(head, variables)
|
|
elif _RE_INT_LITERAL.match(head):
|
|
val = int(head)
|
|
elif _RE_FLOAT_LITERAL.match(head):
|
|
val = float(head)
|
|
else:
|
|
if not _RE_VARPATH.match(head):
|
|
raise ValueError(f"unsupported expression: {head}")
|
|
try:
|
|
val = _get_by_path(variables, head)
|
|
except KeyError:
|
|
val = None
|
|
|
|
for filt in parts[1:]:
|
|
val = _apply_filter(val, filt)
|
|
|
|
return "" if val is None else str(val)
|
|
|
|
|
|
def _fallback_render_embedded(s: str, variables: dict) -> str:
|
|
def repl(m: re.Match) -> str:
|
|
expr = (m.group(1) or "").strip()
|
|
return _fallback_eval_expr(expr, variables)
|
|
|
|
return _RE_JINJA_BLOCK.sub(repl, s)
|
|
|
|
|
|
def _contains_non_env_lookup(s: str) -> bool:
|
|
"""
|
|
True if any embedded {{ ... }} contains lookup(...) that is NOT lookup('env', ...).
|
|
|
|
IMPORTANT:
|
|
- Allow lookup('env', ...) even when followed by filters, e.g.
|
|
{{ lookup('env','domain') | default('x', true) }}
|
|
"""
|
|
for m in _RE_JINJA_BLOCK.finditer(s):
|
|
expr = (m.group(1) or "").strip()
|
|
if _RE_ANY_LOOKUP.search(expr):
|
|
if _RE_LOOKUP_ENV_HEAD.match(expr):
|
|
continue
|
|
return True
|
|
return False
|
|
|
|
|
|
def _set_templar_var(templar: Any, name: str, value: Any) -> tuple[bool, Any]:
|
|
"""
|
|
Best-effort setter for templar flags across Ansible versions.
|
|
Returns (changed, previous_value).
|
|
"""
|
|
if templar is None or not hasattr(templar, name):
|
|
return False, None
|
|
try:
|
|
prev = getattr(templar, name)
|
|
setattr(templar, name, value)
|
|
except Exception:
|
|
return False, None
|
|
return True, prev
|
|
|
|
|
|
def _templar_render_preserve_type(templar: Any, s: str, variables: dict) -> Any:
|
|
"""
|
|
Type-preserving render for the case where the entire string is a
|
|
single Jinja expression. Unlike `_templar_render_best_effort` (which
|
|
forces ``str(rendered)``), this returns templar's native output —
|
|
so a list/dict-returning ``lookup(...)`` keeps its type.
|
|
|
|
Caller MUST pre-check the input shape; this function does no shape
|
|
detection itself. Returns ``None`` if templar is unavailable or
|
|
raises during render — caller is expected to fall back to the
|
|
string-coerced path in that case.
|
|
"""
|
|
if templar is None:
|
|
return None
|
|
|
|
disable_changed_1, prev_disable_1 = _set_templar_var(
|
|
templar, "disable_lookups", False
|
|
)
|
|
disable_changed_2, prev_disable_2 = _set_templar_var(
|
|
templar, "_disable_lookups", False
|
|
)
|
|
|
|
prev_avail: Any | None = None
|
|
if hasattr(templar, "available_variables"):
|
|
try:
|
|
prev_avail = templar.available_variables
|
|
merged_avail: dict = dict(prev_avail) if prev_avail else {}
|
|
if variables:
|
|
merged_avail.update(variables)
|
|
templar.available_variables = merged_avail
|
|
except Exception:
|
|
prev_avail = None
|
|
|
|
rendered: Any = None
|
|
try:
|
|
try:
|
|
rendered = templar.template(_trust_as_template(s), fail_on_undefined=True)
|
|
except TypeError:
|
|
rendered = templar.template(_trust_as_template(s))
|
|
except Exception as exc:
|
|
if is_unresolvable(exc):
|
|
raise
|
|
rendered = None
|
|
finally:
|
|
if prev_avail is not None and hasattr(templar, "available_variables"):
|
|
with contextlib.suppress(Exception):
|
|
templar.available_variables = prev_avail
|
|
if disable_changed_2:
|
|
with contextlib.suppress(Exception):
|
|
templar._disable_lookups = prev_disable_2
|
|
if disable_changed_1:
|
|
with contextlib.suppress(Exception):
|
|
templar.disable_lookups = prev_disable_1
|
|
|
|
return rendered
|
|
|
|
|
|
def _templar_render_best_effort(templar: Any, s: str, variables: dict) -> str:
|
|
"""
|
|
Render with Ansible templar across versions.
|
|
|
|
Policy:
|
|
- Always try templar first (so ALL lookup(...) can be evaluated properly).
|
|
- If templar returns unchanged while Jinja exists:
|
|
- If string contains non-env lookup(...): DO NOT fallback (leave as-is)
|
|
- Else: fallback is allowed (env + varpaths + simple filters)
|
|
"""
|
|
if templar is None:
|
|
return _fallback_render_embedded(s, variables)
|
|
|
|
prev_avail: Any | None = None
|
|
|
|
disable_changed_1, prev_disable_1 = _set_templar_var(
|
|
templar, "disable_lookups", False
|
|
)
|
|
disable_changed_2, prev_disable_2 = _set_templar_var(
|
|
templar, "_disable_lookups", False
|
|
)
|
|
|
|
if hasattr(templar, "available_variables"):
|
|
try:
|
|
prev_avail = templar.available_variables
|
|
merged_avail: dict = dict(prev_avail) if prev_avail else {}
|
|
if variables:
|
|
merged_avail.update(variables)
|
|
templar.available_variables = merged_avail
|
|
except Exception:
|
|
prev_avail = None
|
|
|
|
rendered: Any = s
|
|
trusted_input = _trust_as_template(s)
|
|
try:
|
|
try:
|
|
rendered = templar.template(trusted_input, fail_on_undefined=True)
|
|
except TypeError:
|
|
rendered = templar.template(trusted_input)
|
|
except Exception as exc:
|
|
if is_unresolvable(exc):
|
|
raise
|
|
rendered = s
|
|
finally:
|
|
if prev_avail is not None and hasattr(templar, "available_variables"):
|
|
with contextlib.suppress(Exception):
|
|
templar.available_variables = prev_avail
|
|
|
|
if disable_changed_2:
|
|
with contextlib.suppress(Exception):
|
|
templar._disable_lookups = prev_disable_2
|
|
if disable_changed_1:
|
|
with contextlib.suppress(Exception):
|
|
templar.disable_lookups = prev_disable_1
|
|
|
|
out_s = "" if rendered is None else str(rendered)
|
|
|
|
if out_s.strip() == s.strip() and ("{{" in s or "{%" in s):
|
|
if _contains_non_env_lookup(s):
|
|
return out_s
|
|
|
|
return _fallback_render_embedded(s, variables)
|
|
|
|
return out_s
|
|
|
|
|
|
def render_ansible_strict(
|
|
*,
|
|
templar: Any,
|
|
raw: Any,
|
|
var_name: str,
|
|
err_prefix: str,
|
|
variables: dict,
|
|
max_rounds: int = 6,
|
|
) -> str:
|
|
"""
|
|
Strict rendering helper for lookup/filter plugins.
|
|
|
|
- Renders via Ansible templar when possible (lookup(), filters, vars).
|
|
- Automatic fallback is enabled for a SAFE subset (env lookup + varpaths + simple filters)
|
|
only when templar can't/won't render and NO non-env lookup(...) is present.
|
|
- Re-renders multiple rounds because intermediate results can still contain Jinja.
|
|
- Hard-fails if output is empty or still contains unresolved Jinja markers.
|
|
"""
|
|
if raw is None:
|
|
raise AnsibleError(f"{err_prefix}: {var_name} resolved to None")
|
|
|
|
s = str(raw)
|
|
|
|
out = s
|
|
for _ in range(max_rounds):
|
|
if ("{{" not in out) and ("{%" not in out):
|
|
break
|
|
out2 = _templar_render_best_effort(templar, out, variables)
|
|
if out2 == out:
|
|
break
|
|
out = out2
|
|
|
|
out = "" if out is None else str(out).strip()
|
|
if not out:
|
|
raise AnsibleError(
|
|
f"{err_prefix}: {var_name} rendered to empty string. Raw: {s}"
|
|
)
|
|
|
|
if ("{{" in out) or ("{%" in out):
|
|
raise AnsibleError(
|
|
f"{err_prefix}: {var_name} still contains unresolved Jinja. Rendered: {out}. Raw: {s}"
|
|
)
|
|
|
|
return out
|