SOLID Principles for NetDev Engineers

SOLID Principles for NetDev Engineers


Tech Gauge:

🟩🟩🟩⬜⬜ (3/5)

This article is moderately technical and suitable for readers with some background in Python and OOP.


I dare say the typical background of today's Network Automation Developer looks like this:

  • A network geek who knows services and protocols inside out

  • Once an oracle on vendor-specific hardware and operating systems

  • Gradually morphed (or still morphing) into a developer on the go, probably writing Python.

Even though there’s nothing wrong with it - in fact, I am a big proponent of that path - we gotta recognise that often times it means these self-taught automation ninjas won’t have had the chance to formally go through some fundamental topics on software engineering.

As someone who's still learning and growing in this field, I want to kick off a series to help Network Engineers-turned-Developers to bridge this gap, while also revisiting some crucial yet often overlooked topics myself. There's no better place to start than with the renowned SOLID principles.

While this article won't cover everything, it's always useful to kick off with some definitions. We will also take time to look at how some well-known python network automation tools implement each one of the five principles.

A short intro to SOLID Principles

What we now know as SOLID principles were first introduced by Robert C. Martin, or "Uncle Bob" in the "Design Principles and Design Patterns" paper. The SOLID principles are just a slice of the many ideas Martin and others have shared for object-oriented design. The goal? To make software designs easier to grasp, more adaptable to changes, and a breeze to maintain.

The catchy name is an acronym, with each letter referring to a simple and yet powerful principle. Let’s check it out…

SRP: Single Responsibility Principle (aka Separation of Concerns)

💡 Core Idea: A class should be responsible for doing one thing, and one thing only.

Don’t get too literal here, though: in the above definition, a "thing" should be understood as a "feature", or a “responsibility”. It wouldn’t make a lot of sense to create a class with a single method that does something very trivial, anyways.

To illustrate the concept, imagine a NetworkHost class. The SRP principle encourages developers to write the code for such class to include only attributes and methods that are pertinent to the representation of a network device, and some relevant network functions we could expect from that device. For instance:

Attributes:

  • hostname

  • mgmt_ip_address

  • location

  • vendor

  • interfaces

  • status

Methods:

  • getters and setters for each attribute - if and as required by the overall application business logic

  • get_host_info(): returns a string representation of the network host's information (hostname, Management IP, vendor, status). Some pythonistas (myself included) would argue this should actually be provided by the magic __str__() method.

  • get_interfaces_brief(): returns a data structure containing the interfaces, their admin states, etc..

  • get_network_services_brief(): returns a data structure containing enabled network services, specific configs applied to each, active sessions/sockets, etc…

  • … you got the gist…

By including methods that are directly related to managing the network host's attributes and status, we adhere to the SRP while keeping the class focused and cohesive. Other functionalities, such as network connections, diagnostics, software updates, should be handled by separate classes.

For example, below are some methods that you probably would like to have in this application as a whole, but should avoid implementing them as part of the NetworkHost class:

  • connect_to(): This would include network connection logic. Should be implemented within a ConnectionManager class.

  • ping_host(): Implements the logic to check connectivity between two hosts. It could exist as module-level function in a network_diagnostic_utils.py module, or a method in a class with the same purpose.

  • update_os(): This method involves OS/firmware updates. It’s better suited to a NetworkHostManager class, where various day-to-day Ops-related business logic would live.

In the end, you want a class that is as small and yet as complete as possible to do only what it needs to do. Here is an skeleton of what our class would look like:

class NetworkHost:
    def __init__(self, hostname, mgmt_ip_address, location, vendor, interfaces, status):
        self._hostname = hostname 
        self._mgmt_ip_address = mgmt_ip_address 
        self._location = location 
        self._vendor = vendor 
        self._interfaces = interfaces 
        self._status = status

    # here comes getter and setter methods...
    (...)

    def get_host_info(self):
        # returns a string representation of the network host's information
        (...)

    def get_interface_brief(self):
        # returns a data structure containing the interfaces, their admin states, etc..
        (...)

    def get_network_services_brief(self):
        # returns a data structure containing enabled network services, specific configs applied to each, active sessions/sockets, etc…
        (...)

    # possibly several other methods here - respecting SRP
    (...)

