A Journey in Golden Config

Blog Detail


On the surface, all network engineers have a good understanding about what configuration compliance, or golden config, is. However, I’ve found the devil is in the details, and there are many different ideas as to what it should be. In this article, we will explore what some of those views are and some of the pros/cons for them.

Bulk Matching

This works best for “global configurations” such as ntp, dns, snmp, etc. The idea is that you have a standard of what the configurations should be, and you should ensure the actual configurations are the same.

This works well if the configurations are truly the same with a pattern, as described in this pseudo code:

expected_config = "ntp server 10.1.1.1\nntp server 10.1.1.2\nntp server 10.1.1.3 prefer"
actual_config = run_ios_command("show run | in ^ntp server")

if expected_config == actual_config:
    return True
else:
    return False

This tends to not scale well when you have exceptions, a lot of regionality (e.g., differences for region-based standards such as eu, am, ep, etc.), exceptions to the rule (e.g., if a site has a local ntp server, use it), and does not really consider the configurations that will always be different like IPs on interfaces, BGP ASNs, VLANs, etc.

Regex Pattern Matching

Sometimes, you are not as concerned about the actual data, such as whether or not the NTP servers are specific IPs, but instead are concerned that there are NTP servers configured. In such situations you can set up regex to match against your configurations.

Let’s take a look at this as described in this microfocus documentation.

Block Start: interface (.*)
Block End: !

Condition A: Config Block
must not contain
ip address (10\..*)\s(.*)

Condition B: Config Text
must contain only:
Must contain these lines:
ntp server 169\.243\.103\.34
ntp server 170\.242\.62\.16
ntp server 170\.242\.62\.17
ntp server 169\.243\.226\.94
But must not have any additional lines containing:
ntp server(.*)

Logic: A AND B

As you can see, you can use regex to perform “greedy” matches, such as ip address (10\..*)\s(.*), as well as specific matches, such as ntp server 169\.243\.103\.34.

There are also some scaling concerns with this approach, such as which devices does this template apply to, which templates are applied to a device, complex regex matching that quickly gets out of control, and not providing a path to “fix” the configurations.

Profiling Configurations

As you will note, the previous options made it difficult to scale beyond the global configurations. Developing compliance on configurations like interface can be rather difficult, if not impossible, in them.

With profiling the configurations, we can build strategies to pull out the relevant data and ensure a configuration can be rebuilt to the actual configurations. Well, that was a confusing mouthful, so let’s break this down a bit.

  • Grab a piece of configuration, such as all configuration under “interface GigabitEthernet0/1”.
    • We will call this actual_configuration.
  • Grab the detail from that configuration that you would use to profile it, such as the description and VLAN.
  • Use that data with predefined templates and process through a templating engine.
    • We call this expected_configuration.
  • Compare actual_configuration and expected_configuration and see whether they are the same.
    • If there are configs in actual_configuration and not in expected_configuration, there are unexpected configurations.
    • If there are configs in expected_configuration and not in actual_configuration, there are missing configurations.

Well, this is still a bit much, how about a diagram?

All make sense? If not, one more effort with actual code:

Basic Functions

>>>
>>> import jinja2
>>> import difflib
>>>
>>> def parse_cfg(actual_configuration):
...     parsed_data = []
...     interface = ""
...     description = ""
...     vlan = 0
...     for line in actual_configuration.splitlines():
...         if line.startswith("interface "):
...             if interface:
...                 parsed_data.append({"interface": interface, "description": description, "vlan": vlan})
...             interface = line.split()[1]
...             description = ""
...             vlan = 0
...         if line.startswith(" description "):
...             description = line[13:]
...         if line.startswith(" switchport access vlan "):
...             vlan = line[24:]
...     parsed_data.append({"interface": interface, "description": description, "vlan": vlan})
...     return parsed_data
...
>>> def regen_cfg(vars):
...     template_str = """"""
...     environment = jinja2.Environment()
...     template = environment.from_string(template_str)
...     return template.render(**vars)
...
>>> def compare_cfg(actual_configuration, expected_configuration):
...     if actual_configuration == expected_configuration:
...         return True
...     else:
...         for text in difflib.unified_diff(actual_configuration.split("\n"), expected_configuration.split("\n")):
...             print(text)
...         return False
...
>>> 

Example of Compliant Configuration

>>> compliance_actual_configuration = """
... interface GigabitEthernet0/1
...  description USER PORT
...  switchport mode access
...  switchport access vlan 205
...  snmp trap mac-notification change added
...  snmp trap mac-notification change removed
...  auto qos trust dscp
...  no mdix auto
...  spanning-tree portfast
...  spanning-tree guard root"""
>>>
>>> compliant_vars = {}
>>> compliant_vars['interface_vars'] = parse_cfg(compliance_actual_configuration)
>>> compliant_vars['interface_vars']
[{'interface': 'GigabitEthernet0/1', 'description': 'USER PORT', 'vlan': '205'}]
>>> compliant_expected_configuration = regen_cfg(compliant_vars)
>>>
>>> compare_cfg(compliance_actual_configuration, compliant_expected_configuration)
True
>>> 

