Network Config Templating

using Ansible, Part3

Author: Kirk Byers
Date: 2014-03-25

In Part1 of this blog series, I demonstrated the basics of using Ansible for network configuration templating.

In Part2, I expanded upon this system to create full configuration files including using conditionals.

In this article, I am going to generalize the system and show you how to: 1)use different templates for a single role, 2)create a template hierarchy, and 3)use different roles.

As a quick reminder, there are three parts to this system—1)the tasks file (tasks/main.yml), 2)the vars file (vars/main.yml), and 3)the template file (currently, templates/router.j2). These files are all organized under an Ansible role (in my example, ./RTR-TEMPLATE/roles/router).

Section1—One Role, Multiple Templates

Throughout this series of articles, I have been assuming one type of router (a remote-office router) and a single model (a Cisco 881)—but what if I have a mix of models? What if I have thirty-Cisco 881s, twenty-Cisco 1921s, and a few Cisco 2901s? How could you handle model-specific differences in the context of one type of router (a remote-office router)?

One way you could solve this problem is by using conditionals. For example, 'if cisco881', then insert the Cisco 881 relevant elements; 'elif cisco1921', then insert something else. This allows you to maintain a single template, but could get unwieldy (due to too many router models, too many differences between the models, or some combination thereof). An alternative to this approach is to break the template file up into separate templates based on the model.

Let's look at this second approach (a template file for each router model)—how can we accomplish this? First, we need to edit the tasks file (tasks/main.yml) and then we need to edit the vars file (vars/main.yml). Below is an updated tasks file:

---
- name: Generate configuration files
  template: src=router-881.j2 dest=/home/gituser/ANSIBLE/CFGS/{{item.hostname}}.txt
  with_items: "{{ routers_881 }}"

- name: Generate configuration files
  template: src=router-1921.j2 dest=/home/gituser/ANSIBLE/CFGS/{{item.hostname}}.txt
  with_items: "{{ routers_1921 }}"

The tasks file has been modified to use two different variables—one containing the Cisco 881 routers (routers_881) and one containing the Cisco 1921 routers (routers_1921). It also has been modified to use two different templates (router-881.j2 and router-1921.j2).

And here is my updated vars file (vars/main.yml):

---
routers_881:
   - { hostname: twb-sf-rtr1, secret: apassword, timezone: PST, timezone_dst: PDT, timezone_offset: -8, DHCP: true, dhcp_exclude1_start: 10.1.1.1, dhcp_exclude1_end: 10.1.1.99, dhcp_network: 10.1.1.0, dhcp_netmask: 255.255.255.0, dhcp_gateway: 10.1.1.1, CBAC: true, public_ip: 6.6.6.6, public_netmask: 255.255.255.0, public_gateway: 6.6.6.1, internal_ip: 10.1.1.1, internal_network: 10.1.1.0 }

cisco_881_l2_interfaces:
  - FastEthernet0
  - FastEthernet1
  - FastEthernet2
  - FastEthernet3

routers_1921:
   - { hostname: twb-sf-rtr2, secret: apassword, timezone: PST, timezone_dst: PDT, timezone_offset: -8, DHCP: true, dhcp_exclude1_start: 10.1.1.1, dhcp_exclude1_end: 10.1.1.99, dhcp_network: 10.1.1.0, dhcp_netmask: 255.255.255.0, dhcp_gateway: 10.1.1.1, CBAC: true, public_ip: 6.6.6.6, public_netmask: 255.255.255.0, public_gateway: 6.6.6.1, internal_ip: 10.1.1.1, internal_network: 10.1.1.0 }

This vars file is identical to the vars file in Part2 except that I renamed 'vlan10_ip' and 'vlan10_network' to 'internal_ip' and 'internal_network', respectively. I also simplified the file to only one Cisco 881 and only one Cisco 1921. Finally, I organized the routers to be either in the routers_881 list or in the routers_1921 list.