OPC: Open-Closed Principle

💡 Core Idea: A class should be open for extension, but closed for modification.

In other words, you should be able to add new functionality to your application without changing code in existing classes, but rather by creating new classes.

Still thinking in terms of our NetworkHost class, suppose now there’s a need to instantiate objects that will represent security appliances with enriched detail about the security configs they’ve been loaded with. Being a capable developer, you quickly come up with a slick new method named get_security_settings() that will be responsible for delivering the new desired feature.

According to the OCP, the wrong way to approach this is to simply include such method inside the already existing NetworkHost class. Instead, the best practice is to create an entirely new class, let’s say SecurityNetworkHost, that will have this new method (as well as whatever other methods a security device should contain to satisfy the new business requirements)

Inheritance is a popular choice for achieving the goals of OCP, and that’s the example you see below, but it’s not the only way to it. Concepts like Composition, Interfaces and Protocols are other possibilities (we won’t delve into those here, though).

# Extending Functionality with Inheritance 
class SecurityNetworkHost(NetworkHost):
    def __init__(self, hostname, mgmt_ip_address, location, vendor, interfaces, status, security_data):
        super().__init__(hostname, mgmt_ip_address, location, vendor, interfaces, status)
        self._security_data = security_data

    # here comes getter and setter methods for 'security_data'...
    (...)

    def get_security_settings(self):
        # Does some processing and returns whatever is required
        (...)

     # possibly several other methods here - respecting SRP
    (...)

LSP: Liskov Substitution Principle

💡 Core Idea: An instance of a parent class should be replaceable by an instance of a child class in the same code, without affecting the correctness of the program.

This means that the program that handles objects of NetworkHost should also be able to handle objects of SecurityNetworkHost right away, and without any errors.

To ensure that, when we develop SecurityNetworkHost we must not override any methods from NetworkHost in a way that changes its expected behaviour. For example, when we talked about SRP, we imagined a get_interfaces_brief() method for the NetworkHost class. Such method should return a data structure containing the interfaces, their admin states, and whatnot.

Let’s now suppose that such data structure is a python dictionary. To adhere to the LSP, the SecurityNetworkHost class cannot override that method to instead return a list, or a tuple, or any other data structure that doesn’t implement exactly the same API as python’s dictionary. If that was the case, an instance of SecurityNetworkHost could not be used interchangeably with an instance of NetworkHost.

class NetworkHost:
    (...)
    def get_interfaces_brief(self):
        # Returns a dictionary containing the interfaces and their states
        return {intf: {"admin_state": intf.admin_state, "oper_state": intf.oper_state} for intf in self.interfaces}


class SecurityNetworkHost(NetworkHost):
    (...)
    def get_interfaces_brief(self):
        # Returns a list instead of a dictionary, breaking the LSP.
        return [{"interface": intf, "admin_state": intf.admin_state, "oper_state": intf.oper_state} for intf in self.interfaces]


# Example usage
def print_interfaces_brief(network_host):
    interfaces = network_host.get_interfaces_brief()
    for interface, details in interfaces.items():
        # do something...

print_interface_brief(network_host)  # Works as expected
print_interface_brief(security_network_host)  # Raises AttributeError: 'list' object has no attribute 'items'

ISP: Interface Segregation Principle (aka, YAGNI: You Ain’t Gonna Need It)

💡 Core Idea: A class should not be forced to implement an interface it does not use.

OBS: To avoid confusion, below I’ll use the terms “NIC” to refer to a network device’s interface and “Interface” to refer to the object-oriented programming concept of a contract that a class must implement.

To better grasp how the ISP works, we need to mess a little with the requirements and assumptions of our hypothetical application. Let’s now include the need to represent end-user hosts (laptops & PCs), but with an additional assumption that such devices will have one and only one network interface card (NIC). This new class will be named EndUserHost.

Given this “single NIC” assumption, the previously included interfaces attribute along with any getters, setters and the get_interfaces_brief() methods from the NetworkHost class become completely irrelevant for the EndUserHost class.

