Files
computer-playbook/utils/templating/ansible.py

453 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.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:
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:
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