Working with Ansible Jinja2 code and filters
When working with Ansible Jinja2 code and filters, it is helpful to write small playbooks to test out functionality. This is especially true if you are developing a large role/playbook and want to try out a filter in one of the tasks. You do not want to have to run the entire playbook with a virtual machine just to test one filter output in one task.
Example playbook
Here is an example of such a playbook:
- name: Test to_nice_json
hosts: localhost
gather_facts: false
vars:
foo: bar
tasks:
- name: Show value of foo
debug:
msg: |
foo is {{ foo | to_nice_json }}
Run this playbook like this:
ANSIBLE_STDOUT_CALLBACK=debug ansible-playbook -vv debug.yml
I like to use ANSIBLE_STDOUT_CALLBACK=debug
and filters like to_nice_json
because it really helps me see the structure of the data in a multi-line format
rather than in a single line JSON blob with escaped quotes and embedded
newlines.
Example with nested dict data and map
Here is an example testing out a filter in a complex nested data structure -
given the result
dict, I want to extract a list of cidr_block
values:
- name: Test map and list
hosts: localhost
gather_facts: false
vars:
result:
vpc:
cidr_block_association_set:
- id: block1
cidr_block: 192.168.122.0/24
cidr_block_state:
state: present
- id: block2
cidr_block: 192.168.123.0/24
cidr_block_state:
state: disabled
tasks:
- name: Show cidr_block values
debug:
msg: blocks {{ result.vpc.cidr_block_association_set |
map(attribute="cidr_block") | list | to_nice_json }}
and this is the output of the task:
TASK [debug] *******************************************************************************
task path: /home/rmeggins/ansible_sandbox/vpc-test.yml:16
ok: [localhost] => {}
MSG:
blocks [
"192.168.122.0/24",
"192.168.123.0/24"
]
Example that uses facts
Typically you will set gather_facts: false
to speed up playbooks when you
don’t need system facts. Even fact gathering from localhost takes time.
However, if you do need to test some functionality that requires
ansible_facts
, omit the gather_facts: false
.
---
- name: Test with ansible_facts
hosts: localhost
tasks:
- name: Set some facts
set_fact:
facts: "{{ ansible_facts }}"
separators: ["-", "_"]
versions:
- "{{ ansible_facts['distribution_version'] }}"
- "{{ ansible_facts['distribution_major_version'] }}"
- name: Set more facts
set_fact:
varfiles: "{{ [facts['distribution']] | product(separators) |
map('join') | product(versions | unique) | map('join') |
list + [facts['distribution'], facts['os_family']] }}"
- name: Show varfiles
debug:
msg: varfiles {{ varfiles | to_nice_json }}
Here is an example that parses service_facts
:
---
- name: Parse service_facts
hosts: localhost
tasks:
- name: service facts
service_facts:
- debug:
msg: |
service_facts {{ ansible_facts.services | to_nice_json }}
again {{ ansible_facts.services | dict2items |
selectattr('key', 'match', '^systemd-cryptsetup@') |
map(attribute='value') | map(attribute='name') | list }}
Using Different Jinja2 Versions
System Roles still has to support Ansible 2.9 and Jinja2 2.9 for EL7. The
easiest way to test with this is to use a python virtualenv
:
python -mvenv ~/.venv-ans29-jinja29
. ~/.venv-ans29-jinja29/bin/activate
pip install 'ansible==2.9.*' 'jinja2==2.7.*'
ANSIBLE_STDOUT_CALLBACK=debug ansible-playbook -vv debug.yml
This will run your playbook with ansible 2.9 and jinja 2.7.
When you are done testing, type deactivate
to “leave” the virtualenv. For
more information about virtualenv see python
venv
Coding for multiple versions of Ansible and Jinja
In general, use the filters and tests provided by Ansible 2.9 wherever possible: https://docs.ansible.com/ansible/2.9/user_guide/playbooks_templating.html
If you must use a Jinja2 feature (like map
or selectattr
), and you want to
make sure it works with Jinja 2.7, it is a bit tricky, because there are no
docs. If you use the Jinja 2.10 and later docs at Jinja2
docs, you’ll have to be
very careful because some of the features are not supported in jinja 2.7, and
you’ll have to reference the code at
https://github.com/pallets/jinja/blob/2.7.3/jinja2/filters.py and https://github.com/pallets/jinja/blob/2.7.3/jinja2/tests.py to see if your
filter/test is supported. Some notable differences:
namespace
is not available- the tests
eq
,equalto
, and==
are not available
For namespace
, you’ll just have to figure out how to write your for
loops in
such a way that they don’t need namespace
.
For the eq
filter - it is quite common to want to write a filter expression
like this:
{{ somelistofdicts | selectattr('someattr', '==', 'somevalue') | list }}
but that will not work in jinja 2.7 because the test ==
is not available.
Fortunately, ansible 2.9 provides the match
test, so you can rewrite the above
like this:
{{ somelistofdicts | selectattr('someattr', 'match', '^somevalue$') | list }}
which will work on all versions of ansible and jinja2.
Complex Ansible/Jinja Data Manipulation
See Manipulating data for many examples of complex data parsing and manipulation.
How to solve some common ansible-lint issues
Line Wrapping
ansible-lint has a pretty short line length, which causes problems if you are trying to use good programming practices by having descriptive variable names, which usually end up being quite long. On top of that, ansible-lint enforces spacing in Jinja constructs and expressions. Here are some examples of how to deal with line wrapping in common scenarios:
Use the YAML >-
flow scalars.
For example - instead of this:
- name: This is a very, very, very, ........................... very long line
use this:
- name: >-
This is a very, very, very,
...........................
very long line
The >-
flow scalar operator will concatenate each line into a single line
string, with a single space character replacing the new line and leading spaces.
Jinja expressions can be wrapped
For example - instead of this:
foo: "{{ a_very.long_variable.name | somefilter('with', 'many', 'arguments') | another_filter | list }}"
use the filter |
as a natural line break:
foo: "{{ a_very.long_variable.name |
somefilter('with', 'many', 'arguments') |
another_filter | list }}"
Remember, in a when
, that
, failed_when
, or other such keywords, you can just
write Jinja code - you do not need the ""
:
when: a_very.long_variable.name |
somefilter('with', 'many', 'arguments') |
another_filter | list
But what if the code is already indented a lot, and the variable I’m assigning to is already very long, and I can’t put anything else on the line? Just start the assignment on the next line:
foo: "{{
a_very.long_variable.name |
somefilter('with', 'many', 'arguments') |
another_filter | list }}"
If you have to do the same thing in a when
, that
, etc., you can use a backslash:
foo: \
a_very.long_variable.name |
somefilter('with', 'many', 'arguments') |
another_filter | list
Use vars
for locally scoped intermediate values
But what if my variable name is very long, and/or I use it in several places?
Use a vars
in the task to assign a locally scoped variable with a short name,
or pre-digest some of the work:
- name: Set some test variables
set_fact:
my_very_long_variable_1: "{{ a_very.long_variable.name | some_filter | filter1 }}"
my_very_long_variable_2: "{{ a_very.long_variable.name | some_filter | filter2 }}"
Notice that both assignments have a_very.long_variable.name | some_filter
in
common, so we can “pre-digest” that with a local variable:
- name: Set some test variables
set_fact:
my_very_long_variable_1: "{{ __pre_digest | filter1 }}"
my_very_long_variable_2: "{{ __pre_digest | filter2 }}"
vars:
__pre_digest: "{{ a_very.long_variable.name | some_filter }}"
You can use a vars
on any task - even include_role
, include_tasks
, etc.
You can also use vars
in a block
to create variables used by multiple tasks
in the block
that are locally scoped to the block
.
Use backslash escapes in double quoted strings
But what if I have a very long string that I cannot use >-
to wrap because I
cannot have extra spaces in the value e.g. like a url value?
uri:
url: "https://{{ my_very_long_value_for_hostname }}:{{ my_very_long_value_for_port }}{{ my_very_long_value_for_uri }}{{ my_very_long_value_for_query }}"
You can use a backslash escape in a double quoted string:
uri:
url: "https://{{ my_very_long_value_for_hostname }}:\
{{ my_very_long_value_for_port }}\
{{ my_very_long_value_for_uri }}?\
{{ my_very_long_value_for_query }}"
yaml will concatenate the values with no spaces:
https://myhost:myport/myuri?myquery