You are probably thinking: “Well, I could simply set the interfaces attribute to None and either return None, or raise an Exception inside the relevant methods”. And, technically, you are not wrong… however, that would be forcing EndUserHost class to be concerned about certain things it doesn’t need in the first place.

To better address this issue, let’s refactor our code to formally define some python Interfaces by using the abc module (if you want to dive deeper on how Interfaces are programmed in python, you can read this awesome tutorial about it).

from abc import ABC, abstractmethod

class HostInfo(ABC):
    @abstractmethod
    def get_host_info(self):
        pass

class InterfaceInfo(ABC):
    @abstractmethod
    def get_interface_brief(self):
        pass

class SecuritySettings(ABC):
    @abstractmethod
    def get_security_settings(self):
        pass

Now, we can refactor NetworkHost to look like this:

class NetworkHost(HostInfo, InterfaceInfo):
    def __init__(self, hostname, mgmt_ip_address, location, vendor, interfaces, status):
        self._hostname = hostname 
        self._mgmt_ip_address = mgmt_ip_address 
        self._location = location 
        self._vendor = vendor 
        self._interfaces = interfaces 
        self._status = status

    # here comes getter and setter methods...
    (...)

    def get_host_info(self):
        # returns a string representation of the network host's information
        (...)

    def get_interface_brief(self):
        # returns a data structure containing the interfaces, their admin states, etc..
        (...)

    def get_network_services_brief(self):
        # returns a data structure containing enabled network services, specific configs applied to each, active sessions/sockets, etc…
        (...)

    # possibly several other methods here - respecting SRP
    (...)

… and SecurityNetworkHost to look like this:

class SecurityNetworkHost(NetworkHost, SecuritySettings):
    def __init__(self, hostname, mgmt_ip_address, location, vendor, interfaces, status, security_data):
        super().__init__(hostname, mgmt_ip_address, location, vendor, interfaces, status)
        self._security_data = security_data

    # here comes getter and setter methods for 'security_data'...
    (...)

    def get_security_settings(self):
        # Does some processing and returns whatever is required
        (...)

And finally we create EndUserHost as follows:

class EndUserHost(HostInfo):
    def __init__(self, hostname, mgmt_ip_address, location, vendor, status):
        self.hostname = hostname
        self.mgmt_ip_address = mgmt_ip_address
        self.location = location
        self.vendor = vendor
        self.status = status

    def get_host_info(self):
        # returns a string representation of the network host's information
        (...)

Now, we can see that:

  • the NetworkHost class implements the HostInfo and InterfaceInfo interfaces

  • the SecurityNetworkHost class extends NetworkHost and also implements the SecuritySettings interface

  • the EndUserHost class implements only the HostInfo interface.

This ensures that each class only implements the methods it needs, adhering to the ISP and making the code more modular and maintainable.

As an additional bonus of following this principle, you will see it helps us out on satisfying to core ideas of the next principle.

DIP: Dependency Inversion Principle

OBS: Again, the concept of OOP Interfaces is super relevant here.

💡 Core Ideas:

  1. A high-level module should not depend on the implementation of a low-level module. Instead, both should depend on abstractions.

  2. Abstractions should not depend on details. Details should depend on abstractions.

Ok, I’ll be the first one to admit this one sounds pretty… abstract, right? Let’s try to break it down, highlighting some of the concepts embedded in it’s core ideas, and then we move on with a simple example.

Here is how it works:

  • You start by defining abstract interfaces, which establish a set of method signatures. This is what is referred to as “abstractions” in the core idea of this principle.

  • These signatures form a contract, but no details are included with it. This means the next step is to develop the required concrete classes that will fulfil that contract by implementing the details of the methods outlined in the Interface. These concrete classes are what is referred to as “low-level modules” in the core ideas.

  • The fact that the concrete classes (low-level modules) must implement the contract as defined by the interfaces (abstractions), is what the core idea means by implying that “low-level modules should depend only on abstractions”.

  • Once interfaces and concrete classes are implemented, other layers of the overall system that will include the business logic can be developed to handle instances of those concrete classes. These other parts of the system are what the core idea refers to as “high-level modules”.

  • The fact that the business logic (high-level modules) knows nothing about concrete implementations and merely rely on the methods as defined in the Interfaces (abstractions), is what the core idea means by implying that “high-level modules should depend only on abstractions

  • Finally, the second part of the core idea should be somehow self-explanatory - and almost unnecessary at this point. It just wants to emphasise the one-way relationship between Interfaces (abstractions) and concrete classes (details). The Interface must dictate how the concrete classes will look like, and never the other way around.

