Red Hat Ansible is a powerful automation tool. One of the core concepts taught with Ansible is Jinja2, a Python-based web template language that is commonly used to create infrastructure configuration templates. There are plenty of tutorials covering the basic syntax of Jinja2 and using it with Ansible. This tutorial will focus on tips and tricks that are learned from experience and hopefully will help you in your automation journey.
Most network automation engineers start learning Ansible by sending a few lines of config using the “config” modules like ios_config
:
- name: First Task - Configure CDP and LLDP
ios_config:
lines:
- cdp run
- lldp run
Next, they might start inserting variables into their config modules like this:
- name: Second Task - Configure Interface
ios_config:
lines:
- "description {{ inventory_hostname }}"
- ip address 172.31.1.1 255.255.255.0
parents: interface GigabitEthernet3
Finally, after a bit of practice, engineers often start introducing conditionals and looping within playbooks such as this:
- name: Looping - configure ip helpers on multiple interfaces
ios_config:
lines:
- ip helper-address 172.26.1.10
- ip helper-address 172.26.3.8
parents: "{{ item }}"
with_items:
- interface GigabitEthernet2
- interface GigabitEthernet3
- interface Loopback100
While it is relatively easy to follow simple examples like those above, as more lines of config are being configured by Ansible, and more advanced logic, looping, and variable substitution is introduced, it becomes much more difficult to troubleshoot and read the playbook when complex logic is embedded in the tasks.
When using Jinja2, you are able to shift the complexity from the playbook into templates. Jinja2 also has a lot of built-in features that help with complex looping, conditionals, calculating values, or importing custom Python code (such as to pull in an IP address from a Source of Truth IPAM).
From the playbook side, you can either choose to push the rendered config to the device directly:
- name: render a Jinja2 template onto an IOS device
ios_config:
backup: yes
src: ios_template.j2
Or, you can build the configuration locally, review it, and then push it at a later time or a separate task or playbook:
- name: Render Config to Disk
template:
src: ios_template.j2
dest: configs/ios_config.txt
Having the complexity shifted from the playbook to the Jinja2 template does not remove the complexity, but it allows you to use more readable syntax and make changes to your template without impacting the playbook itself. Here is an example of some intermediate logic that would be a lot less readable within a playbook:
{% if router_bgp is defined %}
router bgp {{ router_bgp['asn_number'] }}
...
{% if router_bgp.get('neighbors') %}
{% for neighbor in router_bgp.get('neighbors') %}
neighbor {{ neighbor.get("neigh_remote_ip") }}
use neighbor-group {{ neighbor.get("group") }}
description {{ neighbor.get("desc") }}
...
{% endfor %}
{% endif %}
...
{% endif %}
Some of the primary hidden benefits of the Jinja2 snippet above is that if the router_bgp
variable does not exist (such as the device does not have BGP), the entire block is ignored, and no errors are thrown for invalid dictionary lookups. Also, even smaller components like having BGP neighbors is a conditional that if there are no neighbors, it ignores that entire block as well.
These design decisions help make the template more reusable and resilient. They also make it easier to have YAML variable files that are written to only include the portions of configuration that are relevant to the particular device, because the template logic will not execute if certain parts of the data structure are not defined, such as router_bgp
.
One of the pain points in developing network automation with Ansible is that the development process can be a bit disjointed. You have YAML variables spread across files and folders in host_vars
, group_vars
, and elsewhere. You may have one or many templates that you are importing into one or many playbooks, for one or many devices.
The primary workflow for Ansible developers is disjointed because you are bouncing between a bunch of files, folders, and executing your playbook over and over again to see if your errors are corrected. Bradley A. Thornton at Red Hat has created a free and open-source tool called TD4A that can be run locally, in a container or tested out for quick usage for free on his website:
The tool has three columns—one with your YAML variables, one with your Jinja2 template, and a third empty column that will render the output of the variables + template. If you run it locally, it has extra bells and whistles like looping in your actual Ansible runtime variables. It is very helpful for quick trial-and-error development or as a teaching tool.
Try it out yourself with the following small sample snippets:
interfaces:
Loopback100:
description: "This is a loopback configured by ansible"
ipv4addr: "10.123.123.100"
subnet: "255.255.255.255"
Loopback101:
description: "This is a loopback configured by ansible"
ipv4addr: "10.123.123.101"
subnet: "255.255.255.255"
Loopback102:
description: "This is a loopback configured by ansible"
ipv4addr: "10.123.123.102"
subnet: "255.255.255.255"
Loopback103:
description: "This is a loopback configured by ansible"
ipv4addr: "10.123.123.103"
subnet: "255.255.255.255"
Loopback104:
description: "This is a loopback configured by ansible"
ipv4addr: "10.123.123.104"
subnet: "255.255.255.255"
{% for iname, idata in interfaces.items() %}
interface {{ iname }}
description {{ idata.description }}
ip address {{ idata.ipv4addr }} {{idata.subnet}}
{% endfor %}
Jinja2 templates are primarily thought of as configuration templates. You can also use Jinja2 to format your output of raw or parsed operational data into text reports, CSV files, or Markdown documents. If you are newer to automation, this may even be a better place to start because you can grab operational data from devices without touching the configuration and lower your risk of making a mistake that impacts production while you are learning the basics.
The lowest-hanging fruit is using the built-in Ansible facts. Barry Weiss from our Learning & Certs team has built this simple example using the IOS Ansible facts to then feed into a Jinja2 template. Here are some snippets to give you an idea of what it does:
---
# This playbook is very basic and is used to demonstrate the basics about ansible
- name: "PLAY 1: This playbook gathers facts from sandbox-iosxe-recomm-1.cisco.com and prints them in the output and then saves it to the output folder"
hosts: routers # THIS WILL REFER TO THE HOST/GROUP THAT THIS PLAY IS TARGETING
connection: network_cli # FOR NETWORK DEVICES THE CONNECTION WILL BE 'network_cli'
tasks: # BELOW TASKS IS WHERE EACH TASK IS DEFINED
- name: "TASK 1: Connect to the device to gather facts about it"
ios_facts:
register: raw_facts
- name: "TASK 2: Print out some information about the device that is formatted on the cli"
debug:
msg: "The hostname is {{ ansible_net_hostname }} and the OS is {{ ansible_net_version }}"
- name: "TASK 3: Print the raw output"
debug:
msg: "{{ raw_facts }}"
- name: "TASK 4: Create outputs/ folder if it does not exist"
file:
path: "outputs"
state: directory
mode: 0775
run_once: true
- name: "TASK 5: send the output of raw_facts to a nice template and print it to a file"
copy:
content: "{{ lookup( 'template', '../templates/facts-template.j2') }}"
dest: "outputs/{{ inventory_hostname }}.txt"
mode: 0664
And the associated Jinja2 template using the autogenerated built-in ansible_net_*
variable names such as ansible_net_hostname
:
Sandbox Device Gather Facts output:
Hostname: {{ ansible_net_hostname }}
Model: {{ ansible_net_model }}
IOS type: {{ ansible_net_iostype }}
OS Version: {{ ansible_net_version }}
Serial: {{ ansible_net_serialnum }}
Interfaces:
{% for iname, idata in raw_facts['ansible_facts']['ansible_net_interfaces'].items() %}
Interface: {{ iname }}
Description: {{ idata.description}}
{% for ip in idata.ipv4 %}
Ipv4 address/cidr: {{ ip['address'] }}/{{ ip['subnet'] }}
{% endfor %}
Status: {{ idata.operstatus }}
======================
{% endfor %}
Using the following sample Ansible facts data:
raw_facts:
ansible_facts:
ansible_net_all_ipv4_addresses:
- 10.10.20.48
- 10.255.255.100
- 10.255.255.2
- 10.24.54.5
- 172.16.200.1
- 2.2.2.2
- 172.16.2.1
- 10.25.255.1
- 143.1.1.1
- 10.25.55.1
- 143.2.1.1
ansible_net_all_ipv6_addresses: []
ansible_net_api: cliconf
ansible_net_filesystems:
- 'bootflash:'
ansible_net_filesystems_info:
'bootflash:':
spacefree_kb: 5837000.0
spacetotal_kb: 7712692.0
ansible_net_gather_network_resources: []
ansible_net_gather_subset:
- interfaces
- default
- hardware
ansible_net_hostname: sandbox-iosxe
ansible_net_image: bootflash:packages.conf
ansible_net_interfaces:
GigabitEthernet1:
bandwidth: 1000000
description: MANAGEMENT INTERFACE - DON'T TOUCH ME
duplex: Full
ipv4:
- address: 10.10.20.48
subnet: '24'
lineprotocol: up
macaddress: 0050.56bf.9379
mediatype: Virtual
mtu: 1500
operstatus: up
type: CSR vNIC
GigabitEthernet2:
bandwidth: 1000000
description: Configured by RESTCONF
duplex: Full
ipv4:
- address: 10.255.255.100
subnet: '24'
- address: 10.255.255.2
subnet: '24'
lineprotocol: down
macaddress: 0050.56bf.ea76
mediatype: Virtual
mtu: 1500
operstatus: down
type: CSR vNIC
GigabitEthernet3:
bandwidth: 1000000
description: Configured by RESTCONF
duplex: Full
ipv4:
- address: 10.24.54.5
subnet: '24'
lineprotocol: down
macaddress: 0050.56bf.1651
mediatype: Virtual
mtu: 1500
operstatus: down
type: CSR vNIC
Loopback1:
bandwidth: 8000000
description: null
duplex: null
ipv4: []
lineprotocol: down
macaddress: null
mediatype: null
mtu: 1514
operstatus: administratively down
type: null
Loopback101:
bandwidth: 8000000
description: Configured by RESTCONF
duplex: null
ipv4:
- address: 143.1.1.1
subnet: '24'
lineprotocol: up
macaddress: null
mediatype: null
mtu: 1514
operstatus: up
type: null
ansible_net_iostype: IOS-XE
ansible_net_memfree_mb: 2070382.6328125
ansible_net_memtotal_mb: 2392443.9765625
ansible_net_model: CSR1000V
ansible_net_neighbors: {}
ansible_net_python_version: 3.6.8
ansible_net_serialnum: 926V75BDNRJ
ansible_net_system: ios
ansible_net_version: 16.09.03
ansible_network_resources: {}
changed: false
failed: false
Here is some sample output:
Sandbox Device Gather Facts output:
Hostname: CSR-1000V
Model: CSR1000V
IOS type: IOS-XE
OS Version: 16.09.03
Serial: 926V75BDNRJ
Interfaces:
Interface: GigabitEthernet1
Description: MANAGEMENT INTERFACE - DON'T TOUCH ME
Ipv4 address/cidr: 10.10.20.48/24
Status: up
======================
Interface: GigabitEthernet2
Description: Configured by RESTCONF
Ipv4 address/cidr: 10.255.255.100/24
Ipv4 address/cidr: 10.255.255.2/24
Status: down
======================
Interface: GigabitEthernet3
Description: Configured by RESTCONF
Ipv4 address/cidr: 10.24.54.5/24
Status: down
======================
Interface: Loopback1
Description: None
Status: administratively down
======================
Interface: Loopback101
Description: Configured by RESTCONF
Ipv4 address/cidr: 143.1.1.1/24
Status: up
Once you have the Ansible facts data, you can also feed it into a version control system like GitHub or GitLab. This gives you a place to refer to the state of your network devices without having to log into them. An additional benefit is that you can feed the data into Jinja2 templates that are formatted Markdown documents. For example, with the same Ansible facts data from the previous example, you can use the following Jinja2 template:
| INTERFACE | DESC | PROTOCOL | STATUS |
| ------- |------ | ------- | -------|
{% for iname, idata in raw_facts['ansible_facts']['ansible_net_interfaces'].items() %}
| {{ iname }} | {{ idata.description }} | {{ idata.lineprotocol }} | {{ idata.operstatus }} |
{% endfor %}
The Jinja2 template with the facts data fed in will create a Markdown table like the following:
| INTERFACE | DESC | PROTOCOL | STATUS |
| ------- |------ | ------- | -------|
| GigabitEthernet1 | MANAGEMENT INTERFACE - DON'T TOUCH ME | up | up |
| GigabitEthernet2 | Configured by RESTCONF | down | down |
| GigabitEthernet3 | Configured by RESTCONF | down | down |
| Loopback1 | None | down | administratively down |
| Loopback101 | Configured by RESTCONF | up | up |
When the Markdown table is displayed on GitHub/GitLab, it will render into an HTML table like this:
This is a relatively easy way to document your network from data that is automatically parsed from Ansible. You can also do similar things with other parsing engines such as TextFSM, pyATS, or TTP.
Instead of gathering state at an arbitrary time, you can also grab some show
command output before and after your network changes. This gives you an automated snapshot; you can then make a comparison against the before state and the after state. I extended Barry’s GitHub example above in the same repository by adding a pre/post checks playbook. This playbook has a lot of additional tricks in it that may be useful to learn from as well, such as grabbing the time stamp to make sure the filenames are unique between playbook runs and not overwritten.
There is another version where I abstracted the pre/post check flow into an Ansible role.
The core flow relevant to this discussion of documentation as code is first sending a set of commands and saving the output into a variable:
- name: "TASK 4: send pre-change show commands"
cli_command:
command: '{{ item }}' # sending multiple commands requires a loop with this module since it expects a string, we feed it in with the loop / item
loop:
- "show ip interface brief"
- "show interfaces"
- "show ip route"
register: show_result # this result is going to get fed into our Jinja2 template to then get written to our pre-change output
The output is then saved into a file location that is autogenerated:
- name: "TASK 2: set fact for pre-change filename which uses date"
set_fact:
pre_file: "./outputs/{{ inventory_hostname }}/pre_change/{{ date_stamp }}_{{ inventory_hostname }}.txt" # the output folder is in gitignore and we make a unique filename based on current time
...
- name: "TASK 5: send the output of pre-change commands to a nice template and print it to a file"
copy:
content: "{{ lookup( 'template', '../templates/pre-post-template.j2') }}" # the template plugin applies the Jinja2 template with our show command variables in memory from the previous task
dest: "{{ pre_file }}" # we defined this long name in task 2 as a fact based on date time and hostname
mode: 0664
And the corresponding Jinja2 template is relatively simple:
{% for result_item in show_result["results"] %}
Command sent was "{{ result_item["item"] }}"
Command Output was:
{{ result_item["stdout"] }}
{% endfor %}
Making a comparison between the pre/post checks is outside the scope of this tutorial, but check out the GitHub repo for an example.