Network Configuration Templating with Ansible – Part 2
In Part 1 of this series on configuration templating, we looked at how to take some basic configurations and extract values that can be made into variables based on different data points, such as location or device type. Now that you have a foundation of how to extract data from configurations in order to create a list of configuration variables, how do you use this information to generate configurations? The next step is looking at how to use these variables to programmatically generate the corresponding configuration files. In order to do this we use a templating language called Jinja2.
Jinja2
Jinja2 is a way to take template files (.j2 extension) based on the original text of a file, and do replacements of sections, lines, or even individual characters within the configuration based on a set of structured data (variables). In order to denote plain text from variable sections in the configuration, Jinja2 uses curly braces and percent signs to allow “codification” of sections of the text and double curly braces to denote variables to inject in the text.
Template Files
For example, if we look at a stripped-down version of the YAML variables from the first example in part 1 of this blog series (variables.yaml), and create a Jinja2 template file called template.j2
as follows:
# variables.yaml
ntp:
servers:
- ip: "1.1.1.1"
- ip: "1.0.0.1"
- ip: "8.8.8.8"
# template.j2
hostname {{ inventory_hostname }}
{% for server in ntp["servers"] %}
ntp server {{ server["ip"] }}
{% endfor %}
Running this template through the Jinja2 engine would yield the following text:
# result.cfg
hostname router1
ntp server 1.1.1.1
ntp server 1.0.0.1
ntp server 8.8.8.8
You may be wondering where router1
came from in the resulting configuration. inventory_hostname
is a built-in Ansible variable that references the hostname of the device (from the Ansible inventory) that is currently being worked on. See Special Varialbes – Ansible for more information.
We can see the utilization of a for
statement in {% for server in ntp["servers"] %}
that will loop through all the server objects in the YAML file, fill in with the {{ server["ip"] }}
variable, and generate a complete line for each of the server ip addresses in the YAML data. If you are familiar with Ansible and Python, the variable syntax will look similar when working with lists and dictionaries in Jinja2. Also, note that code sections have both an opening and closing set of braces and percent signs: {% for x in y %}
and {% endfor %}
. The text and variables inside these two statements is what will get acted upon by the Jinja2 engine. By carefully placing these, you can be very specific on which portions of the config get templated versus just being moved through the engine verbatim.
Placement and Spacing Are Important
If we change the template.j2
file (same YAML file) to look like the following example instead, there will be a completely different result. In some configurations, the config syntax puts all the server IPs on the same line. Note, Jinja2 is very particular on spacing and indentation. Spacing and indentation will be the same as it is laid out in the Jinja2 template file. (Notice the space after {{ server }}
to get spaces between the IPs.)
# template.j2
ntp server {% for server in ntp["servers"] %}{{ server }} {% endfor %}
# result.cfg
ntp server 1.1.1.1 1.0.0.1 8.8.8.8
So, placement of the code blocks can be very flexible and allow for just about any combination of raw text and structured data to be combined.
Playbook
Now that we understand how to work with the structured data/variables, and how to build the Jinja2 template files, we can write an Ansible playbook to generate the configuration snippet. We assume Ansible is already installed on your machine for this.
For this demo, it’s assumed that your file structure is flat (no folders) with all files in the same folder, with the exception of the configs
folder, which is where the generated configurations will be placed by Ansible. We will use the same template.j2
file that was used in the beginning of this post to generate NTP configurations for three routers.
File Structure
(base) {} ansible tree
.
├── configs
├── inventory
├── playbook.yaml
├── template.j2
└── variables.yaml
# inventory
router1
router2
router3
# playbook.yaml
- name: Template Generation Playbook
hosts: all
gather_facts: false
vars_files:
- ./variables.yaml
tasks:
- name: Generate template
ansible.builtin.template:
src: ./template.j2
dest: ./configs/.cfg
delegate_to: localhost
We’ll run the playbook with the following command ansible-playbook -i inventory playbook.yaml
, and we should see three new files output in the current working directory. When not connecting to devices, it is important to use the delegate_to
option, otherwise Ansible will try to SSH to the devices in your inventory and attempt to do the templating there. This normally doesn’t work for network devices, so we have the Ansible host generate the template files itself.
Playbook output:
(base) {} ansible ansible-playbook -i inventory playbook.yaml
PLAY [Template Generation Playbook] ***********************************************************************************************************************
TASK [Generate template] ***********************************************************************************************************************
changed: [router1 -> localhost]
changed: [router3 -> localhost]
changed: [router2 -> localhost]
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
We now see three new files in the configs
folder:
(base) {} ansible tree
.
├── configs
│ ├── router1.cfg
│ ├── router2.cfg
│ └── router3.cfg
├── inventory
├── playbook.yaml
├── template.j2
└── variables.yaml
If we open up one of the .cfg
files, we’ll see the contents are all the same, aside from the hostname, which is specific to the device. This is because we used the inventory_hostname
variable in the Jinja2 template.
# router1.cfg
hostname router1
ntp server 1.1.1.1
ntp server 1.0.0.1
ntp server 8.8.8.8
# router2.cfg
hostname router2
ntp server 1.1.1.1
ntp server 1.0.0.1
ntp server 8.8.8.8
# router3.cfg
hostname router3
ntp server 1.1.1.1
ntp server 1.0.0.1
ntp server 8.8.8.8
It is possible to do even more complex variable replacements when variable inheritance/hierarchy is used, which will be discussed later in this series. For now, you should be mostly comfortable with generating basic templates and external variable files. This method can also be extended with Ansible tasks like get_facts
or textFSM
to gather “live” values from devices to further enrich templates.
Wrap-up
In Part 3 of this series, we will be covering macros and other Jinja2 functions, and how to use them in your templates.
In Part 4 of this series, we will cover advanced templating with variable inheritenace. This is how you can assign different values to variables based on a set of predefined criteria, such as location, device type, or device function.
Conclusion
There is also a couple pieces of software to run these templating tests outside of writing code. This way it’s possible to test even before a decision is made on how the templating will actually be run (using Python, Ansible, etc.). The first one is j2live.ttl225.com, written by our very own @progala! Also noteworthy; TD4A – GitHub or TD4A – Online
-Zach
Tags :
Contact Us to Learn More
Share details about yourself & someone from our team will reach out to you ASAP!