Network Configuration Templating with Ansible – Part 3

In the first and second parts of this series we discussed extracting variables from your device configurations, building your host and group data structures with those variables, and then using that data along with Jinja2 templates to generate configurations programmatically. In the third part of this series, we will dive deeper into the more advanced methods of manipulating the data output during generation by using two key features of Jinaj2, filters and macros.

Filters

When using Jinja2 to generate configurations you might find yourself at times wanting to convert a variable value to another format. This is useful in cases where you don’t necessarily want to document every possible variable for your configuration. One example of this would be using a CIDR notation for an IP address variable. By using CIDR notation, you’re able to document not only the host address but also determine the network address, broadcast address, and associated netmask. Extracting that information from the CIDR address variable is where Jinja2 filters come into play. By using the ipaddr filter that’s based on top of the netaddr Python library, you’re able to pass the CIDR address to a specific variable to get the desired piece of data.

In order to utilize a filter such as ansible.utils.ipaddr, you pipe (using the | character) a value to your desired filter. You can chain together as many filters as you like as shown below:

# router1.yml
network: "192.168.1.0/24"
# template.j2
ip route 0.0.0.0 0.0.0.0 {{ network | ansible.utils.ipaddr("1") | ansible.utils.ipv4("address") }}

By using the CIDR address notation defined by the network variable and passing it through the ipaddr and ipv4 filters, we are able to obtain the gateway address and render the default route as shown:

ip route 0.0.0.0 0.0.0.0 192.168.1.1

This works due to a Jinja2 filter simply being a Python function that accepts arguments and returns some value. With the first ansible.utils.ipaddr("1") step, it’s taking the value of the network variable and finding the first IP address in the network, 192.168.100.1/24. Then the ansible.utils.ipv4("address") filter takes that value and finds just the address, which strips the /24 and returns 192.168.100.1.

Now, you might be asking yourself what would be the use for something like this? Using templates like the above allows for you to make changes across your fleet while still taking into account variations in configurations. For example, you could write a playbook like below to set a new default gateway on devices:

# update_gateways.yaml
- name: Default Route Update Playbook
  hosts: all
  gather_facts: false

  tasks:
    - name: Update default gateway on inventory hosts
      cisco.ios.ios_config:
        backup: "yes"
        src: "./template.j2"
        save_when: "modified"
(base) {} ansible-playbook -i inventory update_gateways.yaml

PLAY [Default Route Update Playbook] ***********************************************************************************************************************

TASK [Update default gateway on inventory hosts] ***********************************************************************************************************************
changed: [router1]
changed: [router2]
changed: [router3]

PLAY RECAP ***********************************************************************************************************************
router1                    : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
router2                    : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
router3                    : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

To obtain the currently available filters included with Ansible, you’ll need to install them from the ansible.utils collection. This can be done by issuing ansible-galaxy collection install ansible.utils at the command line. In addition, as filters are simply Python functions, you are able to write your own for utilizing within your templates. This is extremely helpful when you have some complex piece of data that you wish to manipulate before inserting into a configuration. Using the example from above, we can write a function to perform the same and simplify the template:

#custom_filters.py
import netaddr

class FilterModule(object):
    def filters(self):
        return {
            "get_gateway_address": self.get_gateway_address
        }

    def get_gateway_address(self, network: str):
        network = netaddr.IPNetwork(network)
        return str(network.ip + 1)

As custom filters are just simple Python functions, adding them into the Jinja environment for Ansible requires some specific code. As you can see in the example above, there is a FilterModule class that Ansible looks for when adding custom filters. In this class there must be a filters method that returns a dictionary where the key is the name you want to use for your filter and the value being the function itself. There isn’t a requirement for the called method to reside in the FilterModule class, but putting it there helps prevent potential namespace conflicts.

In order to test this filter within Ansible, you can place the Python file containing the filter definition inside a folder called filter_plugins alongside your playbooks, utilizing it as shown in the diagram below:

(base) {} tree
.
├── filter_plugins
│   ├── custom_filters.py
├── group_vars
├── inventory
├── update_gateways.yaml
├── template.j2

You would then simply update the template line to use the filter like so:

