A Network programmability essay

A real network environment consists of multiple devices of multiple vendors, each of them with a particular syntax, this diversity brings complexity and requires a better-skilled staff.

Network programmability aims to simplify those issues, bringing the power of programming languages with the collaborative culture of the developers, which allows provisioning of multiple devices at once and mitigates human errors like configuration typos.

But looking for implementation methods there are several libraries, frameworks, and tools, most of them based in Python, whose most popular is Ansible and Nornir. Ansible is open-source software for provisioning, configuration management, and application-deployment tool, with a Domain Specific Language used for calling modules where each of them performs a specific task. Nornir, on the other hand, is a Python Framework, i.e. a library, which brings flexibility and efficiency but without the simplicity of pre-built modules made by the active community development.

In this article, it will be shown a development methodology that aims to encapsulate vendors' specific syntax, while delivering a reusable and scalable environment.

Problem Statement

Consider the following network diagram, we want to configure the IP of the interconnections interfaces of the routers, but with multiple vendors, in this case, Cisco, MikroTik, and HP ComWare there’s some additional complexity because each vendor has a particular syntax and different python libraries.

Topo.jpg

Additionally, there might be multiple input sources like monitoring systems, files, users input, and others, that shouldn't be coupled with the configuration logic, at the same time that it is desirable a single entry point for the automation, with the capability of identify the vendor and call the correct implementation.

Summarizing, we have the following problems and wishes to solve:

  • The abstraction of multiple vendors syntax in a single data model;
  • Decoupling of input data and configuration logic;
  • The single entry point for the configuration deployment;
  • Automatic vendor logic selection;

Proposed solution

For the first two problems should be noted that even with different syntax the data model should be the same because every IP interface has a “Name”, an IP Address, a Network Mask, and could have a description.

In that context, RFC 6020 defines the YANG data model for utilization with NETCONF and RESTCONF, but the YANG concept goes deeper than some protocol payload, it has the ability to fully describe a network configuration and operation, so let’s extend that concept with a YAML syntax to describe an interface, and for that purpose look at the following repositories (YANG Interface, OpenConfig IP Interface). With those standards in mind, it is possible to describe the IP interface as the following code:

---
interfaces:
  -  name: "GigabitEthernet0/2"
     description: "Some Description"
     enabled: True
     ipv4:
       address:
         - ip: "10.0.0.2"
           prefix_length: 30

Now let’s build our first class implementation, which is an abstract class for a model that defines two methods: one for data verification and the other for data export. This way, whatever is the input method the configuration deploy will always use the same method for data collection, decoupling the input from the provisioning and allowing syntax check, what bring us the following codes:

Model abstract class:

from abc import ABC, abstractmethod


class ModelSyntaxError(Exception):
    pass


class Model(ABC):


    @abstractmethod
    def getModelData(self):
        pass


    @abstractmethod
    def getModel(self):
        pass


    @abstractmethod
    def _lintModel(self):
        pass

Model Concrete Class:

from models.Model import Model
from models.Model import ModelSyntaxError
import ipaddress


class InterfaceModel(Model):

    def __init__(self, name=None, description=None, ipv4=None, enabled=False):
        self.model = 'ietf-interface'
        self.name = name
        self.description = description
        self.ipv4 = ipv4
        self.enabled = enabled
        self._lintModel()

    def _lintModel(self):
        if(self.name == None):
            raise ModelSyntaxError("Empty interface Name")
        if(type(self.ipv4) != type([])):
            raise ModelSyntaxError("Wrong ipv4 type, MUST be list")
        if(len(self.ipv4) != 0):
            for ip in self.ipv4:
                if(not 'prefix_length' in ip):
                    raise ModelSyntaxError("IPv4 without prefix_length")
                if((ip['prefix_length'] > 32) or (ip['prefix_length'] < 0)):
                    raise ModelSyntaxError("Invalid prefix_length")
                ip_address = ipaddress.ip_interface(f"{ip['ip']}/{ip['prefix_length']}")
                if(ip_address.ip == ip_address.network.network_address):
                    raise ModelSyntaxError("Invalid IP for network mask")
                if(ip_address.ip == ip_address.network.broadcast_address):
                    raise ModelSyntaxError("Invalid IP for network mask")

    def getModel(self):
        return self.model

    def getModelData(self):
        model = {}
        model['name'] = self.name
        model['enabled'] = self.enabled
        if(self.description != None):
            model['description'] = self.description
        if(len(self.ipv4) != 0):
            adressess = {}
            adressess['address'] = self.ipv4
            model['ipv4'] = adressess
        return model

For the next problems, we need the configuration logic to be selected on execution, what indicates a Factory Pattern that, based on the platform attribute of the hosts, selects the appropriate configuration logic (controller), but it would be nice if every controller implements the same modules and, for that, we need another abstract class with the following codes:

Controller abstract class:

from abc import ABC, abstractmethod
from models.Model import Model
from nornir.core.task import Task, Result


class vendorController(ABC):


    @abstractmethod
    def testConfig(self) -> Result:
        pass


    @abstractmethod
    def deployConfig(self):
        pass

Fabric Class:

from controllers.ios.iosController import iosController
from controllers.mikrotik_routeros.routerosController import routerosController
from controllers.hp_comware.hpController import hpController
from models.Model import Model
from nornir.core.task import Task, Result


class vendorFabric():


    def __init__(self , task: Task , model: Model ):
        self.controller = self._getController(task.host.platform , task , model)



    def getController(self):
        return self._controller


    def _getController(self, platform, task , model):
        if(platform == 'ios' ):
            return iosController(task=task , model=model)
        if(platform == 'mikrotik_routeros'):
            return routerosController(task=task , model=model)
        if(platform == 'hp_comware'):
            return hpController(task=task , model=model)

The last step of our implementation is the controller's concrete classes, that receives the Nornir task object and a Model object. The controller should generate an appropriate configuration for every vendor, for that, we use the getModel() method of the Model object that returns the implemented model type.

With the model type, we gather the appropriate Jinja2 template and with the Model Data (getModelData) the configurations are created, and the deploy logic is implemented by the controller.

Controller concrete class:

from controllers.Controller import vendorController
from jinja2 import Environment, PackageLoader
from models.Model import Model
from nornir.core.task import Task, Result
from nornir_napalm.plugins.tasks import napalm_configure
import ipaddress




def cidr_to_netmask(cidr):
  cidr = int(cidr)
  mask = (0xffffffff >> (32 - cidr)) << (32 - cidr)
  return (str( (0xff000000 & mask) >> 24)   + '.' +
          str( (0x00ff0000 & mask) >> 16)   + '.' +
          str( (0x0000ff00 & mask) >> 8)    + '.' +
          str( (0x000000ff & mask)))




class iosController(vendorController):


    def __init__(self , task: Task , model: Model):
        self.task = task
        self.model = model


    def _generateConfig(self):
        env = Environment(loader=PackageLoader('controllers.ios', 'templates'))
        env.filters['cidr_to_netmask'] = cidr_to_netmask
        templ = env.get_template(f"{self.model.getModel()}.j2")
        config = templ.render(self.model.getModelData())
        return config




    def testConfig(self) -> Result:
        return Result(
                host= self.task.host,
                result= self._generateConfig()
            )


    def deployConfig(self) -> Result:
        config = self._generateConfig()
        result = self.task.run(
            name= f"Device Configuration",
            task= napalm_configure,
            configuration=config
        )
        return result

Jinja2 interface template for Cisco IOS:

{% for interface in interfaces %}
interface {{ interface.name }}
{%- if interface.description is defined %}
 description {{ interface.description}}
{%- endif %}
{%- if (interface.ipv4 is defined ) %}
{%- if (interface.ipv4.address is defined ) -%}
{%- for address in interface.ipv4.address %}
 ip address {{ address.ip  }} {{ address.prefix_length | cidr_to_netmask}}
{%- endfor %}{%- endif %}{%- endif %}
{%- if interface.enabled == True %}
 no shutdown{% endif %}{%- if interface.enabled == False %}
 shutdown{%- endif %}
{%- endfor -%}

Conclusion

With that implementation it is possible to create multiple interfaces for several input types and, after the configuration logic is implemented, it’s possible to extend the system features by creating new models classes and the respective templates, with the following architecture and process:

class.png

process.png

Finally, the entry point should call the controller fabric and deploy the configuration.

from nornir import InitNornir
import os
import pprint
from nornir.core.task import Task, Result
from nornir_utils.plugins.functions import print_result
from nornir_utils.plugins.tasks.data import load_yaml
from models.interface import InterfaceModel
from models.interfaces import InterfacesModel
from controllers.Fabric import vendorFabric

def loadData(task: Task) -> Result:
    cwd = os.getcwd()
    data = task.run(
        name= f"{task.host.name} dataModel",
        task= load_yaml,
        file=f"{cwd}/data/{task.host.name}/interface.yaml"
    )
    interfaces = data.result
    ifaces = []
    for interface in interfaces['interfaces']:
        iface = InterfaceModel(
                               name=interface['name'],
                               description=interface['description'],
                               ipv4=interface['ipv4']['address'],
                               enabled=interface['enabled'] if 'enabled' in interface else False)
        ifaces.append(iface)
    interfaces = InterfacesModel(interfaces=ifaces)
    controller = vendorFabric(task=task, model=interfaces).controller
    return controller.deployConfig()



nr = InitNornir()

load_models = nr.run(
    name="Load Models",
    task= loadData
)

print_result(load_models)

With that architecture, it is possible to deploy several types of configurations in a multi-vendor environment, without the requirement of knowledge of each vendor syntax by network operators.

With some implementation, it is possible to integrate the system with several existing applications bringing flexibility and several possibilities, like a closed-loop environment. The article implementation is available in mine Github repository on the following link: Nornir-Edda

Did you find this article valuable?

Support Renato Almeida de Oliveira by becoming a sponsor. Any amount is appreciated!