Example of Non-Compliant Configurations

>>> non_compliant_actual_configuration = """
... interface GigabitEthernet0/1
...  description USER PORT
...  switchport access vlan 205
...  spanning-tree portfast
...  spanning-tree guard root
... """
>>>
>>> non_compliant_vars = {}
>>> non_compliant_vars['interface_vars'] = parse_cfg(non_compliant_actual_configuration)
>>> non_compliant_expected_configuration = regen_cfg(non_compliant_vars)
>>>
>>> compare_cfg(non_compliant_actual_configuration, non_compliant_expected_configuration)
---

+++

@@ -1,7 +1,11 @@


 interface GigabitEthernet0/1
  description USER PORT
+ switchport mode access
  switchport access vlan 205
+ snmp trap mac-notification change added
+ snmp trap mac-notification change removed
+ auto qos trust dscp
+ no mdix auto
  spanning-tree portfast
  spanning-tree guard root
-
False
>>>

With this approach you solve many of the challenges of comparing different types of configurations. That being said, numerous challenges remain.

  • Each configuration stanza requires some custom code
  • Exception management is difficult
    • For example, you want to add broadcast suppression on twenty interfaces within your entire org, how do you handle that?
  • In some cases you do not care about the current configuration, you simply want the configuration to match the expected; so you must support this solution and another solution as well.

Intended State vs Actual State

Having built many such solutions, the idea of building a comparison of the actual state vs intended seemed the most logical. In such a design, the lion’s share of the work is how to generate configurations, in a “traditional” Infrastructure as Code (IaC) approach. With IaC, you generate your configurations (within networking) generally by combining the data with Jinja templates.

Let’s break down this process a bit.

  • Obtain the actual configuration from the backup
    • Parse out the relevant configuration, often by breaking up into features (think stanza levels of configurations)
    • We will call this actual_configuration.
  • Generate the intended configuration
    • We will call this intended_configuration.
  • Compare the two configuration parts

To help bring this to life, here is a diagram of how this works:

In pursuing this approach, you get the direct benefits for configuration compliance of:

  • Having a single solution for any CLI-based configurations, regardless of vendor
  • Limiting the amount of code for any given configuration (to nearly zero)
  • Providing a platform for exception management
  • Providing a path to fix the configurations

Note: Though outside the scope of this blog, the ability to remedy configurations is generally predicated on having both an actual and an intended state.

Additionally, there are the collateral benefits of:

  • Providing a reason to develop an IaC solution
  • Providing a reason to build out configurations
  • Providing a reason to populate a Source of Truth

To finally drive home how this works, let’s review some code.

Basic Setup

>>> import jinja2
>>> from netutils.config import compliance
>>>
>>> def regen_cfg(vars):
...     template_str = """"""
...     environment = jinja2.Environment()
...     template = environment.from_string(template_str)
...     return template.render(**vars)
...
>>> # This is our pseudo SoT
>>> vars = {}
>>> vars['interface_vars'] = [{'interface': 'GigabitEthernet0/1', 'description': 'USER PORT', 'vlan': '205'}]
>>>
>>> network_os = "cisco_ios"
>>> features = [
...     {"name": "interface", "ordered": True, "section": ["interface "]},
... ]
>>>

Example of Compliant Configuration