ip route 0.0.0.0 0.0.0.0 {{ network | get_gateway_address }}

Once you’re confident it’s working as intended, you can bundle it alongside others in a collection for easy installation in other environments. If you’re curious about the included filters in Ansible, the source for them is available in their GitHub repo. The Jinja2 framework also includes a number of filters that can be found in their reference documentation.

Macros

Macros are the equivalent of functions in Jinja2. They can be used to store a single word or phrase, or even do some processing and manipulation of your data using Jinja2 syntax as opposed to Python syntax. These are handy when you might not be comfortable with Python but still want to process your data in some manner. Continuing with the example from above, we could write a macro to note the combination of filters like below:

{% macro get_gateway_ip(network) -%}
{% network | ansible.utils.ipaddr("1") | ansible.utils.ipv4("address") -%}
{%- endmacro -%}

We would then need to update the template to call the macro by passing the variable value to the macro as an argument much like Python takes arguments:

ip route 0.0.0.0 0.0.0.0 {{ get_gateway_ip(network) }}

Notice how we pass in the network variable, which in the template file is equivalent to the CIDR notation 192.168.1.0/24. This value is what is then passed to the macro, and acted upon by the filters. The macro then returns the ip address as 192.168.1.1. The rendered result would be:

ip route 0.0.0.0 0.0.0.0 192.168.1.1

Another example would be to define the default interface configuration and expand that macro for your other port roles. Using the configuration information below, we can create a macro that covers the basics of an interface like so:

interfaces:
    - name: "GigabitEthernet0/1"
      duplex: "full"
      speed: 1000
      port_security: false
    - name: "GigabitEthernet0/2"
      duplex: "full"
      speed: 1000
      port_security: true
{% macro base_intf(intf) -%}
interface {{ intf["name"] }}
  duplex {{ intf["duplex"] }}
  speed {{ intf["speed"] }}
{%- endmacro -%}

We can then create another macro that extends the base_intf macro to add in the appropriate port security configuration like so:

{% macro secure_port(intf) -%}
{{ base_intf(intf) }}
  access-session port-control auto
  dot1x pae authenticator
{%- endmacro -%}

Now, when we want to generate the configuration we simply need to call the appropriate macro, like so:

# interfaces.j2
{% for intf in interfaces %}
{% if intf["port_security"] %}
{{ secure_port(intf) }}
{% else %}
{{ base_intf(intf) }}
{% endif %}
{% endfor %}

The above template would then render the following configuration using the interface information above:

interface GigabitEthernet0/1
  duplex full
  speed 1000
interface GigabitEthernet0/2
  duplex full
  speed 1000
  access-session port-control auto
  dot1x pae authenticator

As above, we can then utilize this template in a playbook to update the interfaces on our Devices like so:

# update_interfaces.yaml
- name: Update Interfaces Playbook
  hosts: all
  gather_facts: false

  tasks:
    - name: Update interfaces on inventory hosts
      cisco.ios.ios_config:
        backup: "yes"
        src: "./interfaces.j2"
        save_when: "modified"
(base) {} ansible-playbook -i inventory update_interfaces.yaml

PLAY [Update Interfaces Playbook] ***********************************************************************************************************************

TASK [Update interfaces on inventory hosts] ***********************************************************************************************************************
changed: [router1]
changed: [router3]
changed: [router2]

PLAY RECAP ***********************************************************************************************************************
router1                    : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
router2                    : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
router3                    : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

As you can see, using macros enables you to minimize duplicated code, which simplifies things in addition to building compartmentalized logic into your templates. In addition, as with Python functions, you can place these macros in a central repository and reference them in your templates using Jinja imports. However, that’s outside the scope of this post, but more information can be found in the Jinja2 documentation.


Conclusion

In this post we went over the basics of Jinja2 filters and macros and how they can be utilized to aid you in manipulating your data being inserted into your templates. In Part 4 of this series, we’ll go into how Ansible handles variable inheritance and how that can enable more advanced templates.

-Justin



ntc img
ntc img

Contact Us to Learn More

Share details about yourself & someone from our team will reach out to you ASAP!

Author