Luckily, just by following the other principles so far, we already have most of the work done here:

  • we already have our Interfaces (abstractions): HostInfo, InterfaceInfo and SecuritySettings

  • we already have our concrete classes (low-level modules): NetworkHost, SecurityNetworkHost and EndUserHost.

Now, it is time create a business logic layer, our high-level module. Let’s stick to unrealistically trivial examples and imagine a class that provides a method that should work with any instance of any class that adheres to the HostInfo interface to print out information about any given host, regardless of its type.

class NetworkManager:
    def __init__(self, host_info: HostInfo):
        self.host_info = host_info

    def display_host_info(self):
        print(self.host_info.get_host_info())

    # potentially several other methods here... 
    (...)

NetworkManager here becomes our high-level module. Notice it requires a HostInfo object to be initialised. Because NetworkHost, SecurityNetworkHost and EndUserHost all implement the HostInfo interface, any instance of those classes satisfy the requirements. In another words: our high-level module is entirely dependent on the abstraction (Interface), but doesn’t care about the low-level modules (implementation details)

Any other part of the code that requires printing Host information on screen can simply instantiate a NetworkManager object and call display_host_info() on it.

Redundant Competitors or Collaborative Allies?

If you've been following along, you might feel that some of these principles are just reiterating the same ideas in slightly different ways. Does it mean they are a bit redundant? Are they competing against each other?

The truth is the principles are indeed interconnected and often complement each other, but each one has its own distinct focus and purpose. Here’s a quick run down on how they synergise with each other:

PrincipleFocusInteractions
SRPA class should have only one reason to change, meaning it should have only one job or responsibility.It helps in creating cohesive classes that are easier to maintain and understand, which indirectly supports all other principles by promoting modularity
OCPEntities should be open for extension but closed for modification.Encourages the use of abstractions and inheritance, which can also support DIP and ISP
LSPSubtypes must be replaceable for their base types (and vice-versa) without altering the correct execution of the code.Ensures that child classes extend parent classes without changing their behaviour, which supports OCP by allowing extensions without modifications.
ISPClients should not be forced to depend on interfaces they do not use.Promotes the creation of specific, small interfaces, which supports SRP by ensuring classes have focused responsibilities. It also supports DIP by encouraging the use of abstractions.
DIPHigh-level modules should not depend on low-level modules. Both should depend on abstractions.By depending on abstractions, DIP ensures that high-level modules are decoupled from low-level modules, which supports OCP by allowing extensions without modifications and ISP by promoting the use of specific interfaces.

SOLID in Real-World Network Automation Tools

Finally, let’s explore the real-world applications of the SOLID principles in Python network automation. By examining how various tools and libraries implement these principles, we can gain a deeper understanding of their practical benefits.

SRP: NAPALM

NAPALM (Network Automation and Programmability Abstraction Layer with Multivendor support) adheres to SRP by separating the responsibilities of different network device drivers. Each driver is responsible for interacting with a specific type of network device

In the NAPALM code, each driver (e.g., napalm-ios, napalm-eos) is implemented in its own module, handling only the interactions with that specific device type. Below a skeleton example of how that concept is applied to the IOSDriver (you can check the actual code here)

# Example from napalm-ios driver
class IOSDriver(NetworkDriver):
    def __init__(self, hostname, username, password, timeout=60, optional_args=None):
        # Initialization code specific to IOS devices
        pass

    def open(self):
        # Code to open a connection to an IOS device
        pass

    def close(self):
        # Code to close the connection
        pass

    def get_facts(self):
        # Code to retrieve facts from an IOS device
        pass

    (...)

OCP: Nornir

Nornir is designed to be easily extensible without modifying its core functionality. Users can extend Nornir by creating custom plugins for tasks, inventory, and connections.