<span role="button" tabindex="0" data-code=">>> compliant_backup_cfg = """ … interface GigabitEthernet0/1 … description USER PORT … switchport mode access … switchport access vlan 205 … snmp trap mac-notification change added … snmp trap mac-notification change removed … auto qos trust dscp … no mdix auto … spanning-tree portfast … spanning-tree guard root""" >>> >>> compliant_intended_cfg = regen_cfg(vars) >>> >>> compliance.compliance(features, compliant_backup_cfg, compliant_intended_cfg, network_os, "string") {'interface': {'compliant': True, 'missing': '', 'extra': '', 'cannot_parse': True, 'unordered_compliant': True, 'ordered_compliant': True, 'actual': 'interface GigabitEthernet0/1\n description USER PORT\n switchport mode access\n switchport access vlan 205\n snmp trap mac-notification change added\n snmp trap mac-notification change removed\n auto qos trust dscp\n no mdix auto\n spanning-tree portfast\n spanning-tree guard root', 'intended': 'interface GigabitEthernet0/1\n description USER PORT\n switchport mode access\n switchport access vlan 205\n snmp trap mac-notification change added\n snmp trap mac-notification change removed\n auto qos trust dscp\n no mdix auto\n spanning-tree portfast\n spanning-tree guard root'}} >>> # simplified yaml view of same data # interface: # compliant: true # missing: '' # extra: '' # cannot_parse: true # unordered_compliant: true # ordered_compliant: true # actual: <omitted> # intended:
>>> compliant_backup_cfg = """
... interface GigabitEthernet0/1
...  description USER PORT
...  switchport mode access
...  switchport access vlan 205
...  snmp trap mac-notification change added
...  snmp trap mac-notification change removed
...  auto qos trust dscp
...  no mdix auto
...  spanning-tree portfast
...  spanning-tree guard root"""
>>>
>>> compliant_intended_cfg = regen_cfg(vars)
>>>
>>> compliance.compliance(features, compliant_backup_cfg, compliant_intended_cfg, network_os, "string")
{'interface': {'compliant': True, 'missing': '', 'extra': '', 'cannot_parse': True, 'unordered_compliant': True, 'ordered_compliant': True, 'actual': 'interface GigabitEthernet0/1\n description USER PORT\n switchport mode access\n switchport access vlan 205\n snmp trap mac-notification change added\n snmp trap mac-notification change removed\n auto qos trust dscp\n no mdix auto\n spanning-tree portfast\n spanning-tree guard root', 'intended': 'interface GigabitEthernet0/1\n description USER PORT\n switchport mode access\n switchport access vlan 205\n snmp trap mac-notification change added\n snmp trap mac-notification change removed\n auto qos trust dscp\n no mdix auto\n spanning-tree portfast\n spanning-tree guard root'}}
>>>
# simplified yaml view of same data
# interface:
#   compliant: true
#   missing: ''
#   extra: ''
#   cannot_parse: true
#   unordered_compliant: true
#   ordered_compliant: true
#   actual: <omitted>
#   intended: <omitted>
<span role="button" tabindex="0" data-code=">>> non_compliant_backup_cfg = """ … interface GigabitEthernet0/1 … description USER PORT … switchport access vlan 205 … spanning-tree portfast … spanning-tree guard root … """ >>> >>> non_compliant_intended_cfg = regen_cfg(vars) >>> >>> compliance.compliance(features, non_compliant_backup_cfg, non_compliant_intended_cfg, network_os, "string") {'interface': {'compliant': False, 'missing': 'interface GigabitEthernet0/1\n switchport mode access\n snmp trap mac-notification change added\n snmp trap mac-notification change removed\n auto qos trust dscp\n no mdix auto', 'extra': '', 'cannot_parse': True, 'unordered_compliant': False, 'ordered_compliant': False, 'actual': 'interface GigabitEthernet0/1\n description USER PORT\n switchport access vlan 205\n spanning-tree portfast\n spanning-tree guard root', 'intended': 'interface GigabitEthernet0/1\n description USER PORT\n switchport mode access\n switchport access vlan 205\n snmp trap mac-notification change added\n snmp trap mac-notification change removed\n auto qos trust dscp\n no mdix auto\n spanning-tree portfast\n spanning-tree guard root'}} >>> # simplified yaml view of same data # interface: # compliant: false # missing: <omitted> # extra: '' # cannot_parse: true # unordered_compliant: false # ordered_compliant: false # actual: <omitted> # intended:
>>> non_compliant_backup_cfg = """
... interface GigabitEthernet0/1
...  description USER PORT
...  switchport access vlan 205
...  spanning-tree portfast
...  spanning-tree guard root
... """
>>>
>>> non_compliant_intended_cfg = regen_cfg(vars)
>>>
>>> compliance.compliance(features, non_compliant_backup_cfg, non_compliant_intended_cfg, network_os, "string")
{'interface': {'compliant': False, 'missing': 'interface GigabitEthernet0/1\n switchport mode access\n snmp trap mac-notification change added\n snmp trap mac-notification change removed\n auto qos trust dscp\n no mdix auto', 'extra': '', 'cannot_parse': True, 'unordered_compliant': False, 'ordered_compliant': False, 'actual': 'interface GigabitEthernet0/1\n description USER PORT\n switchport access vlan 205\n spanning-tree portfast\n spanning-tree guard root', 'intended': 'interface GigabitEthernet0/1\n description USER PORT\n switchport mode access\n switchport access vlan 205\n snmp trap mac-notification change added\n snmp trap mac-notification change removed\n auto qos trust dscp\n no mdix auto\n spanning-tree portfast\n spanning-tree guard root'}}
>>>
# simplified yaml view of same data
# interface:
#   compliant: false
#   missing: <omitted>
#   extra: ''
#   cannot_parse: true
#   unordered_compliant: false
#   ordered_compliant: false
#   actual: <omitted>
#   intended: <omitted>

It may not be immediately obvious, but the key is in the feature definition features = [{"name": "interface", "ordered": True, "section": ["interface "]}]. This is the only thing that needs to change when adding additional features. This truly becomes powerful once you have an SoT and have built out your IaC processes.

This process is the underlying principle on which Nautobot Golden Config app is built:

The app provides tooling and ease of use around the processes, which makes it more consumable, but the crux of what is happening is described in these last few paragraphs and code snippets.

Custom Business Logic

While not more strictly defined, it is important to cover custom business logic. There are times when you may only care about the application of certain features but not check beyond that. Let’s take an example used in Nautobot Golden Config custom compliance engine.

<span role="button" tabindex="0" data-code="# sample_config = '''router bgp 400 # no synchronization # bgp log-neighbor-changes # neighbor 70.70.70.70 remote-as 400 # neighbor 70.70.70.70 password cisco # neighbor 70.70.70.70 update-source Loopback80 # no auto-summary # ''' import re BGP_PATTERN = re.compile("\s*neighbor (?P<ip>\d+\.\d+\.\d+\.\d+) .*") BGP_SECRET = re.compile("\s*neighbor (?P
# sample_config = '''router bgp 400
#  no synchronization
#  bgp log-neighbor-changes
#  neighbor 70.70.70.70 remote-as 400
#  neighbor 70.70.70.70 password cisco
#  neighbor 70.70.70.70 update-source Loopback80
#  no auto-summary
# '''
import re
BGP_PATTERN = re.compile("\s*neighbor (?P<ip>\d+\.\d+\.\d+\.\d+) .*")
BGP_SECRET = re.compile("\s*neighbor (?P<ip>\d+\.\d+\.\d+\.\d+) password (\S+).*")
def custom_compliance_func(obj):
    if obj.rule == 'bgp' and obj.device.platform.slug == 'ios':
        actual_config = obj.actual
        neighbors = []
        secrets = []
        for line in actual_config.splitlines():
            match = BGP_PATTERN.search(line)
            if match:
                neighbors.append(match.groups("ip")[0])
            secret_match = BGP_SECRET.search(line)
            if secret_match:
                secrets.append(match.groups("ip")[0])
    neighbors = list(set(neighbors))
    secrets = list(set(secrets))
    if secrets != neighbors:
        compliance_int = 0
        compliance = False
        ordered = False
        missing = f"neighbors Found: {str(neighbors)}\nneigbors with secrets found: {str(secrets)}"
        extra = ""
    else:
        compliance_int = 1
        compliance = True
        ordered = True
        missing = ""
        extra = ""
    return {
        "compliance": compliance,
        "compliance_int": compliance_int,
        "ordered": ordered,
        "missing": missing,
        "extra": extra,
    }

In the above case you are simply enforcing that if neighbor 70.70.70.70 is found, there is a configured password on it. The obvious downside to this is every situation must be handled with custom code, and you are not reviewing all of the configuration or even a majority of the configuration.

Linting

In some cases you are truly only looking for certain conditions. This can be nice, since it applies more generically and has good utility around ensuring security configurations are applied correctly. Services such as STIG or Cisco Config Analysis Tool are largely based on the same concept, which is to confirm that a piece of configuration is on or specifically not on.

We can take a look at a netlint that was built by fellow Network to Coder Leo Kirchner.

>>> from netlint.checks.checker import Checker
>>> from netlint.checks.utils import NOS
>>>
>>> configuration = [
...   "feature ssh",
...   "feature bgp",
...   "hostname test.local"
... ]
>>>
>>> checker = Checker()
>>>
>>> checker.run_checks(configuration, NOS.CISCO_NXOS)
False
>>> checker.check_results
{'NXOS101': None, 'NXOS102': CheckResult(text='BGP enabled but never used.', lines=['feature bgp']), 'NXOS103': None, 'NXOS104': None, 'VAR101': None, 'VAR102': None, 'VAR103': None}
>>>

Conclusion

Throughout my career, I have personally deployed and built each of these types of systems. However, I truly believe that the only long-term method to scale is provided in the “Intended State vs Actual State” section and used within Nautobot Golden Config. Any other method is likely good to get quick results, but tends to not work in situations more complicated than the initial POC. The collateral benefits are also equally compelling in themselves.

That being said, would love to hear your feedback. Are there any other types of golden config that I have missed? If so, look forward to seeing you in the comments.

-Ken



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 4

Blog Detail

In Part 3 of this series we looked at macros and filters with Jinja2 templating. Now it’s time to ramp it up to the next level and look at something a little bit more like what you would see in the real world. In this post we will cover how to use complex Ansible inventories to create a hierarchy of configuration variables, and how they are inherited down into the resulting configuration. Using variable inheritance and host groups gives you the ability to flexibly assign variables to devices in a dynamic fashion based on group membership. We will see this when we get to the example at the end, where we assign variables at different levels in the hierarchy. This way we will be able to set common variables at a high level, and get more specific or override those variables at a lower point the tree.

Ansible Inventory

In order to understand variable inheritance we will use a common example of a network with multiple regions, having sites within those regions. Ansible has the concept of host groups, which can consist of hosts or other groups. These groups provide the ability to run jobs or assign specific configuration parameters to specific groups of devices. For more advanced Ansible inventory documentation, check out the Ansible docs on inventory. Most of the advanced topics of Ansible inventory, such as dynamic inventory and using multiple inventory sources, are beyond the scope of this blog post.

We’ll start off with an inventory structure set up like this (for visualization):

└── regions
    ├── central
    │   └── sites
    │       ├── chicago
    │       ├── dallas
    │       └── minneapolis
    ├── eastern
    │   └── sites
    │       └── newyork
    └── western
        └── sites
            └── phoenix

Here we can see that regions is the top-level “grouping”. Central, eastern, and western are the regions, with sites underneath each of those. We can then build an Ansible inventory in yaml that looks like this:

# inventory
all:
  children:
    regions:
      children:
        central:
          children:
            sites:
              children:
                chicago:
                  hosts:
                    chi-router1:
                dallas:
                  hosts:
                    dal-router1:
                minneapolis:
                  hosts:
                    min-router1:
        eastern:
          children:
            sites:
              children:
                newyork:
                  hosts:
                    new-router1:
        western:
          children:
            sites:
              children:
                phoenix:
                  hosts:
                    phe-router1:

Now, this looks a little scary, but it is just expanded because of the children keywords that denote child groups of the parent groups. This adds a few extra lines, but should still be readable. In a production environment it would be best to set up a file structure similar to the one in the Ansible docs – inventory where group variables go in their own files named for the group they are assigned to versus all in the same file. Another option is to use dynamic inventories, or Golden Configuration-style jobs within Nautobot. This is because this yaml file gets unwieldy rather quickly once you add a few sites and variables. In Nautobot (Golden Config plugin), you’re able to create a small yaml “inventory” (variable). These inventory files are pulled into “config contexts”, and linked to various objects in Nautobot like regions, tenants, sites, etc., with metadata. See config context docs and Golden Config Docs for more information on this. For brevity and simplicity, we will stick with a single inventory file in our examples in this post. One note: be careful with spacing/indentation in yaml; it is very important. Now that we have the basic inventory structure, we can move into assigning variables to various groupings.

Variable Assignment

In our example we are going to assign (via the vars key) a aaa server for all regions, dns and ntp servers per region, and a syslog server per site to each of the routers in our inventory. Then, in Chicago we have a different dns server that we want those devices to use instead of the regional one. Again, this is a pretty basic example, but you should be able to begin to see the ways this can be used and adapted for different use cases and environments. One thing to note here as you get into more advanced variable structures, is that there is a merge that happens in a specific order for variable scopes in Ansible. This is beyond the scope of this post, but you can read more on that here Ansible Variables – Merging. All we will need to know for this example is that the lower down the tree a variable is assigned, the higher preference it is given; and it will override variables assigned at a higher level. In our example with the dns_server assigned at the central region, will be overridden for hosts at the chicago site.

# inventory
all:
  children:
    regions:
      vars:
        aaa_server: 4.4.4.4
      children:
        central:
          vars:
            ntp_server: 1.1.1.1
            dns_server: 1.1.2.1
          children:
            sites:
              children:
                chicago:
                  vars:
                    syslog_server: 1.1.1.2
                    dns_server: 1.1.2.2
                  hosts:
                    chi-router1:
                dallas:
                  vars:
                    syslog_server: 1.1.1.3
                  hosts:
                    dal-router1:
                minneapolis:
                  vars:
                    syslog_server: 1.1.1.4
                  hosts:
                    min-router1:
        eastern:
          vars:
            ntp_server: 2.2.2.2
            dns_server: 2.2.3.2
          children:
            sites:
              children:
                newyork:
                  vars:
                    syslog_server: 2.2.2.3
                  hosts:
                    new-router1:
        western:
          vars:
            ntp_server: 3.3.3.3
            dns_server: 3.3.4.3
          children:
            sites:
              children:
                phoenix:
                  vars:
                    syslog_server: 3.3.3.4
                  hosts:
                    phe-router1:

We can run ansible-inventory --inventory=inventory.yaml --list to validate our inventory file and also view how the variables will get collapsed/merged, then applied to hosts. We can see in the hostvars section what actual variables and values will be assigned to each host in the inventory based on the inherited values. We can see that chi-router has the same ntp_server as the other central region devices, but the dns_server is different. This is because the dns_server variable assigned to the chicago site overrides the one set by the central region. We can also see the aaa_server value is the same across all devices because it was assigned in the regions host group.

{
    "_meta": {
        "hostvars": {
            "chi-router1": {
                "aaa_server": "4.4.4.4",
                "ntp_server": "3.3.3.3",
                "dns_server": "1.1.2.2",
                "syslog_server": "1.1.1.2"
            },
            "dal-router1": {
                "aaa_server": "4.4.4.4",
                "ntp_server": "3.3.3.3",
                "dns_server": "3.3.4.3",
                "syslog_server": "1.1.1.3"
            },
            "min-router1": {
                "aaa_server": "4.4.4.4",
                "ntp_server": "3.3.3.3",
                "dns_server": "3.3.4.3",
                "syslog_server": "1.1.1.4"
            },
            "new-router1": {
                "aaa_server": "4.4.4.4",
                "ntp_server": "3.3.3.3",
                "dns_server": "3.3.4.3",
                "syslog_server": "2.2.2.3"
            },
            "phe-router1": {
                "aaa_server": "4.4.4.4",
                "ntp_server": "3.3.3.3",
                "dns_server": "3.3.4.3",
                "syslog_server": "3.3.3.4"
            }
        }
    }
    # extra lines omitted for brevity. Here you can see other information on how inventory is grouped.
}

Templating with Inherited Variables

Now that we have our variable and inventory structure created, we can start to look at using that inventory to create configuration sections with Jinja2 templating. We will use the same playbook from blog Part 3 (with inventory.yaml file above), to generate a configuration snippet. We will make one minor change to the playbook, which is that we’re using hosts: regions, which will target the regions host group. If we had another group at the same level (under the all group) as regions, we could target the hosts in those groups separately. If we had an inventory file like the one below, the playbook would only run against hosts inside the regions group, not the datacenters group.

# extra_groups_inventory.yaml
all:
  children:
    regions:
      {ommitted for brevity...}
    datacenters:
      {omitted for brevity...}

Now, to our example playbook, where we will target the regions group and generate configurations for the devices within that group.

<span role="button" tabindex="0" data-code="# playbook.yaml – name: Template Generation Playbook hosts: regions
# playbook.yaml
- name: Template Generation Playbook
  hosts: regions   <--- This is where we can limit to specific groups
  gather_facts: false

  tasks:
    - name: Generate template
      ansible.builtin.template:
        src: ./template.j2
        dest: ./configs/.cfg
      delegate_to: localhost

We will use a simple template to generate the configuration lines to set the dns server, ntp server, and logging server for a Cisco device.

# template.j2
hostname {{ inventory_hostname }}
ip name-server {{ dns_server }}
logging host {{ syslog_server }}
ntp server {{ ntp_server }}
tacacs-server host {{ aaa_server }} key mysupersecretkey

We can now run the playbook with our template with the command ansible-playbook -i inventory.yaml playbook.yaml. First, we see below how the file structure looks prior to running the playbook. Then we see the output of running the playbook with the command above.

# before running the playbook
.
├── configs
├── inventory.yaml
├── playbook.yaml
(base) {} ansible_inventory ansible-playbook -i inventory.yaml playbook.yaml

PLAY [Template Generation Playbook] **************************************************************************************************

TASK [Generate template] **************************************************************************************************
changed: [phe-router1 -> localhost]
changed: [new-router1 -> localhost]
changed: [min-router1 -> localhost]
changed: [chi-router1 -> localhost]
changed: [dal-router1 -> localhost]

PLAY RECAP **************************************************************************************************
chi-router1: ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
dal-router1: ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
min-router1: ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
new-router1: ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
phe-router1: ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

We can see the playbook generates three files in the ./configs folder, one for each router. Here we see the new file structure, and the resulting configuration files that are generated from our playbook.

# after running the playbook
.
├── configs
│   ├── chi-router1.cfg
│   ├── dal-router1.cfg
│   ├── min-router1.cfg
│   ├── new-router1.cfg
│   └── phe-router1.cfg
├── inventory.yaml
├── playbook.yaml
# chi-router1.cfg
hostname chi-router1
ip name-server 1.1.2.2
logging host 1.1.1.2
ntp server 3.3.3.3
tacacs-server host 4.4.4.4 key mysupersecretkey
{ other tacacs config ommitted for brevity... }
# min-router1.cfg
hostname min-router1
ip name-server 3.3.4.3
logging host 1.1.1.4
ntp server 3.3.3.3
tacacs-server host 4.4.4.4 key mysupersecretkey
{ other tacacs config ommitted for brevity... }
# dal-router1.cfg
hostname dal-router1
ip name-server 3.3.4.3
logging host 1.1.1.3
ntp server 3.3.3.3
tacacs-server host 4.4.4.4 key mysupersecretkey
{ other tacacs config ommitted for brevity... }
# phe-router1.cfg
hostname phe-router1
ip name-server 3.3.4.3
logging host 3.3.3.4
ntp server 3.3.3.3
tacacs-server host 4.4.4.4 key mysupersecretkey
{ other tacacs config ommitted for brevity... }
# new-router1.cfg
hostname new-router1
ip name-server 3.3.4.3
logging host 2.2.2.3
ntp server 3.3.3.3
tacacs-server host 4.4.4.4 key mysupersecretkey
{ other tacacs config ommitted for brevity... }

In the results we can see that the routers have each inherited their syslog_server and ntp_server values from their regional variables, and chicago has overridden with the site-specific value. The aaa_server variable is shared across all regions due to its being assigned at the regions group level. We could override this for a specific host or site if we assigned the same aaa_server variable lower in the hierarchy, or if we had the datacenters group like we mentioned at the beginning of the post, we could have a different aaa server assigned to those devices by setting the variable there. Also, note how we didn’t even have to modify the variable calls in the Jinja2 template, because Ansible handles which values get applied via the variable merge process we mentioned above. This makes templates simple and clean, so they don’t require a lot of if/else logic to say “if it’s a device in this region, apply dns server X, but if it’s in another region apply dns server Y”.


Conclusion

This is a very simple example of variable inheritance, and should have given you a taste of what things are possible and how you might be able to apply this to your own network or environment. You can combine variables and inheritance trees within Ansible host groups in an unlimited number of ways to fit each individual or organizational need. We hope you have enjoyed this series and have found it helpful. Until next time.

-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 3

Blog Detail

In the first and second parts of this series we discussed extracting variables from your device configurations, building your host and group data structures with those variables, and then using that data along with Jinja2 templates to generate configurations programmatically. In the third part of this series, we will dive deeper into the more advanced methods of manipulating the data output during generation by using two key features of Jinaj2, filters and macros.

Filters

When using Jinja2 to generate configurations you might find yourself at times wanting to convert a variable value to another format. This is useful in cases where you don’t necessarily want to document every possible variable for your configuration. One example of this would be using a CIDR notation for an IP address variable. By using CIDR notation, you’re able to document not only the host address but also determine the network address, broadcast address, and associated netmask. Extracting that information from the CIDR address variable is where Jinja2 filters come into play. By using the ipaddr filter that’s based on top of the netaddr Python library, you’re able to pass the CIDR address to a specific variable to get the desired piece of data.

In order to utilize a filter such as ansible.utils.ipaddr, you pipe (using the | character) a value to your desired filter. You can chain together as many filters as you like as shown below:

# router1.yml
network: "192.168.1.0/24"
# template.j2
ip route 0.0.0.0 0.0.0.0 {{ network | ansible.utils.ipaddr("1") | ansible.utils.ipv4("address") }}

By using the CIDR address notation defined by the network variable and passing it through the ipaddr and ipv4 filters, we are able to obtain the gateway address and render the default route as shown:

ip route 0.0.0.0 0.0.0.0 192.168.1.1

This works due to a Jinja2 filter simply being a Python function that accepts arguments and returns some value. With the first ansible.utils.ipaddr("1") step, it’s taking the value of the network variable and finding the first IP address in the network, 192.168.100.1/24. Then the ansible.utils.ipv4("address") filter takes that value and finds just the address, which strips the /24 and returns 192.168.100.1.

Now, you might be asking yourself what would be the use for something like this? Using templates like the above allows for you to make changes across your fleet while still taking into account variations in configurations. For example, you could write a playbook like below to set a new default gateway on devices:

# update_gateways.yaml
- name: Default Route Update Playbook
  hosts: all
  gather_facts: false

  tasks:
    - name: Update default gateway on inventory hosts
      cisco.ios.ios_config:
        backup: "yes"
        src: "./template.j2"
        save_when: "modified"
(base) {} ansible-playbook -i inventory update_gateways.yaml

PLAY [Default Route Update Playbook] ***********************************************************************************************************************

TASK [Update default gateway on inventory hosts] ***********************************************************************************************************************
changed: [router1]
changed: [router2]
changed: [router3]

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

To obtain the currently available filters included with Ansible, you’ll need to install them from the ansible.utils collection. This can be done by issuing ansible-galaxy collection install ansible.utils at the command line. In addition, as filters are simply Python functions, you are able to write your own for utilizing within your templates. This is extremely helpful when you have some complex piece of data that you wish to manipulate before inserting into a configuration. Using the example from above, we can write a function to perform the same and simplify the template:

#custom_filters.py
import netaddr

class FilterModule(object):
    def filters(self):
        return {
            "get_gateway_address": self.get_gateway_address
        }

    def get_gateway_address(self, network: str):
        network = netaddr.IPNetwork(network)
        return str(network.ip + 1)

As custom filters are just simple Python functions, adding them into the Jinja environment for Ansible requires some specific code. As you can see in the example above, there is a FilterModule class that Ansible looks for when adding custom filters. In this class there must be a filters method that returns a dictionary where the key is the name you want to use for your filter and the value being the function itself. There isn’t a requirement for the called method to reside in the FilterModule class, but putting it there helps prevent potential namespace conflicts.

In order to test this filter within Ansible, you can place the Python file containing the filter definition inside a folder called filter_plugins alongside your playbooks, utilizing it as shown in the diagram below:

(base) {} tree
.
├── filter_plugins
│   ├── custom_filters.py
├── group_vars
├── inventory
├── update_gateways.yaml
├── template.j2

You would then simply update the template line to use the filter like so:

ip route 0.0.0.0 0.0.0.0 {{ network | get_gateway_address }}

Once you’re confident it’s working as intended, you can bundle it alongside others in a collection for easy installation in other environments. If you’re curious about the included filters in Ansible, the source for them is available in their GitHub repo. The Jinja2 framework also includes a number of filters that can be found in their reference documentation.

Macros

Macros are the equivalent of functions in Jinja2. They can be used to store a single word or phrase, or even do some processing and manipulation of your data using Jinja2 syntax as opposed to Python syntax. These are handy when you might not be comfortable with Python but still want to process your data in some manner. Continuing with the example from above, we could write a macro to note the combination of filters like below:

{% macro get_gateway_ip(network) -%}
{% network | ansible.utils.ipaddr("1") | ansible.utils.ipv4("address") -%}
{%- endmacro -%}

We would then need to update the template to call the macro by passing the variable value to the macro as an argument much like Python takes arguments:

ip route 0.0.0.0 0.0.0.0 {{ get_gateway_ip(network) }}

Notice how we pass in the network variable, which in the template file is equivalent to the CIDR notation 192.168.1.0/24. This value is what is then passed to the macro, and acted upon by the filters. The macro then returns the ip address as 192.168.1.1. The rendered result would be:

ip route 0.0.0.0 0.0.0.0 192.168.1.1

Another example would be to define the default interface configuration and expand that macro for your other port roles. Using the configuration information below, we can create a macro that covers the basics of an interface like so:

interfaces:
    - name: "GigabitEthernet0/1"
      duplex: "full"
      speed: 1000
      port_security: false
    - name: "GigabitEthernet0/2"
      duplex: "full"
      speed: 1000
      port_security: true
{% macro base_intf(intf) -%}
interface {{ intf["name"] }}
  duplex {{ intf["duplex"] }}
  speed {{ intf["speed"] }}
{%- endmacro -%}

We can then create another macro that extends the base_intf macro to add in the appropriate port security configuration like so:

{% macro secure_port(intf) -%}
{{ base_intf(intf) }}
  access-session port-control auto
  dot1x pae authenticator
{%- endmacro -%}

Now, when we want to generate the configuration we simply need to call the appropriate macro, like so:

# interfaces.j2
{% for intf in interfaces %}
{% if intf["port_security"] %}
{{ secure_port(intf) }}
{% else %}
{{ base_intf(intf) }}
{% endif %}
{% endfor %}

The above template would then render the following configuration using the interface information above:

interface GigabitEthernet0/1
  duplex full
  speed 1000
interface GigabitEthernet0/2
  duplex full
  speed 1000
  access-session port-control auto
  dot1x pae authenticator

As above, we can then utilize this template in a playbook to update the interfaces on our Devices like so:

# update_interfaces.yaml
- name: Update Interfaces Playbook
  hosts: all
  gather_facts: false

  tasks:
    - name: Update interfaces on inventory hosts
      cisco.ios.ios_config:
        backup: "yes"
        src: "./interfaces.j2"
        save_when: "modified"
(base) {} ansible-playbook -i inventory update_interfaces.yaml

PLAY [Update Interfaces Playbook] ***********************************************************************************************************************

TASK [Update interfaces on inventory hosts] ***********************************************************************************************************************
changed: [router1]
changed: [router3]
changed: [router2]

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

As you can see, using macros enables you to minimize duplicated code, which simplifies things in addition to building compartmentalized logic into your templates. In addition, as with Python functions, you can place these macros in a central repository and reference them in your templates using Jinja imports. However, that’s outside the scope of this post, but more information can be found in the Jinja2 documentation.


Conclusion

In this post we went over the basics of Jinja2 filters and macros and how they can be utilized to aid you in manipulating your data being inserted into your templates. In Part 4 of this series, we’ll go into how Ansible handles variable inheritance and how that can enable more advanced templates.

-Justin



ntc img
ntc img

Contact Us to Learn More

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