Next, I need to create the two template files (templates/router-881.j2 and templates/router-1921.j2). The 'router-881.j2' template is identical to the 'router.j2' template in Part2 except that I have renamed 'vlan10_ip' and 'vlan10_network' to 'internal_ip' and 'internal_network'. I also removed the 'ntp source Vlan10' line from the template.

router-881.j2 Template (interfaces section including ip nat)

<<< snip >>>
!
{% for interface in cisco_881_l2_interfaces %}
interface {{interface}}
 switchport access vlan 10
 spanning-tree portfast
 !
!
{% endfor %}
interface FastEthernet4
 ip address {{item.public_ip}} {{item.public_netmask}}
 ip access-group INTERNET in
 no ip redirects
 no ip proxy-arp
 ip nat outside
{% if item.CBAC %} ip inspect INTERNET out
{% endif %}
 ip virtual-reassembly
 duplex auto
 speed auto
 no cdp enable
 !
!
interface Vlan1
 no ip address
 !
!
interface Vlan10
 description Internal LAN
 ip address {{item.internal_ip}} 255.255.255.0
 ip nat inside
 ip virtual-reassembly
!
no ip http server
no ip http secure-server
!
!
ip nat inside source list NAT interface FastEthernet4 overload
<<< snip >>>

The router-1921.j2 template is identical to the router-881.j2 template except it has a different interfaces section and a different 'ip nat' statement.

router-1921.j2 Template (interfaces section including ip nat)

<<< snip >>>
!
interface GigabitEthernet0/0
 ip address {{item.public_ip}} {{item.public_netmask}}
 ip access-group INTERNET in
 no ip redirects
 no ip proxy-arp
 ip nat outside
{% if item.CBAC %} ip inspect INTERNET out
{% endif %}
 ip virtual-reassembly
 duplex auto
 speed auto
 no cdp enable
 !
!
interface GigabitEthernet0/1
 description Internal LAN
 ip address {{item.internal_ip}} 255.255.255.0
 no ip redirects
 no ip proxy-arp
 ip nat inside
 ip virtual-reassembly
 duplex auto
 speed auto
!
no ip http server
no ip http secure-server
!
!
ip nat inside source list NAT interface GigabitEthernet0/0 overload
<<< snip >>>

Now that this is all setup, we can execute the playbook:

[gituser@ RTR-TEMPLATE]$ ansible-playbook site.yml

PLAY [Generate configuration files] ******************************************* 

GATHERING FACTS *************************************************************** 
ok: [localhost]

TASK: [router | Generate configuration files] ********************************* 
changed: [localhost] => (item={'timezone_dst': 'PDT', 'dhcp_network': '10.1.1.0', 'CBAC': True, 'internal_network': '10.1.1.0', 'timezone_offset': -8, 'hostname': 'twb-sf-rtr1', 'public_gateway': '6.6.6.1', 'dhcp_gateway': '10.1.1.1', 'public_ip': '6.6.6.6', 'dhcp_netmask': '255.255.255.0', 'secret': 'apassword', 'dhcp_exclude1_end': '10.1.1.99', 'public_netmask': '255.255.255.0', 'timezone': 'PST', 'DHCP': True, 'internal_ip': '10.1.1.1', 'dhcp_exclude1_start': '10.1.1.1'})

TASK: [router | Generate configuration files] ********************************* 
changed: [localhost] => (item={'timezone_dst': 'PDT', 'dhcp_network': '10.1.1.0', 'CBAC': True, 'internal_network': '10.1.1.0', 'timezone_offset': -8, 'hostname': 'twb-sf-rtr2', 'public_gateway': '6.6.6.1', 'dhcp_gateway': '10.1.1.1', 'public_ip': '6.6.6.6', 'dhcp_netmask': '255.255.255.0', 'secret': 'apassword', 'dhcp_exclude1_end': '10.1.1.99', 'public_netmask': '255.255.255.0', 'timezone': 'PST', 'DHCP': True, 'internal_ip': '10.1.1.1', 'dhcp_exclude1_start': '10.1.1.1'})

