Skip to content

Templating

Volant renders Jinja2 on the controller with MiniJinja and adds Ansible’s filters, tests and lookups on top.

Templates render in strict mode. An undefined variable is an error, not an empty string, unless you guard it:

- debug:
msg: "{{ release | default('latest') }}"
when: release is defined or use_latest | bool

A when whose value is not a boolean is rejected as well.

when, changed_when, failed_when and until take one expression or a list of them. With a list, every expression must hold.

- command: /opt/app/migrate.sh
register: migrate
changed_when: "'applied' in migrate.stdout"
failed_when:
- migrate.rc != 0
- "'already up to date' not in migrate.stderr"

A condition wrapped in {{ }} that renders to a string is evaluated once more as a condition, as ansible-core 2.19 does. With healthy: "true" in the play’s vars, when: "{{ healthy }}" runs the task. This second evaluation never happens when the first render read a value from a managed host: the task fails with Encountered untrusted template or expression. instead. See Trusted and untrusted values.

On top of MiniJinja’s own Jinja2 builtins, Volant adds these from Ansible:

Kind Names
Filters default / d, bool, int, float, mandatory, ternary, combine, dict2items, items2dict, to_json, to_nice_json, from_json, to_yaml, to_nice_yaml, from_yaml, basename, dirname, split, regex_replace, regex_search, regex_findall, regex_escape, b64encode, b64decode, comment, difference, intersect, union, flatten, to_uuid, type_debug, quote
Tests truthy, falsy, match, search, regex, contains, changed / change, failed / failure, succeeded / success / successful, skipped / skip, version (and version_compare)
Lookups env, file, vars, pipe, first_found, fileglob, template
From ansible.utils the ansible.utils.ipwrap filter, under that name only

Every filter, test and lookup in this list also answers under its ansible.builtin.* name, so {{ data | ansible.builtin.to_json }} works. A filter, test or lookup that is not in this list fails with its own name, never silently. That includes the filters of any other collection.

first_found, fileglob and template look for files along ansible_search_path. In a role’s task that is the role’s directory, the directory of the task’s file, then the playbook’s directory. Outside a role it is the playbook’s directory. role_path is set in a role’s task and is undefined outside one. fileglob tries a relative pattern under files/ of each entry, then in the entry itself, and the first entry with a match wins. It returns files only, sorted, and a single match comes back as a string. Each one is checked against ansible-core in the golden test corpus under crates/volant/tests/golden.

A value also answers a handful of Python methods: .split(), .startswith() and .find() on a string, .keys() on a mapping, and the others that minijinja-contrib’s Python compatibility layer provides. A method neither that layer nor the engine knows fails with its own name rather than silently doing nothing.

difference, intersect and union build a Python set in ansible-core, whose order is its own: small integers happen to come out sorted, and strings come out in an order that changes with PYTHONHASHSEED from one run to the next. Volant sorts the result when every element is an integer, which matches every integer case measured, and otherwise keeps the order in which the elements first appeared. On strings, that order is not guaranteed to match a given run of ansible-core.

The template module and lookup('template') share one render, and it differs from a task argument’s render in one way: it renders the file’s text once. A task argument goes through further passes, which is how a value that still holds {{ ... }} after rendering can be read as a template again. A template file does not get that second pass, so a value a managed host contributed lands in the written file as plain text and never runs as a template of its own.

It takes the options the template module reads:

Option Default Effect
trim_blocks on Remove the first newline after a block tag.
lstrip_blocks off Strip the whitespace before a block tag on its line.
newline_sequence \n Line endings of the written file: \n, \r or \r\n, escaped or literal.

The file’s own trailing newline survives the render. Action plugins covers the rest of what the template module does.

  • The !vault and !unsafe YAML tags are detected and not supported yet. There is no decryption and no unsafe marking yet.
  • Other Ansible filters, tests and lookups, such as hash, password_hash, ipaddr, the version filter and json_query, are not supported yet.
  • In a template file, template_host, template_uid, template_run_date and template_mtime are not set, unlike template_path, template_fullpath and ansible_managed.
  • In a template file, {% include %} and {% import %} fail: nothing loads a second file during a render.
  • The six delimiter options (variable_start_string and its five relatives) and an output_encoding other than UTF-8 are not supported yet.

Resolving variables costs more than linear time in the number of hosts. Two changes have removed most of that cost: every host reads one shared hostvars map, and inventory-wide values such as groups are built once per batch instead of once per host and task. Nothing in a playbook, an inventory or ansible.cfg changes this cost.

Run time by inventory sizeDebug build, twenty local tasks. Read it as the shape of the curve, not as timings to expect.
  • 50 hosts0.16 s50 hosts0.16 s
  • 100 hosts0.50 s100 hosts0.50 s
  • 200 hosts1.45 s200 hosts1.45 s
Show as a table
Run time by inventory size
50 hosts0.16 s
100 hosts0.50 s
200 hosts1.45 s