class

Krikri::VariableSubstitutor::JinjaRenderer

Inherits Reference < Object

Renders and evaluates task-param Jinja ({{ }} and {% %}) on the shared krikri-jinja engine, against a lazily prepared variable scope (JinjaVarResolver).

Constants

MAX_PREPARE_VARS_DEPTH = 50
UPDATE_THEN_REREAD_RE = /\A\{\{\s*([A-Za-z_]\w*)\.update\(\s*([A-Za-z_]\w*)\s*\)\s*\}\}\{\{\s*\1\s*\}\}\z/

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.

WHOLE_STRUCTURE_FILTERS = /\|\s*(?:to_json|to_nice_json|to_yaml|to_nice_yaml)\b/

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

new(vars : Hash(String, JSON::Any), decode : Bool = false)
Source

Class methods

elide_omitted(value : JSON::Any) : JSON::Any

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.

Source
evaluate_structured(source : String, vars : Hash(String, JSON::Any), strict : Bool = false) : JSON::Any | Nil

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.

Source
force_whole_structure_leaves!(expr : String, vars : Hash(String, JSON::Any)) : Nil

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.

Source
known_filter?(name : String) : Bool

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.

Source
known_test?(name : String) : Bool

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).

Source
prepare_hostvars(raw_value : JSON::Any, substitutor : VarSubstitutor, defer_unresolved : Bool = false, deferred_leaves : Hash(String, String) | Nil = nil) : JSON::Any

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.

Source
prepare_var(raw_value : JSON::Any, substitutor : VarSubstitutor, name : String = "") : JSON::Any | Nil

A variable's value after recursive re-templating, or nil when it is undefined - what JinjaVarResolver hands the engine for name.

Source
rerender_nested_templates(value : JSON::Any, substitutor : VarSubstitutor, defer_unresolved : Bool = false, deferred_leaves : Hash(String, String) | Nil = nil) : JSON::Any
Source
strict_render_undefined_message(text : String, vars : Hash(String, JSON::Any)) : String | Nil

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.

Source
unknown_feature_name(error : KrikriJinja::TemplateError, kind : String) : String | Nil

The test (or filter) name a krikri-jinja "unknown test" error names.

Source

Instance methods

evaluate_value!(expr : String) : JSON::Any | Nil

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.

Source
render(text : String) : String

Render a template containing Jinja2 control structures

Source
render!(text : String) : String

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.

Source
stray_end_tag_message(text : String) : String | Nil

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).

Source