Krikri::VariableSubstitutor::JinjaRenderer
Renders and evaluates task-param Jinja ({{ }} and {% %}) on the
shared krikri-jinja engine, against a lazily prepared variable scope
(JinjaVarResolver).
Constants
Real bug found benchmarking geerlingguy.postgresql: its own
pg_hba.conf.j2 iterates postgresql_hba_entries (a list of
dicts) via {% for client in ... %} ... {{ client.auth_method }} ...{% endfor %}, where each entry's auth_method: field is
itself "{{ postgresql_auth_method }}" - a role default
computed from ANOTHER default, the same recursive-re-templating
shape this codebase has already fixed a dozen-odd times over
for plain scalar variable values. This is a distinct sub-case
none of those fixes covered: only a top-level String value
used to get re-rendered - postgresql_hba_entries itself is an
Array, so it never even reached the raw.is_a?(String) check at
all, and the literal unrendered {{ postgresql_auth_method }}
text landed straight into the rendered config file (PostgreSQL
then refused to start: "invalid authentication method '{{'").
Ansible's own recursive re-templating applies at every
level of a nested structure, not just the outermost value -
walks Array/Hash values recursively, re-rendering every String
leaf that still contains "{{".
Exposed as a class method so FilterEngine and ExpressionEvaluator
apply the identical recursive re-render.
Narrow special case for one specific idiom (found round 755/753,
jtyr.nsswitch/jtyr.motd): some_var: "{{ some_dict.update( other_dict) }}{{ some_dict }}" - call .update() purely for its
mutating side effect, discard its None return, then render the
now-merged dict. Ansible's templar preserves the result as
a genuine dict (_AnsibleLazyTemplateDict, private ansible-core
internals - see KNOWN_MISSING.md's own writeup); replicating
that faithfully (deferred evaluation + type preservation through
the whole vars pipeline) is a major architectural undertaking,
not attempted here. This instead special-cases exactly the
documented shape - both operands bare variable names, no
arbitrary Jinja expression inside .update(...) - by reading
both from the vars store directly, merging (matching Python
dict.update's own shallow-merge, top-level-key-overrides
semantics), and persisting the merge back onto the target
variable (matching Python's real in-place mutation, visible to
any LATER reference of it too - not just this one). Falls
through to the general string-rendering path below for anything
that doesn't match this exact shape.
Filters whose every legitimate implementation serializes the WHOLE
structure it is applied to - every nested string is read by
definition, so Ansible's container materialization forces a deep
strict template of all of them (the fail-on-whole-container-
consumption half of the issue #422 laziness matrix, live-verified
vs 2.19.11: rendering {{ BOOTSTRAP.ssh }} fails with
'ansible_port' is undefined even though a key access does not).
Constructors
Class methods
Ansible's omit inside a CONTAINER removes that entry
rather than leaving a placeholder in it - verified against
ansible-core 2.19.4: {{ [1, v_omit, 3] }} renders [1, 3] and
{{ {'a': 1, 'b': v_omit} }} renders {"a": 1}. The engine builds
such a literal itself (this is the raw-value path every bracket/
dict expression takes), so it sees omit as the ordinary string
this engine represents it with, and kept it - the literal
sentinel text then landed in whatever the list/dict fed.
ExpressionEvaluator's own literal-array/dict builders need the
same treatment separately: the two evaluators share no
implementation, so this bug class has to be fixed once in each
(see AGENTS.md). Only containers are touched here - a bare
scalar omit must survive intact this far, since that is what
tells the caller to drop a whole parameter.
Structured (typed) evaluation of a bare expression against the
LAZY variable scope every other evaluation path uses
(JinjaVarResolver -> prepare_var's recursive re-templating).
Callers must not use KrikriJinja.evaluate_expression directly
with a full vars context: that API converts the whole hash
EAGERLY (KrikriJinja.from_json_any), so a variable whose stored
value is itself unrendered Jinja (an author vars file loaded by
include_vars, a role default) reaches the engine as literal
text - {{ __pk | list }} over an include_vars-loaded template
produced the template's own characters as a list instead of
rendering it first (linux-system-roles.postgresql, round
981063). Returns nil for an undefined result.
The fail-on-consumption counterpart of prepare_var's lazy nested leaves (see #prepare_rerender_walk): when a chain's head is a bare name whose stored value is a still-templated container and the chain pipes it into a whole-structure serializer, render the container strictly BEFORE the chain runs - the same raise (same class, same innermost "'x' is undefined" message) the old eager variable-scope conversion produced for these shapes, and what nested_container_undefined_filter_test spec'd against real ansible-core. A strictly-rendered leaf feeds the serializer the same rendered value scope conversion used to hand it; only leaves that would genuinely raise in Ansible change the outcome here, so the guard is a no-op for every already-rendered structure.
True if name resolves as a filter on the shared engine, including
a collection-qualified name (ansible.builtin.ternary) by its
trailing segment, as a render would resolve it.
The test-side twin of #known_filter?, for ConditionalEvaluator's
compile-time test-name pre-pass (an unknown is <name> in a
when: must hard-fail even when and/or short-circuiting never
reaches that clause).
The hostvars magic variable: every host's vars re-templated,
under the same depth guard. Each entry is gated by THAT host's own
unsafe-name registry - another host's registered results/set_facts/
facts are unsafe exactly like the current host's (a hostile target
reachable through hostvars['other'] gets no re-render either),
while the host's author-defined template vars still render - and
each entry re-renders in THAT HOST'S OWN scope (HostvarsContext):
Ansible's HostVarsVars templar renders hostvars['other'].x
with the other host's vars, never the reading host's.
A variable's value after recursive re-templating, or nil when it is undefined - what JinjaVarResolver hands the engine for name.
The strict-undefined TEMPLATE probe: renders text once on the krikri-jinja engine with Ansible's strict-undefined environment and returns ansible-core 2.19's error message when the render CONSUMED an undefined value (a missing attribute/key on a native dict/list/ scalar, an undefined name) - nil when the render succeeded or failed for a non-undefined reason (unknown construct, syntax gap: the probe must never turn an evaluator capability gap into a task failure). Same message conversion and out-of-scope declines as Krikri.strict_undefined_probe_message, which this complements for MIXED-text/container values an expression probe cannot parse.
The test (or filter) name a krikri-jinja "unknown test" error names.
Instance methods
Evaluates expr (bare Jinja expression text, no surrounding
{{ }}) and returns its RAW structured result as JSON::Any
(nil for a genuinely undefined result - the same nilable
convention VariableLookup#resolve/#resolve_simple/etc.
already use) instead of #render!'s always-a-String output.
#render! always produces a String - fine for a FINAL {{ }}
substitution, but wrong for a caller (like ExpressionEvaluator's
delegation branches) that hands the result to something expecting
structured data, or that formats an Array/Hash result through this
codebase's own VariableLookup#format_value (its JSON-compact
style) so the internal "render sub-expression to a String,
JSON.parse it back" round trip keeps working.
Evaluated by krikri-jinja against the same lazy variable scope the
task-param path always had (JinjaVarResolver), with Ansible's
chainable lenient undefined and the inline-literal escape handling
#shared_env picks (verbatim for task params, decoded for
when:/assert:).
An unknown filter gets one chance to be a role-local (or
playbook-adjacent) filter_plugins/*.py filter: it is registered
on the shared engine and the evaluation retried once. The same
one-chance retry applies to an unknown TEST name (a role-local
test_plugins/*.py test - Aisbergg.networkmanager's value is list, round 2300110). Anything else is re-raised untouched so
the caller's own fallback engages.
Without this gate a delegated chain's unknown-filter error reached
the hand-rolled fallback, whose suffix walk collapsed the whole
expression to "undefined" - diodonfrost.vagrant's {{ (vagrant_index.content | from_json).versions | list | sort_versions | last }} (role-local filter_plugins/
sort_versions.py) rendered the literal string "undefined" into a
download URL while ansible-playbook resolved 2.4.3.
Render a template containing Jinja2 control structures, raising
on any failure instead of swallowing it - for a caller (like
ExpressionEvaluator's own delegation branches) that wants to
fall back to a DIFFERENT rendering strategy on failure, rather
than #render's own "give back the original unrendered text"
behavior, which would be actively wrong for a caller expecting a
real evaluated value.
ansible-core hard-fails a template holding a closing/branch tag
with no opener (x{% endif %}) with "Encountered unknown tag
'endif'.", whereas #render's lenient give-back-the-text fallback
would pass the raw tag text on to the module. Returns Jinja2's
message for that one parse failure, nil for everything else
(other parse gaps stay lenient).