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.

What You’ll Learn

What You’ll Need

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.

Benefit: Handling Complexity

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:

TD4A

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 %}

TD4A

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.

Ansible Facts

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

Documentation as Code

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:

Interface Table

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.

Learn More