Network Configuration Templating with Ansible – Part 2

Blog Detail

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



ntc img
ntc img

Contact Us to Learn More

Share details about yourself & someone from our team will reach out to you ASAP!

Network Configuration Templating with Ansible – Part 1

Blog Detail

When discussing network automation with our customers, one of the main concerns that come up is the ability to audit their device configurations. This becomes especially important during the last quarter of the year, as many corporations are going through their yearly audit to obtain their required approvals for PCI or other compliance standards. Our solution for that is to use the Golden Configuration application for Nautobot, but it’s also entirely possible to use simple Ansible playbooks to perform the audit. This blog series will go over the essential pieces to understanding network configuration templating using Ansible, but the same process can easily be translated for use with Nautobot.

To start templating your configuration you must identify the feature that you wish to audit. Whether it be your DNS or NTP settings, it’s usually easier to start with small parts of the configuration before moving on to the more complicated parts, such as routing or interfaces. With a feature selected, you can start reviewing the device configurations to create your templates. For this article, we’ll use NTP configuration from an IOS router as the chosen feature:

ntp server 1.1.1.1 prefer
ntp server 1.0.0.1
ntp server 8.8.8.8
clock timezone GMT 0
clock summer-time CET recurring

After you’ve identified the portions of the configuration that you wish to template for the feature, the next step is to review the configuration snippet(s) and identify the variables relevant to the configuration feature. Specifically, you want to extract only the non-platform-specific variables, as the platform-specific syntax should be part of your template with the variables abstracted away for use across platforms. Using the example above, we can extract the following bits of information:

  • three NTP server hosts
    • 1.1.1.1
    • 1.0.0.1
    • 8.8.8.8
  • preferred NTP server
    • 1.1.1.1 is preferred
  • time zone and offset
    • GMT
    • 0
  • daylight saving timezone
    • CET

With these variables identified, the next step is to define a schema for these variables to be stored in. For Ansible this is typically in a YAML file as host or group vars. As YAML is limited in the types of data it can document, lists and key/value pairs typically, it’s best to design the structure around that limitation. With the example above, we’d want to have a list of the NTP servers as one item with a key noting which is preferred, the timezone with offset, and the daylight saving timezone. One potential schema would be like the below:

---
# file: group_vars/all.yml
ntp:
  servers:
    - ip: "1.1.1.1"
      prefer: true
    - ip: "1.0.0.1"
      prefer: false
    - ip: "8.8.8.8"
      prefer: false
  timezone:
    zone: "GMT"
    offset: 0
    dst: "CET"

Defining this structure is important as it will need to be flexible enough to cover data for all platforms while also being simple enough that your templates don’t become complicated. You’ll want to ensure that all other variables that are for this feature are of the same structure to ensure compatibility with the Jinja2 templates you’ll be creating in future parts of this series. It’s possible to utilize something like the Schema Enforcer framework to enable enforcement of your schemas against newly added data. This allows you a level of trust that the data provided to the templates are of the right format.

The next step, once the variables have been defined and you’ve determined a structure for them, is to understand where they belong within your network configuration hierarchy. This means that you need to understand in which circumstances these values are considered valid. Are they globally applicable to all devices or only to a particular region? This will define whether you place the variables in a device-specific variable or a group-specific one, and if in a group which group. This is especially important, as where you place the variables will define which devices inherit them and will use them when it comes time to generate configurations. For this article, we’ll assume that these are global variables and would be placed in the all group vars file. With this in mind, you’ll want to start building your inventory with those variable files. Following the Ansible Best Practices, it’s recommended to have a directory layout like so:

inventory.yml
group_vars/
    all.yml
    routers.yml
    switches.yml
host_vars/
    jcy-rtr-01.infra.ntc.com.yml
    jcy-rtr-02.infra.ntc.com.yml

This should allow for clear and quick understanding of where the variables are in relation to your network fleet. This will become increasingly important as you build out more templates and adding variables. With your inventory structure built out, you can validate that the variables are assigned to your devices as expected with the ansible-invenotry -i inventory.yml --list which will return the variables assigned to each device like so:

