mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
453 lines
14 KiB
Python
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
|