NETCONF: Abstract the Abstractions

Author: Kirk Byers
Date: Aug 9, 2022
Netmiko Logo

In a recent article, I described how to use NETCONF to configure a loopback interface on a Cisco IOS-XE route. In this article, I also used the IOS-XE NETCONF Candidate Configuration Feature.

As I mentioned in that article, the shown solution had several problems including: 1) there was no validation, 2) there was no locking of the configuration, 3) there was no saving the configuration to startup-config, and 4) finally the XML configuration was hard-coded to a particular interface.

Let's start working on these issues. In particular, let's eliminate the hard-coding of the XML configuration to a particular loopback interface.

Abstract the Abstractions

Now I want to keep the XML configuration file in an external file and we want to abstract away the hard-coding of the loopback to a particular interface number.

If we look at our previous loopback_cfg.xml file and find all the occurrences of the loopback interface number we see the following:

$ grep 99 loopback_cfg.xml 
        <name>99</name>
      <name>Loopback99</name>
        <name>Loopback99</name>
      <name>Loopback99</name> 

Thus we have four occurrences of the loopback number. Let's convert this loopback_cfg.xml file over to a Jinja2 file and replace the loopback number with a variable.

Jinja2 Glue-Code

The place to start here is to first convert the Jinja2 file over to a Jinja2 template, but with the file completely unchanged. In other words, the file contains ZERO Jinja2 variables and ZERO Jinja2 control structures.

Below we will add the Python Jinja2 glue-code to our program. This glue-code will properly render this template. Thus at the end of this, we will have recreated our XML, but from the Jinja2 template.

So what does the Python Jinja2 glue-code look like:

from jinja2 import FileSystemLoader, StrictUndefined
from jinja2.environment import Environment

def generate_loopback_cfg(j2_vars=None):
    if j2_vars is None:
        j2_vars = {}

    j2_env = Environment(
        undefined=StrictUndefined, 
        keep_trailing_newline=True
    )
    j2_env.loader = FileSystemLoader(["."])

    template_file = "loopback_cfg.j2"
    template = j2_env.get_template(template_file)
    return template.render(**j2_vars) 

We have a couple of Jinja2 imports.

We then create a function that receives Jinja2 input variables (variables that we will ultimately embed inside of our template).

One of the first things we do in this function is create a Jinja2 environment object. You can think of the Jinja2 environment as a way of specifying how the Jinja2 rendering should take place.

In this Jinja2 environment we set a few characteristics of the environment namely we force any undefined variables to generate an exception (StrictUndefined). We also specify where we are going to look for templates (FileSystemLoader). Finally, we add an argument to change a very minor aspect related to whitespace (keep_trailing_newline=True).

At the end of our function, we point to our Jinja2 template and tell Jinja2 to render it:

    template_file = "loopback_cfg.j2"
    template = j2_env.get_template(template_file)
    return template.render(**j2_vars) 

If this all worked properly we should exactly generate our original XML file. So let's write the generated content out to a file and compare the before and after.

xml_content = generate_loopback_cfg()

filename = "loopback_cfg.xml"
with open(filename, "w") as f:
    f.write(xml_content) 
$ diff loopback_cfg.xml loopback_cfg.j2
$ 

So the two files are identical.

Jinja2 Variables

Now that we have a working Jinja2 structure, let's expand upon it.

In particular, let's make variables out of all references to the hard-coded loopback number.

Consequently, we add the following into our Jinja2 template (replacing all of the "99" loopback number references).

        <name>{{ loopback_number }}</name>
      <name>Loopback{{ loopback_number }}</name>
        <name>Loopback{{ loopback_number }}</name>
      <name>Loopback{{ loopback_number }}</name>  

We should now be able to generate our XML file using this "{{ loopback_number }}" variable and create the exact same XML file that we had previously.

loopback_vars = {"loopback_number": "99"}
xml_content = generate_loopback_cfg(loopback_vars)

filename = "loopback_cfg.xml"
with open(filename, "w") as f:
    f.write(xml_content) 
$ python cfg_loopback.py 
$ diff loopback_cfg.xml loopback_cfg.xml.bup 
$  

Putting it All Together

Now that we have the Jinja2 working, we should be able to generate the XML corresponding to any loopback number. Additionally, using the code from part1 we should be able to push this configuration to our Cisco IOS-XE device and commit the change into our running configuration.

import os
from lxml import etree  # noqa
from ncclient import manager
from rich import print
import pdbr  # noqa

from jinja2 import FileSystemLoader, StrictUndefined
from jinja2.environment import Environment

def generate_loopback_cfg(j2_vars=None):
    if j2_vars is None:
        j2_vars = {}

    j2_env = Environment(
        undefined=StrictUndefined,
        keep_trailing_newline=True
    )
    j2_env.loader = FileSystemLoader(["."])

    template_file = "loopback_cfg.j2"
    template = j2_env.get_template(template_file)
    return template.render(**j2_vars)

def save_xml(filename, xml_content):
    with open(filename, "w") as f:
        f.write(xml_content)

if __name__ == "__main__":

    device = {
        "host": "3.85.14.166",
        "port": 830,
        "username": "pyclass",
        "password": "bogus",
        "hostkey_verify": False,
    }
    device["password"] = os.environ["PYNET_PASSWORD"]

    with manager.connect(**device) as m:

        # Generate XML
        loopback_vars = {"loopback_number": "1"}
        xml_content = generate_loopback_cfg(loopback_vars)
        add_loopback = xml_content

        # Save out to a file (so we have a record of the config)
        save_xml("loopback_cfg.xml", xml_content)

        # Stage the new loopback configuration
        nc_reply = m.edit_config(target="candidate", config=add_loopback)
        print(f"\n{nc_reply}\n")

        # Commit the candidate configuration to running config.
        nc_reply = m.commit()
        print(f"\n{nc_reply}\n") 

And executing this yields:

$ netmiko-show --cmd "show ip int brief" cisco7
Interface              IP-Address      OK? Method Status                Protocol
GigabitEthernet1       172.26.1.41     YES DHCP   up                    up      
VirtualPortGroup0      192.168.35.101  YES other  up                    up 
$ python cfg_loopback.py 

<?xml version="1.0" encoding="UTF-8"?>
<rpc-reply 
xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" 
message-id="urn:uuid:a54bb466-193d-4022-8744-3515719bd293" 
xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0">
  <ok/>
</rpc-reply>

<?xml version="1.0" encoding="UTF-8"?>
<rpc-reply 
xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" 
message-id="urn:uuid:ba8f77f9-0297-4acb-abbd-513342f4a5d0"
xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0">
  <ok/>
</rpc-reply> 
$ netmiko-show --cmd "show ip int brief" cisco7
Interface              IP-Address      OK? Method Status                Protocol
GigabitEthernet1       172.26.1.41     YES DHCP   up                    up      
Loopback1              unassigned      YES unset  up                    up      
VirtualPortGroup0      192.168.35.101  YES other  up                    up 

And there it is, our nice, little Loopback1 interface.

Reference Code

Reference code used in this article can be found here

Kirk Byers

@kirkbyers

You might also be interested in: