September 17, 2017 · network automation roles templating Jinja2 Includes Macros

Networking with Ansible 107

1.0 Introduction

This post will mainly focus on templating. Templating is what we do when we want to dynamically generate configurations. While repetition does make perfect, we definitely want to apply ourselves to the software development principle of DRY, a change of any one element should never impact other logically unrelated elements Our templating system needs be predictable when it comes to changes and behave in a uniform matter no matter what tasks we throw at it.

The way we will approach this is by defining global variables that is processed with an awereness of system dependencies and requirements in our templating system and the product of this will be our generated configuration.

Jinja2 templating terminology

The templating engine of Jinja2 is very flexible and there are some new terminology which will be good to have before reading further.

A Jinja template is always a text file, and text-based format can be generated with Jinja, Jinja does not have any extension of it's own. .html, yml, ini is all fine for usage.

A template contains variables and/or expressions which becomes values when our template is rendered. Furthermore we control the templating logic with tags.

Default Jinja delimiters
{% ... %} for Statements
{{ ... }} for Expressions
{# ... # for Comments (Not included in template output)
# ... ## for Line Statements

Includes
The include statement is useful to include a template and return the rendered contents of the included file into the current namespace. We use includes to share common and reusable state configurations when processing in the templating engine.

Macros
Think of macros as jinjas way of implementing functions. It is with macros we can achieve the the DRY principle, oftens used idioms are created with macros and just as we can call on functions in the world of programming we can call upon our macros instead of keep repeating the same syntax over and over again, let's save that for the generated configurations.

1.1 Templating Basics

We will be using a Jinja2 conditional in one playbook to generate configuration files for the same setting, but with syntax matching the target devices. This is going to be tested on both ios, eos and nxos. We will see that creating dynamically generated configuration files is not really that difficult a concept, and while we are doing a limited set of actions it should at least give us an appreciation of the level of difficulty it entails.

The IF-statement

template.yml

---
- name: 
  hosts: cisco:arista:nxos
  tasks:
    - template:
        src: test_template.j2
        dest: "./CFGS/{{ inventory_hostname }}.txt"

This is our playbook which targets our cisco, arista and nxos devices. Under tasks we are invoking the templating function and we point to our template source file and also the dynamically created configuration file under the CFGS directory, using theinventory_hostname and appending a .txt extension to the files.

test_template.j2

{% if platform == "ios" %}
ip name-server {{ name_server1 }}  
ip name-server {{ name_server2 }}  
{% elif platform == "eos" %}
ip name-server vrf default {{ name_server1 }}  
ip name-server vrf default {{ name_server2 }}  
{% elif platform == "nxos" %}
ip name-server {{ name_server1 }} {{ name_server2 }}  
{% endif %}

The syntax for ios, eos and nxos differs and this difference is handled by simply stating that if the device is of x type the configuration that is generated will only be relevant for that ansible target device.

The platform variable is defined under our target group file under group_vars.

Let's assume we are targeting an nxos device: The {% if platform == "ios" %} can result in either true or false, upon false it moves to the next conditional which would be {% elif platform == "eos" %}, since we're targeting nxos this also returns false and upon our last conditional it returns true and it generates a configuration for inventory_hostname under the CFGS directory with only the line that is generated by the {% elif platform == "nxos" %}.

It uses the variable we had declared in all.yml under group_vars and populates accordingly.

The result in this case are two files named nxos1 and nxos2 both containing the following line:

ip name-server 8.8.8.8 8.8.4.4

group_vars/all.yml

---
name_server1: 8.8.8.8  
name_server2: 8.8.4.4

default_domain: blandreas.fr

ntp1: 130.126.24.24  
ntp2: 152.2.21.1  

These are the variables that will be processed with the Jinja templating engine. There is no Ansible target logic at all to be found here, just domain, name server and ntp settings.

Running this give us the following output:

And let's verify the generated configuration files as well:

nxos1.txt
ip name-server 8.8.8.8 8.8.4.4
nxos2.txt
ip name-server 8.8.8.8 8.8.4.4
pynet-rtr1.txt
ip name-server 8.8.8.8
ip name-server 8.8.4.4
pynet-rtr2.txt
ip name-server 8.8.8.8
ip name-server 8.8.4.4
pynet-sw5.txt
ip name-server vrf default 8.8.8.8
ip name-server vrf default 8.8.4.4
pynet-sw6.txt
ip name-server vrf default 8.8.8.8
ip name-server vrf default 8.8.4.4
pynet-sw7.txt
ip name-server vrf default 8.8.8.8
ip name-server vrf default 8.8.4.4
pynet-sw8.txt
ip name-server vrf default 8.8.8.8
ip name-server vrf default 8.8.4.4

The old FOR loop

We're going to operate on the arista devices and instead of old-school typing out identical port configurations let's have them be generated. Remember, let's keep it DRY.

forlooptemplate-j2

{% for port_number in range(1, 8) %}
interface Ethernet{{ port_number }}  
   switchport mode access
   switchport access vlan 20
   spanning-tree portfast
   spanning-tree bpduguard enable
!
{% endfor %}

The first line defines a variable only scoped for use within or for-loop. It has a range from 1 to 8. Every iteration will increment the value of port_number until it has recieved the value 8.

The generated result will look as per below.:

interface Ethernet1  
   switchport mode access
   switchport access vlan 20
   spanning-tree portfast
   spanning-tree bpduguard enable
!
interface Ethernet2  
   switchport mode access
   switchport access vlan 20
   spanning-tree portfast
   spanning-tree bpduguard enable
!
.... until interface Ethernet8 has been generated.
Combining for,if and fact gathering

Let's test another thing, we've been doing facts gathering for so many posts now, I know, who cares right? Well, you will care soon. Let's have the template work with facts gathered from the target system instead since the information is already there and we would obviously be fools not to take advantage of that.

We're running if-statements in our for-loop which is not that hard, and it also gives us some additional ways to complicate(automate!!!) our configurations.

Let's go!

facts_generated_configurations.yml

---
- name: facts_generated_configurations
  hosts: arista
  tasks:
    - name:  Retrieve facts about device
      napalm_get_facts:
        provider: "{{ creds_napalm }}"
      tags: interface_tag

    - debug:
        var: napalm_interface_list

    - template:
        src: facts_generated_configurations.j2
        dest: "./CFGS/{{ inventory_hostname }}.txt"

We're using tags to make sure we are getting back a variable called napalm_interface_list this list variable contains all our available interfaces.

facts_generated_configurations.j2

{% for interface in napalm_interface_list %}
{% if interface not in ["Management1", "Vlan1"]: %}
interface {{ interface }}  
   switchport mode access
   switchport access vlan 20
   spanning-tree portfast
   spanning-tree bpduguard enable
!
{% endif %}
{% endfor %}

So in this case we are still using a for-loop but for every iteration we apply a conditional that exempts the interfaces Management1 and Vlan1 from being generated in our configuration file. This is achieved by the lineif interface not in ["Management1", "Vlan1"] which basically says if the current value of interface is either Management1 or Vlan1, do not iterate. We really do not want these interfaces to be changed at all.

To summarise, we collected facts from all the arista devices, we're then using the list in our jinja template to dynamically generate configuration for all the interfaces on the device, with the exception of Management1 and Vlan1 interfaces which we made exempt with the if statement.

1.2 Jinja Templating & Ansible Roles

Previously we solved the whole if platform x generate config_x "problem" with the following templating conditional:

{% if platform == "ios" %}
ip name-server {{ name_server1 }}  
ip name-server {{ name_server2 }}  
{% elif platform == "eos" %}
ip name-server vrf default {{ name_server1 }}  
ip name-server vrf default {{ name_server2 }}  
{% elif platform == "nxos" %}
ip name-server {{ name_server1 }} {{ name_server2 }}  
{% endif %}

Instead of having this logic in our templating system we can make us of Ansible Roles in our ansible playbook to accomplish the same thing. Playbook composing easily becomes a mess, previously we have hidden away stuff with both host- & group variables. Another really good feature of Ansible is Roles. Roles in themselves does not really contain any magic, think of it as a way of creating a structure for includes, roles don't really do anything but it does allow us (and forces us!) to grouping content around a structure.

I am certain I can still mess things up but in my opinion being forced to commit to creating a structure is a good thing, everything that makes you plan is great when building things! In this section we will build a structure playbook with the purpose of generating some dns configurations for us.

We are going to create the following folder and file structure:

templating_roles/  
├── CFGS
├── group_vars
│   ├── all.yml
│   ├── arista.yml
│   ├── cisco.yml
│   ├── juniper.yml
│   └── nxos.yml
├── roles
│   ├── arista
│   │   ├── tasks
│   │   │   └── main.yml
│   │   └── templates
│   │       └── dns.j2
│   ├── cisco_ios
│   │   ├── tasks
│   │   │   └── main.yml
│   │   └── templates
│   │       └── dns.j2
│   └── cisco_nxos
│       ├── tasks
│       │   └── main.yml
│       └── templates
│           └── dns.j2
└── site.yml

In the root of the playbook directory we have a file called site.yml which is a a default name which ansible looks up on runtime. It contains

site.yml

---
- name: Templating Structure with Ansible Roles.
  hosts: cisco:nxos:arista
  roles:
    - {role: 'cisco_ios', when: platform == "ios"}
    - {role: 'cisco_nxos', when: platform == "nxos"}
    - {role: 'arista', when: platform == "eos"}

As you we can see in our "main" file, we moved the conditional logic out of the template and into the playbook instead. As before the platform has been stated in our group_vars files.

In our example we give every role both a template and a task only pertaining to that role. The site.yml does not really do anything, it only contains logic to invoke other files to perform. Basically, it includes logic and data from roles, which in our example equals vendor types.

Let's have a look at the arista role.

roles/arista/tasks/main.yml

- template:
    src: dns.j2
    dest: "./CFGS/{{ inventory_hostname }}.txt"

Notice how we are not really specifying where dns.j2 is located, since that structure and point of reference has been taken care for us by creating a playbook structure. Since we have a folder called templates Ansible automatically looks for the file in that directory.

roles/arista/templates/dns.j2

ip name-server vrf default {{ name_server1 }}  
ip name-server vrf default {{ name_server2 }}  

This setup gives us exactly the same as the previous example, the big idea with using roles like this is that it forces a proper structure to be in place before creating templates. This will also create a situation where we use less dodgy logic, and the logic we are using will is a result of the structure of our playbooks.

Keep in mind for future production projects that it will be to our advantage to have as much logic in our playbook structures versus our templates, since the latter can get rather messy and create undesired dependencies.

1.3 Full Configuration Templating

We are going to look at pushing out full configurations by coupling Jinja with Ansible and/or Napalm. We are going to focus on pushing as much information about our devices into the inventory system. In this section we will be utilising the host_vars to declare host specific data. The end goal is to make the template configuration be more reusable.

Playbook Directory Structure

templating_full_configurations/  
├── gen_full_conf1.j2
├── gen_full_conf1.yml
├── group_vars
│   ├── all.yml
│   ├── arista.yml
│   ├── cisco.yml
│   ├── juniper.yml
│   └── nxos.yml
├── host_vars
│   ├── pynet-sw5
│   │   └── main.yml
│   ├── pynet-sw6
│   │   └── main.yml
│   ├── pynet-sw7
│   │   └── main.yml
│   └── pynet-sw8
│       └── main.yml
└── TEMP

Playbook: gen_full_conf1.yml
The main playbook looks a bit dull since everything is template based in our play to be. We define the hostgroup and we define our templating task source file and our directory and naming standard for our generated configuration files.

- name: Full Configuration Template Generation
  hosts: arista
  tasks:
    - name: Generate Arista Configs
      template: src=gen_full_conf.j2 dest=TEMP/{{ hostname }}.txt

Template: gen_full_conf1.j2

!
transceiver qsfp default-mode 4x10G  
!
hostname {{ hostname }}  
!
ntp server {{ ntp1 }}  
!
spanning-tree mode mstp  
!
aaa authorization exec default local  
!
no aaa root  
!
{% for user in users %}
{% if user.role is defined %}
username {{ user.username }} privilege {{ user.privilege }} role {{ user.role }} secret 5 {{ user.secret }}  
{% elif user.privilege is not defined %}
username {{ user.username }} secret 5 {{ user.secret }}  
{% else %}
username {{ user.username }} privilege {{ user.privilege }} secret 5 {{ user.secret }}  
{% endif %}
{% endfor %}
!
clock timezone America/Los_Angeles  
!
interface Ethernet1  
   spanning-tree portfast
!
{% for intf_num in range(2, 8) %}
interface Ethernet{{ intf_num }}  
!
{% endfor %}
interface Management1  
   shutdown
!
interface Vlan1  
   ip address {{ mgmt_ip_address }}/24
!
ip route 0.0.0.0/0 {{ default_gateway }}  
!
ip routing  
!
management api http-commands  
   no shutdown
!
!
end  

host_var example: /host_vars/pynet-sw5/main.yml
Some host specific variables such as hostname, mgmt IP and the eapi-secret for the target.

---
hostname: pynet-sw5  
mgmt_ip_address: 10.220.88.32  
eapi_secret: $1$Kt0.fwmk$5Q14WW76.w5xBIHwMHNX0/  

group_vars example: /group_vars/all.yml
Here we define everything that goes for all our devices, no matter type, function or vendor.

---
name_server1: 8.8.8.8  
name_server2: 8.8.4.4

default_domain: bogus1.com

ntp1: 130.126.24.24  
ntp2: 152.2.21.1  

host_var example: /group_vars/arista.yml
Here we define the variables that is only relevant for arista switches and their roles in our infrastructure.

---
platform: eos  
default_gateway: 10.220.88.1  
users:  
  - { username: admin, privilege: 15, role: network-admin, secret: $1$aM6w809x$tgkc6ZGhcScvELVKVHq3n0 }
  - { username: admin1, privilege: 15, secret: $1$7kD0oS/t$wXhtTFwnWwnlPFKWwXoJ70 }
  - { username: eapi, secret: "{{ eapi_secret }}" }
  - { username: pyclass, privilege: 15, secret: $1$C3VfUfcO$86t4iqCX60yW.NIR8d2Lh0 }

creds_core_ssh:  
  host: "{{ ansible_host }}"
  username: "{{ username }}"
  password: "{{ password }}"
  transport: cli
  timeout: 60

creds_core_eapi:  
  host: "{{ ansible_host }}"
  username: "{{ username }}"
  password: "{{ password }}"
  transport: eapi
  use_ssl: True
  validate_certs: no

creds_napalm:  
  hostname: "{{ ansible_host }}"
  username: "{{ username }}"
  password: "{{ password }}"
  dev_os: "eos"
  optional_args: {}
Playbook runthrough.

Only configuration lines or blocks using variables or macros will be discussed, the rest is rather static and boring.

hostname {{ hostname }} from our host_var we get the value of the hostname variable.

ntp server {{ ntp1 }}, this is retrieved from /group_vars/all.yml

Username, passwords, roles and secret templating.

This ties into to the arista.yml host_vars file and using in our running for-loop we are using a set of condiditional in order to catch all different scenarios for our user configuration bits.

First it handles all the scenarios where we have a user.role defined and uses the rest of the variables in our dictionary and generates the following: username admin privilege 15 role network-admin secret 5 $1$aM6w809x$tgkc6ZGhcScvELVKVHq3n0

If there is no role defined it then goes through the elif user.privilege is not defined and the following will be generated: username eapi secret 5 $1$Kt0.fwmk$5Q14WW76.w5xBIHwMHNX0/

Our catch-all else would then generate the following two lines:
username admin1 privilege 15 secret 5 $1$7kD0oS/t$wXhtTFwnWwnlPFKWwXoJ70
username pyclass privilege 15 secret 5 $1$C3VfUfcO$86t4iqCX60yW.NIR8d2Lh0

{% for user in users %}
{% if user.role is defined %}
username {{ user.username }} privilege {{ user.privilege }} role {{ user.role }} secret 5 {{ user.secret }}  
{% elif user.privilege is not defined %}
username {{ user.username }} secret 5 {{ user.secret }}  
{% else %}
username {{ user.username }} privilege {{ user.privilege }} secret 5 {{ user.secret }}  
{% endif %}
{% endfor %}
users:  
  - { username: admin, privilege: 15, role: network-admin, secret: $1$aM6w809x$tgkc6ZGhcScvELVKVHq3n0 }
  - { username: admin1, privilege: 15, secret: $1$7kD0oS/t$wXhtTFwnWwnlPFKWwXoJ70 }
  - { username: eapi, secret: "{{ eapi_secret }}" }
  - { username: pyclass, privilege: 15, secret: $1$C3VfUfcO$86t4iqCX60yW.NIR8d2Lh0 }

Interface configuration

First of all, we are handling interface Ethernet1 very carefully here as this is our management interface, we are manually setting setting portfast in our template and also we are using our mgmt IP variable to define the IP for VLan1, the resulting configuration ends up as:

interface Vlan1
   ip address 10.220.88.32/24

The for-loop pertaining the remaining interfaces simply uses a fixed range (not best practice material...) and generates the configuration lines according to our loop. An example of a resulting configuration line would be: interface Ethernet2

We are also shutting down the Management1 interface as per:

interface Management1
  shutdown
!
interface Ethernet1  
   spanning-tree portfast
!
{% for intf_num in range(2, 8) %}
interface Ethernet{{ intf_num }}  
!
{% endfor %}
interface Management1  
   shutdown
!
interface Vlan1  
   ip address {{ mgmt_ip_address }}/24
!
1.4 Templating and Deploying Configurations

We're first going to look at generating DNS configuratons(partials) and then pushing them to the cisco ios, cisco nxos and arista devices using the following ansible core modules: nxos_config, eos_config and ios_config

In 1.4.2 we will be generating and pushing full configurations to our arista devices using the napalm_install_config method as well create diffs for the configurations we have generated.

Playbook Directory Structure

templating_deploy/  
├── arista_gen_config.j2
├── CFGS
├── DIFFS
├── gen_dns.j2
├── group_vars
│   ├── all.yml
│   ├── arista.yml
│   ├── cisco.yml
│   └── nxos.yml
├── host_vars
│   ├── pynet-sw5
│   │   └── main.yml
│   ├── pynet-sw6
│   │   └── main.yml
│   ├── pynet-sw7
│   │   └── main.yml
│   └── pynet-sw8
│       └── main.yml
├── push_dns.yml
└── push_full_configs.yml
1.4.1 DNS Configuration with Ansible Core Modules

├── push_dns.yml

The Task '"Delete old config files"' really does just what it says, using the file module we specifcy the path and have it delete existing files under CFGS by setting the state to enfore an absence of the defined file.

The configuration generation works just as before, we specify a source template and a path and naming scheme for our generated configuration files.

After this we need to make use of three separate ansible core modules since we need to look towards Napalm to have the abstraction layer going. It simply uses the transport and credentials we have in our group vars and push the generated configuration by specifying the path.

---
- name: Generate configs for name-servers
  hosts: cisco:nxos:arista
  tasks:
    - name: "Delete old config files"
      file:
        path: "./CFGS/{{ inventory_hostname }}.txt"
        state: absent

    - name: "Generating config files"
      template:
        src: gen_dns.j2
        dest: "./CFGS/{{ inventory_hostname }}.txt"

- name: Push IOS configs
  hosts: cisco
  tasks:
    - ios_config:
        provider: "{{ creds_core_ssh }}"
        src: "./CFGS/{{ inventory_hostname }}.txt"

- name: Push NX-OS configs
  hosts: nxos
  tasks:
    - nxos_config:
        provider: "{{ creds_core_ssh }}"
        src: "./CFGS/{{ inventory_hostname }}.txt"

- name: Push EOS configs
  hosts: arista
  tasks:
    - eos_config:
        provider: "{{ creds_core_ssh }}"
        src: "./CFGS/{{ inventory_hostname }}.txt"

├── gen_dns.j2

Our templing for the DNS bits uses a a few conditionals and generate the right configuration line according to the platform variable.

{% if platform == "ios" %}
ip name-server {{ name_server1 }}  
ip name-server {{ name_server2 }}  
{% elif platform == "eos" %}
ip name-server vrf default {{ name_server1 }}  
ip name-server vrf default {{ name_server2 }}  
{% elif platform == "nxos" %}
ip name-server {{ name_server1 }} {{ name_server2 }}  
{% endif %}
1.4.2 Pushing Full Configurations with Napalm

└── push_full_configs.yml

---
- name: Generate full configs for Arista
  hosts: arista
  tasks:
    - name: "Delete old config files"
      file:
        path: "./CFGS/{{ inventory_hostname }}.txt"
        state: absent
      tags: generate_cfgs

    - name: Generate Arista Configs
      template: 
        src: arista_gen_config.j2
        dest: "CFGS/{{ inventory_hostname }}.txt"
      tags: generate_cfgs

    - name: NAPALM push configs to devices
      napalm_install_config: 
        provider: "{{ creds_napalm }}"
        config_file: "CFGS/{{ inventory_hostname }}.txt"
        commit_changes: False
        replace_config: True
        diff_file: "DIFFS/{{ inventory_hostname }}.txt"
      tags: push_cfgs

├── arista_gen_config.j2
There is a bit more going on here, but we're basically doing the same scenario as what we did in 1.3 Full Configuration Templating.

!
transceiver qsfp default-mode 4x10G  
!
hostname {{ hostname }}  
!
ntp server {{ ntp1 }}  
!
spanning-tree mode mstp  
!
aaa authorization exec default local  
!
no aaa root  
!
{% for user in users %}
{% if user.role is defined %}
username {{ user.username }} privilege {{ user.privilege }} role {{ user.role }} secret 5 {{ user.secret }}  
{% elif user.privilege is not defined %}
username {{ user.username }} secret 5 {{ user.secret }}  
{% else %}
username {{ user.username }} privilege {{ user.privilege }} secret 5 {{ user.secret }}  
{% endif %}
{% endfor %}
!
clock timezone America/Los_Angeles  
!
interface Ethernet1  
   spanning-tree portfast
!
{% for intf_num in range(2, 8) %}
interface Ethernet{{ intf_num }}  
!
{% endfor %}
interface Management1  
   shutdown
!
interface Vlan1  
   ip address {{ mgmt_ip_address }}/24
!
ip route 0.0.0.0/0 {{ default_gateway }}  
!
ip routing  
!
management api http-commands  
   no shutdown
!
!

Let's say we want to add another ntp-server, we've gone ahead and declared the following in our all.yml ntp2: 152.2.21.1 and getting this into our template would be as simple as just adding it below ntp server {{ ntp1 }}

!
ntp server {{ ntp1 }}  
ntp server {{ ntp2 }}  
!
1.5 Advanced Templating Capabilities

First we are going to examine different ways of including or referencing other templates from our existing one with includes after that we will examine at two examples where we examine basic and more advanced examples on macros, the Jinja2 equivalent of Python functions.

Include example #1

test_include.yml
This will be our play and as we can see we are using branch_office_1.j2 as our template.

---
- name: Configuration templating using include statement
  hosts: localhost
  gather_facts: false
  tasks:
    - name: Generate configuration files
      template: 
        src: branch_office_1.j2 
        dest: "CFGS/{{ item.hostname }}.txt"
      with_items:
        - {hostname: pynet-rtr1, default_gateway: 10.10.10.1}
        - {hostname: pynet-rtr2, default_gateway: 10.10.20.1}

branch_office_1.j2

service timestamps debug datetime msec localtime show-timezone  
service timestamps log datetime msec localtime show-timezone  
!
!
{% include 'hostname.j2' %}
!
!
ip default-gateway {{item.default_gateway}}  
!
line con 0  
line vty 0 4  
 login
line vty 5 15  
 login
!
!
end  

The line we are interested in would be {% include 'hostname.j2' %}, more documentation on this can be found here. But what we are doing is basically pulling another Jinja template into our current one. The contents of what we are including is as per below:

hosts.j2

hostname {{ item.hostname }}  
hostname {{ item.hostname }}  
hostname {{ item.hostname }}  
hostname {{ item.hostname }}  

Running the example

Verifying the configuration changes
cat CFGS/pynet-rtr1.txt

service timestamps debug datetime msec localtime show-timezone  
service timestamps log datetime msec localtime show-timezone  
!
!
hostname pynet-rtr1  
hostname pynet-rtr1  
hostname pynet-rtr1  
hostname pynet-rtr1  
!
!
ip default-gateway 10.10.10.1  
!
line con 0  
line vty 0 4  
 login
line vty 5 15  
 login
!
!
end

This was a really basic introduction to includes. Let us look at the second example with include statements.

Include example #2

test_include2.yml
In this example yaml file we're still passing variable using with_items but we are also passing jinja templates as variables. For the 881-router we are telling it to use the model_interfaces: 881_interfaces.j2 template and for the 891-router it should use the 891_interfaces.j2 template. The only difference is just that the 881 is getting it's IP set on the FastEthernet4 interface and the 891 on it's GigabitEthernet1 interface.

---
- name: Configuration templating using include statement
  hosts: local
  gather_facts: false
  tasks:
    - name: Generate configuration files
      template: 
        src: branch_office_2.j2 
        dest: "CFGS/{{ item.hostname }}.txt"
      with_items:
        - {hostname: pynet-rtr1, default_gateway: 10.10.10.1, model_interfaces: 881_interfaces.j2,
           ip_addr: 10.10.10.10, netmask: 255.255.255.0}
        - {hostname: pynet-rtr2, default_gateway: 10.10.20.1, model_interfaces: 891_interfaces.j2,
           ip_addr: 10.10.10.10, netmask: 255.255.255.0}

881_interfaces.j2

interface FastEthernet0  
 no ip address
!
interface FastEthernet1  
 no ip address
!
interface FastEthernet2  
 no ip address
!
interface FastEthernet3  
 no ip address
!
interface FastEthernet4  
 ip address {{ item.ip_addr }} {{ item.netmask }}
 duplex auto
 speed auto
!
interface Vlan1  
 no ip address

891_interfaces.j2

interface FastEthernet0  
 no ip address
!
interface FastEthernet1  
 no ip address
!
interface FastEthernet2  
 no ip address
!
interface FastEthernet3  
 no ip address
!
interface GigabitEthernet1  
 ip address {{ item.ip_addr }} {{ item.netmask }}
 duplex auto
 speed auto
!
interface Vlan1  
 no ip address

branch_office_2.j2

We did go over the hostname {{ item.hostname }} in our last example, but do notice the following line: {% include item.model_interfaces %}. The important thing to realise is that this template is dynamically generated and since we have passed the model_interfaces for the two "items" it uses the one we have specificed. While this is statically defined, we could have this done via lookups or other functions.

service timestamps debug datetime msec localtime show-timezone  
service timestamps log datetime msec localtime show-timezone  
!
hostname {{ item.hostname }}  
!
!
{% include item.model_interfaces %}
!
ip default-gateway {{ item.default_gateway }}  
!
line con 0  
line vty 0 4  
 login
line vty 5 15  
 login
!
!
end  

Running our example

Verifying the configuration changes
cat CFGS/pynet-rtr1.txt

service timestamps debug datetime msec localtime show-timezone  
service timestamps log datetime msec localtime show-timezone  
!
hostname pynet-rtr1  
!
!
interface FastEthernet0  
 no ip address
!
interface FastEthernet1  
 no ip address
!
interface FastEthernet2  
 no ip address
!
interface FastEthernet3  
 no ip address
!
interface FastEthernet4  
 ip address 10.10.10.10 255.255.255.0
 duplex auto
 speed auto
!
interface Vlan1  
 no ip address
!
ip default-gateway 10.10.10.1  
!
line con 0  
line vty 0 4  
 login
line vty 5 15  
 login
!
!
end  
Macros example #1

test_macro.yml
We're not really doing anything interesting the ansible playbook, the magic is happening in our template.

---
- name: Configuration templating using include statement
  hosts: localhost
  gather_facts: false
  tasks:

  - name: Generate configuration files
    template: src=access_switch_1.j2 dest=CFGS/{{ item.hostname }}.txt
    with_items:
      - {hostname: pynet-sw1, default_gateway: 10.10.10.1,
         ip_addr: 10.10.10.10, netmask: 255.255.255.0}
      - {hostname: pynet-sw2, default_gateway: 10.10.20.1,
         ip_addr: 10.10.10.10, netmask: 255.255.255.0}

access_switch_1.j2

{% macro intf_trunk(native_vlan=1, trunk_allowed_vlans=1) -%} 
 switchport mode trunk
 switchport trunk native vlan {{ native_vlan }}
 switchport trunk allowed vlan {{ trunk_allowed_vlans }}
{%- endmacro %}
{% macro intf_access(vlan=1) -%} 
 switchport mode access
 switchport access vlan {{ vlan }}
{%- endmacro %}
!
!
service timestamps debug datetime msec localtime show-timezone  
service timestamps log datetime msec localtime show-timezone  
!
hostname {{ item.hostname }}  
!
!
interface FastEthernet0  
 no ip address
 {{ intf_trunk(native_vlan=1, trunk_allowed_vlans="1,100") }}
!
interface FastEthernet1  
 no ip address
 {{ intf_trunk(native_vlan=1, trunk_allowed_vlans="1,100") }}
!
interface FastEthernet2  
 no ip address
 {{ intf_access(vlan=100) }}
!
interface FastEthernet3  
 no ip address
 {{ intf_access(vlan=100) }}
!
!
ip default-gateway {{ item.default_gateway }}  
!
line con 0  
line vty 0 4  
 login
line vty 5 15  
 login
!
!
end  

Let's break out from the full jinja2 file and go through what is happening. {% macro intf_trunk(native_vlan=1, trunk_allowed_vlans=1) -%}.

{% macro intf_trunk(native_vlan=1, trunk_allowed_vlans=1) -%} 
 switchport mode trunk
 switchport trunk native vlan {{ native_vlan }}
 switchport trunk allowed vlan {{ trunk_allowed_vlans }}
{%- endmacro %}
{% macro intf_access(vlan=1) -%} 
 switchport mode access
 switchport access vlan {{ vlan }}
{%- endmacro %}

{% macro intf_trunk up to this point we are defining our macro and assigning it the macro name of intf_trunk and just as in Python we can bring default variable values to use in our macro scope, which is what the following bit refers to: (native_vlan=1, trunk_allowed_vlans=1). The -%} is our ending syntax.

The first three lines below are just standard text with the native_vlan and trunk_allowed_vlans variables declared and the last line is the syntax we use to end the this macro.

 switchport mode trunk
 switchport trunk native vlan {{ native_vlan }}
 switchport trunk allowed vlan {{ trunk_allowed_vlans }}
{%- endmacro %}

We are also creating a macro called intf_access where we pass a default value the variable vlan that has the value of 1. Other than that it is pretty much the same as the above examples.

{% macro intf_access(vlan=1) -%} 
 switchport mode access
 switchport access vlan {{ vlan }}
{%- endmacro %}

Let us examine on how we call upon our newly created macros.

intf_trunk example

!
interface FastEthernet0  
 no ip address
 {{ intf_trunk(native_vlan=1, trunk_allowed_vlans="1,100") }}
!

Here we are calling on our macro and passing additional arguments, the allowed_vlans is now going to add vlan 100 as well as vlan 1 to our trunk on the FastEthernet0-1 interfaces as per below:

 switchport mode trunk
 switchport trunk native vlan 1
 switchport trunk allowed vlan 1,100

intf_access example

interface FastEthernet2  
 no ip address
 {{ intf_access(vlan=100) }}

For our intf_access macro we are specifying that the Fastethernet2-3 interfaces should be configured as per below according to our macro.

switchport mode access  
switchport access vlan 100  

Running our example

Verifying the configuration changes
cat CFGS/pynet-sw1.txt

!
!
service timestamps debug datetime msec localtime show-timezone  
service timestamps log datetime msec localtime show-timezone  
!
hostname pynet-sw1  
!
!
interface FastEthernet0  
 no ip address
 switchport mode trunk
 switchport trunk native vlan 1
 switchport trunk allowed vlan 1,100
!
interface FastEthernet1  
 no ip address
 switchport mode trunk
 switchport trunk native vlan 1
 switchport trunk allowed vlan 1,100
!
interface FastEthernet2  
 no ip address
 switchport mode access
 switchport access vlan 100
!
interface FastEthernet3  
 no ip address
 switchport mode access
 switchport access vlan 100
!
!
ip default-gateway 10.10.10.1  
!
line con 0  
line vty 0 4  
 login
line vty 5 15  
 login
!
!
end
Macros example #2

Notice that we are creating a dictionary of our interfaces in our playbook this time and in our template we will be looping through and dynamically building our configuration files depending on the outcome of the conditionals we have in our loop.

test_macro_2.yml

---
- name: Configuration templating using include statement
  hosts: localhost
  gather_facts: false
  tasks:

  - name: Generate configuration files
    template: src=access_switch_2.j2 dest=CFGS/{{ item.hostname }}.txt
    with_items:
      - {hostname: pynet-sw1, default_gateway: 10.10.10.1,
         ip_addr: 10.10.10.10, netmask: 255.255.255.0, 
         interfaces: [
            {name: FastEthernet0, switchport_mode: access},
            {name: FastEthernet1, switchport_mode: access},
            {name: FastEthernet2, switchport_mode: access},
            {name: FastEthernet3, switchport_mode: trunk},
            {name: FastEthernet4, switchport_mode: trunk}
        ]}
      - {hostname: pynet-sw2, default_gateway: 10.10.20.1,
         ip_addr: 10.10.10.10, netmask: 255.255.255.0,
         interfaces: [
            {name: FastEthernet0, switchport_mode: access},
            {name: FastEthernet1, switchport_mode: access},
            {name: FastEthernet2, switchport_mode: access},
            {name: FastEthernet3, switchport_mode: trunk},
            {name: FastEthernet4, switchport_mode: trunk}
        ]}

access_switch_2.j2

{% macro intf_trunk(native_vlan=1, trunk_allowed_vlans=1) -%} 
 switchport mode trunk
 switchport trunk native vlan {{ native_vlan }}
 switchport trunk allowed vlan {{ trunk_allowed_vlans }}
{%- endmacro %}
{% macro intf_access(vlan=1) -%} 
 switchport mode access
 switchport access vlan {{ vlan }}
{%- endmacro %}
!
!
service timestamps debug datetime msec localtime show-timezone  
service timestamps log datetime msec localtime show-timezone  
!
hostname {{ item.hostname }}  
!
!
{% for intf in item.interfaces %}
interface {{ intf.name }}  
 no ip address
{% if intf.switchport_mode == 'trunk' %}
 {{ intf_trunk(native_vlan=1, trunk_allowed_vlans="1,100") }}
{% elif intf.switchport_mode == 'access' %}
 {{ intf_access(vlan=100) }}
{% endif %}
!
{% endfor %}
!
!
ip default-gateway {{ item.default_gateway }}  
!
line con 0  
line vty 0 4  
 login
line vty 5 15  
 login
!
!
end  

for intf in interfaces

{% for intf in item.interfaces %}
interface {{ intf.name }}  
 no ip address
{% if intf.switchport_mode == 'trunk' %}
 {{ intf_trunk(native_vlan=1, trunk_allowed_vlans="1,100") }}
{% elif intf.switchport_mode == 'access' %}
 {{ intf_access(vlan=100) }}
{% endif %}
!
{% endfor %}

This is the important bit. for every iteration (intf) in our dictionary we first define the name with the intf.name variable, we then clear the current ip address configuration and then we start with our conditional statements.

In this case it simply uses the informationfrom the playbook declared dictionary items, every interface item has a name:value and a switchport_mode:value.

If the intf.switchport_mode has the value of trunk it will call on our intf_trunk macro and generate the following configuration lines:

interface NAME  
 no ip address
 switchport mode trunk
 switchport trunk native vlan 1
 switchport trunk allowed vlan 1,100

But if the intf.switchport_mode has the value of access it will call on our intf_access macro instead and generate the following configuration lines:

interface NAME  
 no ip address
 switchport mode access
 switchport access vlan 100

Running our example

Verifying the configuration changes
cat CFGS/pynet-sw1.txt

!
!
service timestamps debug datetime msec localtime show-timezone  
service timestamps log datetime msec localtime show-timezone  
!
hostname pynet-sw1  
!
!
interface FastEthernet0  
 no ip address
 switchport mode access
 switchport access vlan 100
!
interface FastEthernet1  
 no ip address
 switchport mode access
 switchport access vlan 100
!
interface FastEthernet2  
 no ip address
 switchport mode access
 switchport access vlan 100
!
interface FastEthernet3  
 no ip address
 switchport mode trunk
 switchport trunk native vlan 1
 switchport trunk allowed vlan 1,100
!
interface FastEthernet4  
 no ip address
 switchport mode trunk
 switchport trunk native vlan 1
 switchport trunk allowed vlan 1,100
!
!
!
ip default-gateway 10.10.10.1  
!
line con 0  
line vty 0 4  
 login
line vty 5 15  
 login
!
!
end  

This can get both complex and administratively unruly quite quickly. The case for network automation is not to shift focus from manually configuring network devices to manually and constantly editing yaml and jinja2 files. Always remember that planning and structuring the playbooks and variables should always come before adding too much complexity in the jinja2 templates.