PLAY RECAP ******************************************************************** 
localhost                  : ok=3    changed=2    unreachable=0    failed=0

Executing this command created the two configuration files in the 'CFGS' directory as we would expect. Quick inspection of the two files shows that the 'twb-sf-rtr1.txt' file has the Cisco 881 interfaces and nat statement while the 'twb-sf-rtr2.txt' file has the Cisco 1921 interfaces and nat statement.

Section2—Create a Template Hierarchy

In Section1 of this article, I showed you how to use multiple templates to handle model specific differences for a given type of router (remote-office router). Unfortunately, I have now duplicated large sections of the configuration across two templates—this is two locations that need updated. This setup can also easily result in configuration drift across time (updates to only one template and not to the other). It would be nice if we could have most of the common configuration elements in one file. So how can we accomplish this?

We can accomplish this by building a hierarchy of templates. First, we start by defining a base template (templates/base.j2). This file contains the sections of the config that are the same between the two files. In our example, the essential differences between the router-881.j2 and router-1921.j2 file is the interfaces section and the global 'ip nat' statement. Consequently, the base template will contain everything but these two sections. The base template will also contain references (block and endblock) to the missing sections (see below).

Note, I am omitting large sections of the base template (<<< snip >>>) in order to focus on the parts that are relevant.

base.j2 template

no service pad
service tcp-keepalives-in
service tcp-keepalives-out
service timestamps debug datetime msec localtime show-timezone
service timestamps log datetime msec localtime show-timezone
service password-encryption
!
hostname {{item.hostname}}

<<< snip >>>

{% block interfaces %}
{% endblock %}
!  
no ip http server
no ip http secure-server
!
!
{% block nat %}
{% endblock %}
ip route 0.0.0.0 0.0.0.0 {{item.public_gateway}}

<<< snip >>>

line vty 0 4
 exec-timeout 20 0
 logging synchronous
 transport input ssh
 transport output ssh
!
ntp update-calendar
ntp server 1.1.1.1
ntp server 2.2.2.2
end

Inside the templates/base.j2 file we have two named blocks ('interfaces' and 'nat'). This is where additional configuration elements will be inserted.

After creating the base.j2 template file, we must then modify our router-881.j2 and router-1921.j2 template files. These two files will use base.j2 and will insert their configuration elements into the two named blocks. For example, here is the new router-881.j2.

router-881.j2 template

{% extends "base.j2"%}

{% block interfaces %}
{% for interface in cisco_881_l2_interfaces %}
interface {{interface}}
 switchport access vlan 10
 spanning-tree portfast
 !
!
{% endfor %}
interface FastEthernet4
 ip address {{item.public_ip}} {{item.public_netmask}}
 ip access-group INTERNET in
 no ip redirects
 no ip proxy-arp
 ip nat outside
{% if item.CBAC %} ip inspect INTERNET out
{% endif %}
 ip virtual-reassembly
 duplex auto
 speed auto
 no cdp enable
 !
!
interface Vlan1
 no ip address
 !
!
interface Vlan10
 description Internal LAN
 ip address {{item.internal_ip}} 255.255.255.0
 ip nat inside
 ip virtual-reassembly
 !
{% endblock %}

{% block nat %}
ip nat inside source list NAT interface FastEthernet4 overload
{% endblock %}

At the very top of this file, I state that this template is extending the 'base.j2' template file. I then specify the start and end of the 'block interfaces' and of the 'block nat'. Each of these blocks will be inserted into the base.j2 file at the position defined in base.j2.

I then must change the router-1921.j2 file in a similar way (i.e. define it to extend the base.j2 file and create the interfaces and nat blocks).

After this is done, I can then execute my playbook. Note, tasks/main.yml and vars/main.yml are the same as they were in Section1 of this article.