For example, Nornir allows users to create custom task plugins that extend its functionality.

# Example of a custom task plugin
from nornir.core.task import Task, Result

def custom_task(task: Task, command: str) -> Result:
    result = task.run(task=task.run_command, command=command)
    return Result(host=task.host, result=result)

# Usage
from nornir import InitNornir

nr = InitNornir(config_file="my_config.yaml")
result = nr.run(task=custom_task, command="show ip interface brief")

LSP: Netmiko

Netmiko ensures that subclasses can be used interchangeably with the base class. In Netmiko’s code, each device driver (e.g., CiscoIosBase, AristaEOSBase) inherits from BaseConnection.

Here’s an example of how the CiscoIosBase works (remember to look at the real code for the actual implementation)

# Example from CiscoIosBase driver
class CiscoIosBase(BaseConnection):
    def send_command(self, command_string, **kwargs):
        # Implementation specific to Cisco IOS devices
        pass

# Usage
from netmiko import ConnectHandler

device = {
    'device_type': 'cisco_ios',
    'host': '192.168.1.1',
    'username': 'admin',
    'password': 'password',
}

connection = ConnectHandler(**device)
output = connection.send_command("show ip interface brief")

ISP: Netmiko (again)

Netmiko is also a good example of a real-world implementation of ISP, as it provides different interfaces for different functionalities, ensuring that clients only depend on the interfaces they use. For example, it has separate classes for different functionalities, such as BaseConnection for connection logic and BaseCommand for command execution logic.

OBS: Note that in Netmiko’s case, the interface is not defined using the formal Python abc.ABC class. While this approach is less elegant, it is still acceptable in Python and considered an interface from a OOP perspective.

# Example of using Netmiko's BaseConnection and BaseCommand classes
from netmiko import ConnectHandler

class BaseConnection:
    def __init__(self, device_params):
        self.device_params = device_params
        self.connection = None

    def connect(self):
        self.connection = ConnectHandler(**self.device_params)

    def disconnect(self):
        self.connection.disconnect()

class BaseCommand:
    def __init__(self, connection):
        self.connection = connection

    def execute(self, command):
        return self.connection.send_command(command)

# Usage
device = {
    'device_type': 'cisco_ios',
    'host': '192.168.1.1',
    'username': 'admin',
    'password': 'password',
}

# Only using the connection interface
connection = BaseConnection(device)
connection.connect()

# Only using the command interface
command = BaseCommand(connection.connection)
output = command.execute("show ip interface brief")
print(output)

connection.disconnect()

DIP: PyATS

PyATS (Python Automated Test Systems) follows DIP by using abstract interfaces for test scripts and allowing the actual implementations to be provided by different test libraries. This decouples the high-level test logic from the low-level implementations.

PyATS' test steps and interfaces are defined in the pyats.aetest module in PyATS code.

# Example of a test script using PyATS
from pyats import aetest
from pyats.topology import loader

class CommonSetup(aetest.CommonSetup):
    @aetest.subsection
    def connect(self, testbed):
        self.testbed = loader.load(testbed)
        self.device = self.testbed.devices['router1']
        self.device.connect()

class PingTest(aetest.Testcase):
    @aetest.test
    def ping(self):
        output = self.device.execute('ping 8.8.8.8')
        assert 'Success rate is 100 percent' in output

class CommonCleanup(aetest.CommonCleanup):
    @aetest.subsection
    def disconnect(self):
        self.device.disconnect()

if __name__ == '__main__':
    aetest.main(testbed='testbed.yaml')

Wrapping It Up: A SOLID path forward for NetDev Engineers

Understanding and applying the SOLID principles can significantly enhance the quality of software design for Network Automation Developers. These guidelines make your code easier to read, extend, and maintain, allowing you to create strong and scalable solutions.

Tools like NAPALM, Nornir, Netmiko, and PyATS demonstrate how these principles work in real life, and drawing inspiration from them might help you tackle complex challenges.

As network engineers transition into development roles, embracing these principles is key to building efficient and reliable software. So, sharpen your coding skills and become a true Network Automation Ninja!