Using Jinja2 Macros as Template Functions

Jinja2 is a popular text templating engine for Python that is also used heavily with Ansible. Many network engineers are familiar with it and with leveraging Jinja2 templates for device configurations. But I’ve found one feature that’s under-utilized by network engineers: Jinja2’s macro support. Macros within Jinja2 are essentially used like functions with Python and are great for repeating configs with identical structures (think interface configurations, ASA object-groups, etc).

Real-World Situation

Let’s start with a basic real-world configuration example. I’ll start with the following IOS interface configuration for a switch:

interface FastEthernet0/1
  switchport mode access
  switchport access vlan 10
interface FastEthernet0/2
  switchport mode trunk
  switchport trunk native vlan 20

I could repeat this for multiple interfaces, but two interfaces will be enough to demonstrate. If I wanted to write an Ansible playbook to generate this configuration, using structured data YAML files for holding the configuration, one way I could build the YAML file interfaces.yml would be like this:

---
all_interfaces:
  - name: FastEthernet0/1
    vlan: 10
    mode: "access"
  - name: FastEthernet0/2
    vlan: 20
    mode: "trunk"

I could then create the Jinja2 template file switch01.j2 like this:

#jinja2: lstrip_blocks: True
{% for interface in all_interfaces %}
    interface {{ interface.name }}
      switchport mode {{ interface.mode }}
    {% if interface.mode == 'trunk' %}
      switchport trunk native vlan {{ interface.vlan }}
    {% elif interface.mode == 'access' %}
      switchport access vlan {{ interface.vlan }}
    {% endif %}
{% endfor %}

When generating the configuration text using the above template, I would get the expected output as shown above in the example IOS switch config. This is all well and good, but what happens if I want to configure a second switch?

I don’t want to copy the switch01.j2 file and rename it to switch02.j2. I also don’t want to simply reuse that file. What happens if switch01 gets replaced with an NX-OS switch, or even a switch from another vendor? I would run into issues quickly as my network expanded. This presents the use case for Jinja2 macros.

Jinja2 Macros Example

To create the interfaces Jinja2 template above and convert it into a macro, I can wrap all of the code with {% macro %}{% endmacro %}. I can then use that code as a function, import it into other templates, and even pass variables into it (just like I would with a regular Python function). For example, I will first create the template interfaces_template.j2:

{% macro l2_interfaces(interfaces) %}
{% for interface in interfaces %}
    interface {{ interface.name }}
      switchport mode {{ interface.mode }}
    {% if interface.mode == 'trunk' %}
      switchport trunk native vlan {{ interface.vlan }}
    {% elif interface.mode == 'access' %}
      switchport access vlan {{ interface.vlan }}
    {% endif %}
{% endfor %}
{% endmacro %}

Line 1 is {% macro l2_interfaces(interfaces) %}. The first part of the tag is macro, which simply defines this tag as a tag type of macro. The next part l2_interfaces() is used to define the name of the function. Lastly, interfaces is the name of the variable I want to pass into this macro when calling it.

If I were to write this out in Python, it would look like:

def l2_interfaces(interfaces):
    for interface in interfaces:
        print(f"interface {interface['name']}")
        if interface["mode"] == "trunk":
            print(f"  switchport trunk native vlan {interface['vlan']}")
        elif interface["mode"] == "access":
            print(f"  switchport access vlan {interface['vlan']}")

Now I have a macro I can use in other Jinja2 templates. Using the same interfaces.yml YAML file from before where the interfaces are defined, I will first create a template for switch01, with filename switch01.j2:

#jinja2: lstrip_blocks: True
{% from 'interfaces_template.j2' import l2_interfaces %}
{{ l2_interfaces(all_interfaces) }}

And that’s it! Both interfaces will be properly generated as expected into the following config (shown at the top of this blog post):

interface FastEthernet0/1
  switchport mode access
  switchport access vlan 10
interface FastEthernet0/2
  switchport mode trunk
  switchport trunk native vlan 20

There are only three lines in the template file switch01.j2, but I want to explain them in detail.

Line 1

  • The first line is #jinja2: lstrip_blocks: True. This helps to control extra whitespace generated by Jinja2 from the tags. More info on controlling whitespace can be found on a previous NTC blog post.

Line 2

  • The second line is {% from 'interfaces_template.j2' import l2_interfaces %}. You’ll notice there is no closing tag for it, like {% endfrom %}, as Jinja2 does not require closing this tag.
  • The part 'interfaces_template.j2' references which file to import from. If it were nested in a folder called “switches/”, it would be imported with switches/interfaces_template.j2.
  • The last part, import l2_interfaces, tells Jinja2 the name of the macro to import.

Line 3

  • The last line is {{ l2_interfaces(all_interfaces) }}, which calls the function I just imported on the line above and passes in the variable all_interfaces, which is obtained from the YAML file interfaces.yml.

Now I can easily create a template for a second switch, and expand the configuration some more, with Jinja2 template switch02.j2:

{% from 'interfaces_template.j2' import l2_interfaces %}
hostname Switch02
ip domain-name networktocode.com
!
{{ l2_interfaces(all_interfaces) }}

Which generates:

hostname Switch02
ip domain-name networktocode.com
!
interface FastEthernet0/1
  switchport mode access
  switchport access vlan 10
interface FastEthernet0/2
  switchport mode trunk
  switchport trunk native vlan 20

If I wanted to add interfaces, all I would have to do is add them to interfaces.yml, then run my Jinja2 template again! While this example is good for demonstration purposes, I would ideally expand it out further in a real-world environment, to allow different interface information on a per-switch basis.

Importing Functions, Abbreviated

I can rename the function “l2_interfaces” to “l2i” when imported, just like I can when importing in Python, by adding as l2i to the end. For example: {% from 'interfaces_template.j2' import l2_interfaces as l2i %}. This can be handy when importing macros with long names.

I would then use the function like so:

{% from 'interfaces_template.j2' import l2_interfaces as l2i %}
{{ l2i(all_interfaces) }}

Conclusion

I hope you’ve found this topic helpful, and possibly learned something entirely new about Jinja2! Feel free to comment below, or reach out to us at Network to Code on our public Slack at slack.networktocode.com and ask around.

-Matt



ntc img
ntc img

Contact Us to Learn More

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

Author