Jinja¶
What is Jinja?¶
At a high level, Jinja is a templating engine that creates markup files such as HTML or XML and custom text files, such as in our instance config files. Under the hood, Jinja is an open-source Python library that lets you create extensible templates. One of the major benefits of Jinja is that the template files you create allow you to define static text and variables. Some of the Jinja template syntax may look familiar because Jinja is not the first templating engine and is inspired by Django.
What is Jinja Used For?¶
As it may have become apparent from the YAML section, after creating our data model of the various configuration parameters we want to automate, we need to get that data model and all its variables into a format that can be read and understood by our network devices. This is where Jinja comes in. The use of Jinja templates, along with some yet to be shown Ansible magic, allows us to render full or partial configuration files that can loaded onto network devices. The underlying purpose of this, as it relates to automation, is that with the use of various expressions and variables in the Jinja templates, we can use a single template, with single or multiple YAML variable files, and create configurations against a multitude of network devices. As far as the actual template file and its extension, any file can be called a template regardless of its extension as long as it's formatted correctly. However, we typically use the .j2
extension on all Jinja template files.
Jinja Syntax¶
Before we discuss the different tasks we can accomplish in our Jinja templates, it's important to note that a few common expressions are used throughout all Jinja templates, including those related to network devices. They are outlined below.
Comments¶
Comments are represented as such, with our friend the pound symbol:
Expressions/Variables¶
Expressions or variables are represented with a pair of curly brackets:
Statements¶
Statements are represented with a percent symbol:
Live Jinja Parser
Quick and easy method for testing data models and Jinja syntax: https://j2live.ttl255.com/
Variable Substitution¶
As shown previously, we know expressions or variable substitution is performed with the double curly brackets, {{ my_var }}
, but what does this look like in a Jinja template?
For example, we may want to generate the hostname in our template for all the devices in our inventory file. To do this, we can use a standard Ansible variable called inventory_hostname
, which substitutes the name of the current inventory host the Ansible play is running against.
Assuming an inventory file with the following entries like below:
The output of these files all listed below would be as follows:
How about a more complex variable substitution. This shows how to substitute for a single dictionary item:
The Jinja template to call these variables is shown here:
# Render aaa authC config line
aaa authentication login default {{ aaa_authentication.login.default }}
As you can see, the variable has many parameters. Let's walk through these parameters using the aaa authentication
config line.
aaa_authentication
: This is the name of the parent or top-level label or key in our dictionary. This tells the Jinja template the next level to look for the variable we substitute.
login
: This is another label or key down the dictionary, and again tell us where to keep looking for the final variable.
default
: This is the key-value pair mapping in the dictionary that we want assigned as the variable. We see this is the final parameter in our variable substitution because we want the value of that key to be the result.
The output of the data model against that template is as follows:
Loops and Conditionals¶
Loops¶
Let's start this section by checking out what is available to us loop wise, and that is easy as the only option we have is the for
loop!
For loops use the following Jinja statement syntax:
For our first example, lets loop through the below list with a single DNS server:
The template with a for loop to iterate through this list would be as follows:
Let's analyze the sections of this template.
dns
: DNS is a variable that we are using to represent each item in the list. This can be anything you wish.
name_servers
: This is the parent label or key of the list. This says which items in the list we want to iterate through and assign to the variable we defined.
After assigning the value in the list to our created variable, we issue our configuration line which contains static text and our variable. The output would look as follows:
What if we had multiple items in the list like this:
The for loop would run as many times as there are items in the list and the configuration file output would look as follows:
ip name-server 10.100.100.20
ip name-server 8.8.8.8
ip name-server 4.4.4.4
ip name-server 208.67.222.222
Let's look at a slightly more complex, nested data structure, such as a dictionary with a list item. We will use the following portion of our data model:
The template with a for loop to iterate through this list would be as follows:
{% for rsrv in radius_servers %}
radius-server host {{ rsrv.host }} vrf {{ rsrv.vrf }} key {{ rsrv.key }}
{% endfor %}
Let's analyze the sections of this template.
rsrv
: This variable represents an item in the dictionary radius_servers
as the dictionary is looped over.
Looking at the configuration line we created, we see instead of walking through the dictionary via the dictionary key names, we key off our variable which represents the items in our dictionary. This can be seen with the rsrv.hosts
line. This means we are looking for the value of the host
key for each server in our list that is currently assigned to the rsrv
variable. The same holds true for the rsrv.vrf
and rsrv.key
lines.
The for loop would run as many times as there are items in the list, which is just one, and the configuration file output would look as follows:
While that was simple, what if we have something more complex, like a dictionary with a list of dictionaries? Using the following YAML data model, let's look at how this would be represented in Jinja.
interfaces:
Ethernet1:
descr: "Trunk Port"
mode: "trunk"
Ethernet2:
descr: "Access Port"
mode: "access"
The template with a for loop to iterate through this list would be as follows:
{% for intf in interfaces %}
interface {{ intf }}
description {{ interfaces[intf].descr }}
switchport mode {{ interfaces[intf].mode }}
{% endfor %}
Let's analyze the sections of this template.
intf
: This variable represents each primary key in the dictionary interfaces as the dictionary is looped over.
Looking at the reset of the variables defined, we can see it gets a little trickier to read them. Assuming this is the first iteration through the for loop, let's break down the variable definitions.
interfaces[intf].descr
: This describes the first item in the dictionary. Since intf is the dictionary's primary key variable, we know through the first iteration that intf
represents Ethernet1. Therefore, this variable will read as interfaces[Ethernet1].descr
. This would then change to interfaces[Ethernet2].descr
as the next iteration of the loop happens.
interfaces[intf].mode
: This follows the same logic of a variable within the variable as the above explanation. This variable will read as interfaces[Ethernet1].mode
. This would then change to interfaces[Ethernet2].mode
as the next iteration of the loop happens.
The for loop would run as many times as there are items in the list and the configuration file output would look as follows:
interface Ethernet1
description Trunk Port
switchport mode trunk
interface Ethernet2
description Access Port
switchport mode access
items() Method¶
As one can imagine from the above example, the syntax of accessing the variables within the nested dictionary can quickly become complex, only exasperated by having it recursively work through nested dictionaries with multiple levels. Fortunately, there is a nice way to access both the dictionary keys and their attributes simultaneously. This is done with the items()
method. Let's check out how the previous example would look using the items()
method instead.
Lets revisit our data model again:
interfaces:
Ethernet1:
descr: "Trunk Port"
mode: "trunk"
Ethernet2:
descr: "Access Port"
mode: "access"
Looking at the nested dictionary, we can start assigning variables. We will use intf
for the keys, representing Ethernet1 and Ethernet2 as we iterate. We can then use the variable intf_data
to represent the attributes under each key, descr
and mode
. Since we want to grab the keys and attributes of everything under the top-level interfaces
key, we will call our items()
method against that.
Lets check out what our Jinja template will look like to accomplish this:
{% for intf, intf_data in interfaces.items() %}
interface {{ intf }}
description {{ intf_data.descr }}
switchport mode {{ intf_data.mode }}
{% endfor %}
The for loop would run as many times as there are items in the list and the configuration file output would look as follows:
interface Ethernet1
description Trunk Port
switchport mode trunk
interface Ethernet2
description Access Port
switchport mode access
Conditionals¶
Conditionals, such as {% if %}
, {% elif %}
, and {% else %}
, are extremely helpful for either configurations that may apply to only a subset of devices you are generating configurations for.
With this example we will cover both conditionals and booleans.
In this example we will look at the following data model:
The following dictionary key is what we are going to pay close attention to for our Jinja template: shutdown
Here we will use a Jinja template to configure whether the interfaces defined are in a shutdown state or not:
{% if device.interfaces.Ethernet47.shutdown == true %}
interface Ethernet47
shutdown
{% elif device.interfaces.Ethernet47.shutdown == false %}
interface Ethernet47
no shutdown
{% endif %}
Reviewing this template, we can see we are using a conditional based on whether the boolean of the key shutdown is true
or false
. Lets see what the output is depending on what boolean we are using.
shutdown: true¶
shutdown: false¶
Variable Checking¶
One scenario we need to consider is when our Jinja template renders a configuration for a device that may not have a data model defined. In this instance, we want our Jinja template to use a conditional to check if the variable we are trying to read exists.
There are several ways to check this, however, one way would be with the is defined
syntax shown below:
Inside of this conditional, we can then put whatever Jinja template we want, and it will only be rendered if the data model exists for that node.
Lets check out an example using a data model similar to our previous one.
Now lets review variable checking implemented in this template.
{% if device.interfaces.Ethernet47.ip is defined %}
interface Ethernet47
ip address {{ device.interfaces.Ethernet47.ip }}
{% else %}
oh no the ip address is missing!
{% endif %}
Filters¶
The final section we will cover is filters. While multiple filters are available, we will cover a very common one, the ipaddr
filter. In a simple explanation, the ipaddr
filter takes a full IP address and subnet mask and strips off just the mask, resulting in only the address. This can be helpful when using a full prefix and mask in your data model, and you don't want to create a new, duplicate, key-value pair mapping to be called.
IPADDR() Filter¶
The ipaddr
filter has various operations, allowing you to manipulate a prefix or network and obtain certain information about it. This can be helpful when using a full prefix and mask in your data model, and you don't want to create a new, duplicate, key-value pair mapping to be called.
The following are some of the functions available within the ipaddr() filter:
address:
When using a prefix in x.x.x.x/yy notation, this filter will pull only the address portion.
network:
This filter will calculate the network ID of a given prefix.
netmask:
This filter will generate the subnet mask from /yy prefix/CIDR notation.
broadcast:
This filter will calculate the broadcast address of a given prefix.
size:
This filter will calculate the size of a subnet based on the subnet mask.
range_usable:
This filter finds the range of usable addresses within a subnet.
To illustrate this, we will use a simple example for showing the IP info of these two interfaces run against a single device, spine1.
The data model is as follows:
The Jinja template is as follows, which will use the above described filters to give us various information about the IP addresses assigned to Ethernet1 and Ethernet2:
Here is some information about some interfaces:
Ethernet1
IP: {{ interfaces.Ethernet1.ipv4 | ipaddr('address') }}
Subnet Mask: {{ interfaces.Ethernet1.ipv4 | ipaddr('netmask') }}
Network ID: {{ interfaces.Ethernet1.ipv4 | ipaddr('network') }}
Network Size: {{ interfaces.Ethernet1.ipv4 | ipaddr('size') }}
Usable Range: {{ interfaces.Ethernet1.ipv4 | ipaddr('range_usable') }}
Broadcast Address: {{ interfaces.Ethernet1.ipv4 | ipaddr('broadcast') }}
Ethernet2
IP: {{ interfaces.Ethernet2.ipv4 | ipaddr('address') }}
Subnet Mask: {{ interfaces.Ethernet2.ipv4 | ipaddr('netmask') }}
Network ID: {{ interfaces.Ethernet2.ipv4 | ipaddr('network') }}
Network Size: {{ interfaces.Ethernet2.ipv4 | ipaddr('size') }}
Usable Range: {{ interfaces.Ethernet2.ipv4 | ipaddr('range_usable') }}
Broadcast Address: {{ interfaces.Ethernet2.ipv4 | ipaddr('broadcast') }}
After reviewing our template, we run the playbook against the current inventory device, keying in on the IPv4 address mapping. We then use the filter command |
and specify the address
keyword, meaning we only want the address part of the whole prefix.
Running the playbook results in the following output:
Here is some information about some interfaces:
Ethernet1
IP: 1.1.1.1
Subnet Mask: 255.255.255.192
Network ID: 1.1.1.0
Network Size: 64
Usable Range: 1.1.1.1-1.1.1.62
Broadcast Address: 1.1.1.63
Ethernet2
IP: 2.2.2.2
Subnet Mask: 255.0.0.0
Network ID: 2.0.0.0
Network Size: 16777216
Usable Range: 2.0.0.1-2.255.255.254
Broadcast Address: 2.255.255.255
JOIN() Filter¶
When working with lists in YAML, it may be necessary to concatenate list items into a single-line configuration command instead of looping through the list and creating a configuration command for each item. Examples could be configuring a list of NTP or DNS servers in a single line versus individual entries. This can be accomplished with the following filter:
join(" "):
This filter joins all the items in the list. Pay close attention to the space between the double quotes. This provides spacing between the items in a list. Without this space, all items in the list would be joined into one continuous string.
To illustrate this, we will use the DNS server data model and Jinja template.
The data model is as follows:
The following Jinja template will allow us to configure all four DNS servers on a single line for a single configuration command:
Running the playbook results in the following output:
Onto the Jinja Lab¶
Now that we have learned about the various Jinja templating constructs, let's jump into the lab and write our templates using our previously created data models and network device configs!