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.
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.
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!
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!
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
Share details about yourself & someone from our team will reach out to you ASAP!