[gituser@ RTR-TEMPLATE]$ ansible-playbook site.yml

PLAY [Generate configuration files] ******************************************* 

GATHERING FACTS *************************************************************** 
ok: [localhost] 

TASK: [router | Generate configuration files] ********************************* 
ok: [localhost] => (item={'timezone_dst': 'PDT', 'dhcp_network': '10.1.1.0', 'CBAC': True, 'internal_network': '10.1.1.0', 'timezone_offset': -8, 'hostname': 'twb-sf-rtr1', 'public_gateway': '6.6.6.1', 'dhcp_gateway': '10.1.1.1', 'public_ip': '6.6.6.6', 'dhcp_netmask': '255.255.255.0', 'secret': 'apassword', 'dhcp_exclude1_end': '10.1.1.99', 'public_netmask': '255.255.255.0', 'timezone': 'PST', 'DHCP': True, 'internal_ip': '10.1.1.1', 'dhcp_exclude1_start': '10.1.1.1'})

TASK: [router | Generate configuration files] ********************************* 
ok: [localhost] => (item={'timezone_dst': 'PDT', 'dhcp_network': '10.1.1.0', 'CBAC': True, 'internal_network': '10.1.1.0', 'timezone_offset': -8, 'hostname': 'twb-sf-rtr2', 'public_gateway': '6.6.6.1', 'dhcp_gateway': '10.1.1.1', 'public_ip': '6.6.6.6', 'dhcp_netmask': '255.255.255.0', 'secret': 'apassword', 'dhcp_exclude1_end': '10.1.1.99', 'public_netmask': '255.255.255.0', 'timezone': 'PST', 'DHCP': True, 'internal_ip': '10.1.1.1', 'dhcp_exclude1_start': '10.1.1.1'})

PLAY RECAP ******************************************************************** 
localhost                  : ok=3    changed=0    unreachable=0    failed=0

Note, the Ansible output shows you that the configuration files were unchanged. In other words, Ansible did not actually update the configuration files because the new configuration files using a template hierarchy are identical to the configuration files generated in Section1.

Section3—Multiple Roles

In Sections1 and 2, I showed you how to generate the configurations for one type of router (a remote-office router) while supporting different models (a Cisco 881 and a Cisco 1921). But what if you need to support multiple types of network devices in your templating system? What if you need to support remote-office routers, access-switches, and some-other-category of network device in your templating system?

In order to support a new category of network device, we simply create a new role in Ansible. Starting in the ./RTR-TEMPLATE/roles directory, I create the following subdirectories (see Part1 for more information):

[gituser@ RTR-TEMPLATE]$ cd roles/
[gituser@ roles]$ mkdir access-switch
[gituser@ roles]$ cd access-switch/
[gituser@ access-switch]$ mkdir tasks
[gituser@ access-switch]$ mkdir vars
[gituser@ access-switch]$ mkdir templates

Inside of these directories, you must then create the tasks/main.yml file, the vars/main.yml file, and one or more template files in templates/. Follow the same patterns that I covered earlier in this series of articles.

Once your tasks/main.yml, vars/main.yml, and template file(s) are configured, you must then edit the main site.yml file (in my example this file is located in ./RTR-TEMPLATE/site.yml). In this file you simply add the new role to your set of roles

---
- name: Generate configuration files
  hosts: localhost
  roles:
    - router
    - access-switch

Now when you execute your playbook, Ansible will execute each of specified roles. In this manner all of the configuration files specified in all of the roles will be generated.

In order to demonstrate the operation of multiple roles, I setup the access-switch tasks/main.yml, vars/main.yml, and templates/access-switch.j2 files. The configuration template, access-switch.j2, is a small configuration snippet. The vars/main.yml file specifies five access-switches (twb-sf-sw1 through twb-sf-sw5). The routers role is unchanged from Section2 of this article. Note, I also made one other minor change to the site.yml file above (I changed the 'name' string to 'Generate configuration files' so that it was more general).