{
    "_meta": {
        "hostvars": {
            "jcy-rtr-01.infra.ntc.com": {
                "ntp": {
                    "servers": [
                        {
                            "ip": "1.1.1.1",
                            "prefer": true
                        },
                        {
                            "ip": "1.0.0.1",
                            "prefer": false
                        },
                        {
                            "ip": "8.8.8.8",
                            "prefer": false
                        }
                    ],
                    "timezone": {
                        "dst": "CET",
                        "offset": 0,
                        "zone": "GMT"
                    }
                }
            },
            "jcy-rtr-02.infra.ntc.com": {
                "ntp": {
                    "servers": [
                        {
                            "ip": "1.1.1.1",
                            "prefer": true
                        },
                        {
                            "ip": "1.0.0.1",
                            "prefer": false
                        },
                        {
                            "ip": "8.8.8.8",
                            "prefer": false
                        }
                    ],
                    "timezone": {
                        "dst": "CET",
                        "offset": 0,
                        "zone": "GMT"
                    }
                }
            }
        }
    },
    "all": {
        "children": [
            "routers",
            "ungrouped"
        ]
    },
    "routers": {
        "hosts": [
            "jcy-rtr-01.infra.ntc.com",
            "jcy-rtr-02.infra.ntc.com"
        ]
    }
}

Conclusion

This allows you to validate and ensure that the variables you’ve created are being assigned where you expect. In the next part of this series we’ll dive into how to craft a configuration template using the Jinja2 templating engine.

-Justin



ntc img
ntc img

Contact Us to Learn More

Share details about yourself & someone from our team will reach out to you ASAP!

Automation Principles – Inheritance

Blog Detail

This is part of a series of posts intended to provide an understanding of Network Automation Principles.

The term inheritance has its origin rooted in object-oriented programming, dating back to the ’60s. As the name suggests, it describes the relationship between objects and how their attributes are handed down, using similar hierarchical relationship naming as used in a family tree (parent, child, grandparent, etc.).

Inheritance is one of the tools in the arsenal that allow programmers to simplify their code and generally keep it more DRY.

Inheritance in Computer Science

A common example used to portray this would be classifying a vehicle, specifically with vehicle being the parent object and vehicle types such as cars, vans, motorcycles being the child object.

class Vehicle():
    def __init__(self, manufacturer, model, color):
        self.manufacturer = manufacturer
        self.model = model
        self.color = color

class Car(Vehicle):
    @property
    def tires(self):
        return 4
    @property
    def doors(self):
        return 4

class MotorCycle(Vehicle):
    @property
    def tires(self):
        return 2
    @property
    def doors(self):
        return 0

Working with this simple example, we can start to see how Inheritance works in Python:

>>> honda_civic = Car('Honda', "Civic", "blue")
>>> honda_civic.color
'blue'
>>> honda_civic.tires
4
>>> honda_civic.doors
4
>>> 
>>> 
>>> 
>>> honda_cbr = MotorCycle('Honda', "CBR", "red")
>>> honda_cbr.color
'red'
>>> honda_cbr.tires
2
>>> honda_cbr.doors
0
>>>

Example in Networks

Let’s take a look at a similar construct using network-focused terms.

class NetworkDevice():
    def __init__(self, manufacturer, model, memory):
        self.manufacturer = manufacturer
        self.model = model
        self.memory = memory

class Router(NetworkDevice):
    @property
    def ports(self):
        return 8
    @property
    def tunneling_protocols(self):
        return ["dmvpn", "gre"]


class Switch(NetworkDevice):
    @property
    def ports(self):
        return 48
    @property
    def rmon(self):
        return True

One thing you may notice here is that while Router has the property of tunneling_protocols, the Switch does not. The same is true in reverse for rmon support. So with the use of inheritance, you can use the same interface as the Router for the properties that are on both or inherited from the parent, which supports the ability to be DRY.

>>> cisco_asr = Router("Cisco", "ASR1000", "1Gb")
>>> cisco_asr.tunneling_protocols
['dmvpn', 'gre']
>>> cisco_asr.ports
8
>>>
>>>
>>> cisco_nexus = Switch("Cisco", "NX9K", "1Gb")
>>> cisco_nexus.ports
48
>>> cisco_nexus.rmon
True
>>>

You can add multiple levels of inheritance in Python. Oftentimes a strategy would be to use a Mixin, which is a conceptual idea of a class that has the sole use of being mixed in with other classes. Take the following example.

class SuperNetworkDeviceMixin():
    @property
    def cpu(self):
        return "8 cores"
    @property
    def routing_protocols(self):
        return ["bgp", "ospf", "isis"]

class Router(NetworkDevice, SuperNetworkDeviceMixin):
    @property
    def ports(self):
        return 8
    @property
    def tunneling_protocols(self):
        return ["dmvpn", "gre"]

You can see that SuperNetworkDeviceMixin can be used to add the properties cpu and routing_protocols to the Router class.

This ability to have multiple levels of inheritance is based on the Method Resolution Order (MRO), if you are interested in learning more.

Inheritance in Ansible

Ansible provides the ability to have set the hash_behaviour, however the default is replace. With this, it works the same way as inheritance, in that the more specific attribute overrides the parent.

Given the files:

group_vars/all.yml


ntp:
  - 10.1.1.1
  - 10.1.1.2

dns:
  - 10.10.10.10
  - 10.10.10.11

snmp:
  - 10.20.20.20
  - 10.20.20.21

group_vars/eu.yml


ntp:
  - 10.200.200.1
  - 10.200.200.2

host_vara/lon-rt01.yml


snmp:
  - 10.150.150.1
  - 10.150.150.2

Would result in the variables being “flattened” via inheritance to:


ntp:
  - 10.200.200.1
  - 10.200.200.2

dns:
  - 10.10.10.10
  - 10.10.10.11

snmp:
  - 10.150.150.1
  - 10.150.150.2

As the ntp would be inherited from the eu.yml file and dns inherited from the all.yml file. The layout of the inheritance structure is found in Ansible Variable Precedence documentation.

Real-Life Use Case with NAPALM

NAPALM heavily relies on inheritance to provide a consistent interface to many vendors, while still having drastically different code for each vendor. In this seriously truncated code taken directly from NAPALM’s source code, you can see how the base class (NetworkDriver) works and how a child class (IOSDriver) is implemented.

class NetworkDriver(object):

    def get_ntp_peers(self) -> Dict[str, models.NTPPeerDict]:
        """
        Returns the NTP peers configuration as dictionary.
        The keys of the dictionary represent the IP Addresses of the peers.
        Inner dictionaries do not have yet any available keys.
        Example::
            {
                '192.168.0.1': {},
                '17.72.148.53': {},
                '37.187.56.220': {},
                '162.158.20.18': {}
            }
        """
        raise NotImplementedError

class IOSDriver(NetworkDriver):

    def get_ntp_peers(self):
        """Implementation of get_ntp_peers for IOS."""
        ntp_stats = self.get_ntp_stats()
        return {
            napalm.base.helpers.ip(ntp_peer.get("remote")): {}
            for ntp_peer in ntp_stats
            if ntp_peer.get("remote")
        }

This way, each class that inherits from the base class clearly indicates which methods it implements (by explicitly overwriting those methods) and which ones it doesn’t (and thus will raise a NotImplementedError if called).

Inheritance in Django

One great example of Inheritance is Django. Its usage of object-oriented programming can be amazing when you understand it and frustrating when you don’t. Knowing how the MRO works and is implemented in Django is a must to start to understand how Django works. In that pursuit, I have found the “classy” pages for Django and DRF to be helpful.


Conclusion

Inheritance is used extensively throughout object oriented-programming languages such as Python. Additionally, there are concepts are used elsewhere, such as in Ansible’s variable structure.

Understanding the concept can help you in your day-to-day automation as well as provide the ability to build more scalable solutions.

-Ken



ntc img
ntc img

Contact Us to Learn More

Share details about yourself & someone from our team will reach out to you ASAP!