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!

Automation Principles – Data Normalization

Blog Detail

This is part of a series of posts to help establish sound Network Automation Principles.

Providing a common method to interact between various different systems is a fairly pervasive idea throughout technology. Within your first days of learning about traditional networking, you will inevitably hear about the OSI model. The concept being that each layer provides an interface from one layer and to another layer. The point is, there must be an agreement between those interfaces.

The same is true with data, which poses a problem within the Network space, as most interfaces to the network are via vendor- specific CLI, API, etc. This is what makes a uniform YANG model via an open modeling standard such as Open Config or IETF models so attractive.

The problem for adoption for such a standard is multifaceted. While cynics believe it is a vendor ploy to keep vendor lock-in, I think it is a bit more nuanced than that. Without spending too much time on the subject, here are some points to consider.

  • Vendor-neutral models are by nature complex, as they should contain the superset of all features
  • Complexity makes it more difficult to use the product
  • Despite the complexity, vendor-neutral models always seem to lack a core feature to any given vendor
  • Vendors have to extend models to support “their differentiators” or features, which is complex and subject to future issues
  • Data does not always map easily from the vendor’s model to any other model
  • All of this makes it complex to actually build out, if these features are not built from within the OS to start with

That’s a huge topic, with a 30,000-foot view of some pros/cons, no reason to dive deeper now.

Data Normalization in Computer Science

The construct on agreed upon interfaces has many names in Computer Science, depending on the context. While not all are related to data specifically, the concept remains the same.

  • Interface – as the generic term to define how two systems connect, not to be confused with a “network interface”.
  • API (or Application Programming Interface) – which is not always a REST API, is an agreed upon interface.
  • Contract – as a term to to reinforce the idea that there is an agreed upon standard between two systems.
  • Signature – as a type enforced definition of a function.

As mentioned, some of these terms are specific to a context, such as signature being more associated with a function, but these are all terms you will here often that describe the basic concepts.

Data Normalization in NAPALM

NAPALM provides a series of “getters”; these are basically what a Network Engineer would call “show commands” in structured data and normalized. Let’s observe the following example, taken from the get_arp_table doc string.

Returns a list of dictionaries having the following set of keys:
    * interface (string)
    * mac (string)
    * ip (string)
    * age (float)

Example::

    [
        {
            'interface' : 'MgmtEth0/RSP0/CPU0/0',
            'mac'       : '5C:5E:AB:DA:3C:F0',
            'ip'        : '172.17.17.1',
            'age'       : 1454496274.84
        },
        {
            'interface' : 'MgmtEth0/RSP0/CPU0/0',
            'mac'       : '5C:5E:AB:DA:3C:FF',
            'ip'        : '172.17.17.2',
            'age'       : 1435641582.49
        }
    ]

What you will observe here is there is no mention of vendor, and there is seemingly nothing unique about this data to tie it to any single vendor. This allows the developer to make programmatic decisions in a single way, regardless of vendor. The way in which data is normalized is up to the author of the specific NAPALM driver.

import sys
from napalm import get_network_driver
from my_custom_inventory import get_device_details

network_os, ip, username, password = get_device_details(sys.argv[1])

driver = get_network_driver(network_os)
with driver(ip, username, password) as device:
    arp_table = device.get_arp_table()

for arp_entry in arp_table:
    if arp_entry['interface'].startswith("TenGigabitEthernet"):
        print(f"Found 10Gb port {arp_entry['interface']}")

From the above snippet, you can see that regardless of what the fictional function get_device_details returns for a valid network OS, the process will remain the same. The hard work of performing the data normalization still has to happen within the respective NAPALM driver. That may mean connecting to the device, running CLI commands, then parsing; or that could mean making an API call and transposing the data structure from the vendors to what NAPALM expects.

Configuration Data Normalization

Considerations for building out your own normalized data model:

  • Do not follow the vendor’s syntax, this simply pushes the problem along
  • Having thousands of configuration “nerd nobs” requires expert-level understanding of a data model that will not likely be as well documented or understood as the vendor’s CLI. The point is to remove complexity, not shift complexity
  • Express the business intention, not the vendor configuration
  • Abstract the uniqueness of the OS implementation away from from intent
  • Express the greatest amount of configuration state in the least amount of actual data

Generally speaking, when I am building a data model, I try to build it to be normalized. While not always achievable on day 1 (due to lacking complete understanding of requirements or lacking imagination) the thought process is always there. Even if dealing with a single vendor, the first question I will ask is “would this work for another vendor?”

Reviewing the following configurations from multiple vendors:

Configuration Data Normalization

I will pick out what is unique from the configuration, and thus a variable.

Configuration Data Normalization

Based on observation of the above configurations, the following normalized data structure was created.

bgp:
  asn: 6500
  networks:
    - "1.1.1.0/24"
    - "1.1.2.0/24"
    - "1.1.3.0/24"
  neighbors:
    - description: "NYC-RT02"
      ip: "10.10.10.2"
    - description: "NYC-RT03"
      ip: "10.10.10.3"
    - description: "NYC-RT04"
      ip: "10.10.10.4"

With this data-normalized model in mind, you can quickly see how the below template can be applied.

router bgp {{ bgp['asn'] }}
  router-id {{ bgp['id'] }}
  address-family ipv4 unicast
{% for net in bgp['networks'] %}
    network {{ net }}
{% endfor %}
{% for neighbor in bgp['neighbors'] %}
  neighbor {{ neighbor['ip'] }} remote-as {{ neighbor['asn'] }}
    description {{ neighbor['description'] }}
    address-family ipv4 unicast
{% endfor %}

In this example, the Jinja template provides the glue between a normalized data model and the vendor-specific configuration. Jinja is just used as an example; this could just as easily be converted to an operation with REST API, NETCONF, or any other vendor’s syntax.

The Case for Localized Simple Normalized Data Models

Within Network to Code, we have found that simple normalized data models tend to get more traction than the more complex ones. While it is clear that each enterprise building its own normalized data model is not exactly efficient either—since each organization is having to reinvent the wheel—the adoption tends to offset that inefficiency.

Perhaps there is room within the community for some improvement here, such as creating a venue to easily publish data models and have others consume those data models. This can serve as inspiration, a starting point, and a means of comparison of various different normalized data models without the rigor that is required for solidified data models that would come from OC/IETF.

Data Normalization Enforcement

Enforcing normalized data models is filled with tools. This will be covered in more detail within the Data Model blog, but here are a few:

  • JSON Schema
  • Any relational database
  • Kwalify

You may even find a utility called Schema Enforcer valuable if you’re looking at using JSON Schema for data model enforcement within a CI pipeline. Check out this intro blog if you’re interested.


Conclusion

There are many ways to normalize data, and many times when the vendor syntax or output is nearly the same. You may be able to reuse the exact same code from one vendor to another. By creating normalized data models, you can better prepare for future use cases, remove some amount of vendor lock-in, and provide a consistent developer experience across vendors.

Creating normalized data models takes some practice to get right, but it is a skill that can be honed over time and truly provide a richer experience.

-Ken



ntc img
ntc img

Contact Us to Learn More

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