Now, I can then execute this playbook:

[gituser@ RTR-TEMPLATE]$ ansible-playbook site.yml

PLAY [Generate configuration files] ******************************************* 

GATHERING FACTS *************************************************************** 
ok: [localhost]

TASK: [router | Generate configuration files] ********************************* 
ok: [localhost] => (item={'timezone_dst': 'PDT', 'dhcp_network': '10.1.1.0', 'CBAC': True, 'internal_network': '10.1.1.0', 'timezone_offset': -8, 'hostname': 'twb-sf-rtr1', 'public_gateway': '6.6.6.1', 'dhcp_gateway': '10.1.1.1', 'public_ip': '6.6.6.6', 'dhcp_netmask': '255.255.255.0', 'secret': 'apassword', 'dhcp_exclude1_end': '10.1.1.99', 'public_netmask': '255.255.255.0', 'timezone': 'PST', 'DHCP': True, 'internal_ip': '10.1.1.1', 'dhcp_exclude1_start': '10.1.1.1'})

TASK: [router | Generate configuration files] ********************************* 
ok: [localhost] => (item={'timezone_dst': 'PDT', 'dhcp_network': '10.1.1.0', 'CBAC': True, 'internal_network': '10.1.1.0', 'timezone_offset': -8, 'hostname': 'twb-sf-rtr2', 'public_gateway': '6.6.6.1', 'dhcp_gateway': '10.1.1.1', 'public_ip': '6.6.6.6', 'dhcp_netmask': '255.255.255.0', 'secret': 'apassword', 'dhcp_exclude1_end': '10.1.1.99', 'public_netmask': '255.255.255.0', 'timezone': 'PST', 'DHCP': True, 'internal_ip': '10.1.1.1', 'dhcp_exclude1_start': '10.1.1.1'})

TASK: [access-switch | Generate configuration files] ************************** 
changed: [localhost] => (item={'hostname': 'twb-sf-sw1'})
changed: [localhost] => (item={'hostname': 'twb-sf-sw2'})
changed: [localhost] => (item={'hostname': 'twb-sf-sw3'})
changed: [localhost] => (item={'hostname': 'twb-sf-sw4'})
changed: [localhost] => (item={'hostname': 'twb-sf-sw5'})

PLAY RECAP ******************************************************************** 
localhost                  : ok=4    changed=1    unreachable=0    failed=0

You can see from the above that Ansible did not regenerate the configuration files for the 'routers' role (these files were unchanged from before). Ansible did, however, generate the five access-switch configuration snippet files.

If I check my ./CFGS directory, I see all of the relevant configuration files:

[gituser@ CFGS]$ ls -al
total 40
drwxr-xr-x  3 gituser gituser 4096 Mar 25 11:19 .
drwxr-xr-x 11 gituser gituser 4096 Mar 10 16:57 ..
drwxr-xr-x  2 gituser gituser 4096 Mar 25 11:19 OLD
-rw-------  1 gituser gituser 2949 Mar 25 09:37 twb-sf-rtr1.txt
-rw-------  1 gituser gituser 2674 Mar 25 10:42 twb-sf-rtr2.txt
-rw-------  1 gituser gituser  322 Mar 25 11:14 twb-sf-sw1.txt
-rw-------  1 gituser gituser  322 Mar 25 11:14 twb-sf-sw2.txt
-rw-------  1 gituser gituser  322 Mar 25 11:14 twb-sf-sw3.txt
-rw-------  1 gituser gituser  322 Mar 25 11:14 twb-sf-sw4.txt
-rw-------  1 gituser gituser  322 Mar 25 11:14 twb-sf-sw5.txt

Note from 2017-02-15, newer versions of ansible now require the following format with_items: "{{ test_routers }}". Consequently, I updated this article to reflect this requirement.

This concludes my series of articles on using Ansible as a configuration templating system. I hope that you found it useful.

Kirk Byers

@kirkbyers

You might also be interested in: