Jinja2: Assemble Strategy

Because you are reading this post, you’re likely aware of the great power of the Jinja2 templating language. You might have turned an entire data center configuration in one huge complex template, doing crazy conditionals to render some lines and skip some others. Spines or leaves devices do not make any difference to you, as long as you have a device_role variable which helps you to build the right config. Multi-vendor is not a thing for you anymore – Juniper, Cisco, Nexus (…you name them) all together in your super_master.j2. One template to rule them all! Hundreds and hundreds of lines that do the magic and make you scream every time template error while templating string or UndefinedVariable pops out! You end up digging into the code for hours, commenting blocks of lines, and trying to run it again..and again. Rolling back git commits, making the effort to remember when the last time was that the template rendered properly. What about when you needed to add a few config lines just for a specific bunch of devices or you tried to port your template and reuse it for another project?

You suddenly realize that you need a different design to make your template more scalable so you will not go crazy every time you need to work on it. Let’s explore what options we have and what the best approach might be.

Single template

Let’s use the previous case as an example. We will leverage the Ansible rendering engine. Remember, this is a templating strategy, so the below examples are also true for all those applications which rely on Jinja as template language, such as SALT, python, etc. Based on two groups of devices – spines and leaves you could build a single base.j2 such as:

{% for iface in interfaces %}
interface {{ iface['name'] }}
 {% if 'Management' in iface['name'] %}
 vrf forwarding mgmt
 {% endif %}
 {% if iface['ip'] %}
 ip address {{ iface['ip'] }}
 {% endif %}
 {% if 'loopback' not in iface['name'] %}
 mtu {{ iface['mtu'] }}
 {% endif %}
 [...]
!
{% endfor %}
{% if 'leaves' in group_names %}
 {% for vxlan in vxlans %}
interface {{ vxlan['interface'] }}
 vxlan source-interface {{ vxlan['source_update'] }}
 vxlan udp-port {{ vxlan['port'] }}
   {% for vlan_vni in vxlan['vlan_vni'] %}
     {% for vlan, vni in vlan_vni.items() %}
 vxlan vlan {{ vlan }} vni {{ vni }}
     {% endfor %}
   {% endfor %}
 [...]
   {% endfor %}
!
 {% endfor %}
{% endif %}
{% if 'spines' in group_names %}
 {% for peer in bgp['peers'] %}
 neighbor {{ peer['name'] }} peer-group
 neighbor {{ peer['name'] }} remote-as {{ peer['remote_as'] }}
 neighbor {{ peer['name'] }} maximum-routes {{ peer['attributes']['max_routes'] }} warning-only
   {% if peer['attributes']['update_source'] %}
 neighbor {{ peer['name'] }} update-source {{ peer['attributes']['update_source'] }}
   {% endif %}
   {% if peer['attributes']['ebgp_multihop'] %}
 neighbor {{ peer['name'] }} ebgp-multihop {{ peer['attributes']['ebgp_multihop'] }}
   {% endif %}
   {% if peer['attributes']['next_hop_unchanged'] %}
 neighbor {{ peer['name'] }} next-hop-unchanged
   {% endif %}
   {% if peer['attributes']['next_hop_self'] %}
 neighbor {{ peer['name'] }} next-hop-self
   {% endif %}
 [...]
 {% endfor %}
{% endif %}

Well…that was a lot of code to read just for an interface, VXLAN, and BGP configuration. Now try to imagine building a full running-config how complex it would become. Catching an error becomes tricky. Making future implementations feels like trying to move through a minefield. Let’s see how we can improve our template design.

Include strategy

Jinja comes to the rescue with include tag.

We can break up our base.j2 in small chunks of templates and move them under the appropriate folder:

├── base.j2
├── bgp
│   └── bgp.j2
├── interfaces
│   └── interfaces.j2
└── vxlan
   └── vxlan.j2

so, our base.j2 will look like this:

{% include `interface/interface.j2` %}
 
{% if 'leaves' in group_names %}
{% include `vxlan/vxlan.j2` %}
{% endif %}
 
{% if 'spines' in group_names %}
{% include `bgp/bgp.j2` %}
{% endif %}

Much better, isn’t it? Every template is now properly organized under its own folder and file (i.e. interface under interface/interface.j2) and we included them into base template. By still applying the group conditional logic, we can have the desired config for each device. As you can see, the code becomes more readable, easier to implement (if we need to update a BGP template, we will work only on bgp.j2), easier to troubleshoot, and more portable.

Great! But what if this can be further simplified? Get ready, minimalists!

Jinja assemble strategy

Still using the include tag, we can assemble our configuration, plugging in or out parts of templates and looping through a list of includes for each group of device.

Assuming we are still using Ansible, we can group devices per role and have a group_vars folder that looks like this:

├── leaves
│   └── main.yml
└── spines
   └── main.yml

Where leaves/main.yml

assemble:
 - 'interface/interface.j2'
 - 'vxlan/vxlan.j2'

…and spines/main.yml

assemble:
 - 'interface/interface.j2'
 - 'bgp/bgp.j2'

Defining what template we want to include in our assemble process, and taking advantage of group_vars, our base.j2 will contain just a simple for loop:

{% for item in assemble %}
{% include item %}
{% endfor %}

We can now plug in or out templates by just appending or removing a new include under our lists. Our base.j2 will loop through the lists based on group_vars and assemble the final config.

That’s all. It could not be simpler than that!


Conclusion

By the way, if you ever had troubles with whitespaces or indentation in Jinja (…and I am sure you have!) make sure to check this out!

-Federico



ntc img
ntc img

Contact Us to Learn More

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

Author