In the search for a framework that would allow me to plug my automations without too much fuss, I came across Nornir. I have been enjoying it so much that I decided to do write down an introduction for others. My hope is that this post will allow others to hit the ground running.

Nornir logo

Nornir

The ‘about’ section in the Nornir repo does a good job summarizing it. Stated is that Nornir is a ‘pluggable multi-threaded framework with inventory management to help operate collections of devices’.

In Nornir, you write tasks that perform an automation for a single device. Nornir can run these tasks against devices defined in its inventory. You can run a task against all hosts, or you can choose to target hosts based on characteristics defined in the inventory. After Nornir is done running the tasks, it returns the results in a Python object. You can output the results on screen or write something to store them in a database.

Nornir parts

Something worth noting is the Nornir’s speed and efficiency. Without having to put in a lot of effort yourself, you can send a command to 5.000 devices in 3 minutes using minimal resources.

Nornir does not come with a DSL. You express yourself in Python. All the tasks you create, all the flow control you put into your automation and all the debugging is done in Python. In my opinion, at least for complicated operations, this is a lot cleaner and better when compared to what most DSLs have to offer.

Working with Nornir implies that you can use everything that is available in the Python ecosystem. Most people using other frameworks tout the fact that it comes with ‘batteries included’. Most often, these ‘batteries’ are Python modules and libraries, brought to you by the open-source community. Quite often, you can pip install the same libraries and plug them into Nornir.

Another thing worth mentioning about is the fact that Nornir is minimal and to the point. In Nornir, there are very little constructs you have to worry about or familiarize yourself with. As soon as you understand the basic mechanics, you are good to go.

Installing Nornir and setting up the inventory

The best way to better understand Nornir is to create and run a few tasks yourself. First, let’s install Nornir and the plugins used in this article:

pip3 install nornir
pip3 install nornir_napalm
pip3 install nornir_netmiko
pip3 install nornir_utils

At the time of writing, this installed Nornir 3.0.0. All the plugins I used were version 0.1.1.

Before we can run any task in Nornir, we need to have an inventory. The inventory is where the characteristics of devices Nornir needs to touch are defined. Nornir allows you to interface with external systems and use that data to programmatically build an inventory. It also ships with a SimpleInventory, which is what we’ll use in this example. Using the SimpleInventory, we need to define three YAML files for the following:

  • hosts
  • groups
  • defaults

The hosts define the devices that Nornir can target. We can define elaborate characteristics for every host, but in this example, we will stick to the basics. The following is the hosts file I will be using, stored under /inventory/hosts.yaml:

veos01:
  hostname: 10.254.169.50
  groups:
    - arista
veos02:
  hostname: 10.254.169.51
  groups:
    - arista

We defined 2 hosts: veos01 and veos02. We specified an IP address and we put the devices in a group. Inside the groups, we can define attributes that hosts will inherit.

The following is the groups file that will put in place the defaults that will allow you to manage devices using the NAPALM plugin. The file is stored as /inventory/groups.yaml:

juniper: { platform: junos, port: 830 }
arista: { platform: eos, port: 22 }
arista_eapi: { platform: eos, port: 443}
cisco_ios: { platform: ios, port: 22 }
cisco_nxos: { platform: nxos, port: 22 }

We assigned the hosts to the arista group. This means that the hosts will be of the platform ‘eos’ and they will use port ‘22’.

Lastly, our example also requires a defaults file, which we will store as /inventory/defaults.yaml:

username: admin
password: password

The characteristics defined in the defaults are added to every device. Host or groups characteristics take precedence. So, in case you want to make a juniper device use a different password, you could overwrite the default password at the group level.

The groups and defaults can be used for more than just instructions on how to connect to devices. They are a nice construct to use to describe your infrastructure on a more abstract level and have that data available at runtime inside a task when you execute the script. You could create groups for datacenters and assign every datacenter with an AS, a syslog IP, management subnets etc. Doing so allows you to use that information for a variety of reasons. Two important things you could use this information for are targeting and templating.

Running your first task

A good first Nornir script could be one where you do nothing more then send the show version command to the devices in the inventory and return the output to your screen. The following example will do just that:

"""
Nornir example script sending a command using netmiko
"""
from nornir import InitNornir
from nornir_utils.plugins.functions import print_result
from nornir.plugins.inventory.simple import SimpleInventory
from nornir.core.plugins.inventory import InventoryPluginRegister
from nornir_netmiko.tasks import netmiko_send_command

if __name__ == "__main__":    
    InventoryPluginRegister.register("SimpleInventory", SimpleInventory)
    
    nr = InitNornir(
        runner={
            "plugin": "threaded",
            "options": {
                "num_workers": 30,
            },
        },
        inventory={
            "plugin": "SimpleInventory",
            "options": {
                    "host_file": "/inventory/hosts.yaml",
                    "group_file": "/inventory/groups.yaml",
                    "defaults_file": "/inventory/defaults.yaml"
            },
        }
    )      

    result = nr.run(netmiko_send_command, command_string="show version")

    print_result(result)

We started off importing all the required libraries. After this, we instruct Nornir to use the SimpleInventory plugin and we create a Nornir object referencing the files that contain the hosts, groups, and defaults.

After the nr object is created, we run the netmiko_send_command task against every host and issue the show version command. When the task is completed, we use print_result to output the return of the task to screen. After running the script, we can see the following:

sh-4.2# python scripts/nornir_netmiko_example.py
netmiko_send_command************************************************************
* veos01 ** changed : False ****************************************************
vvvv netmiko_send_command ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
vEOS
Hardware version:      
Serial number:         
Hardware MAC address:  444c.a842.c02f
System MAC address:    444c.a842.c02f

Software image version: 4.24.1.1F
Architecture:           i686
Internal build version: 4.24.1.1F-17172302.42411F
Internal build ID:      cf3ba327-e192-46b4-8e8e-4aeaee798dd2

Uptime:                 9 weeks, 1 days, 0 hours and 22 minutes
Total memory:           1872580 kB
Free memory:            736192 kB

^^^^ END netmiko_send_command ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* veos02 ** changed : False ****************************************************
vvvv netmiko_send_command ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
 vEOS
Hardware version:      
Serial number:         
Hardware MAC address:  444c.a809.182b
System MAC address:    444c.a809.182b

Software image version: 4.24.1.1F
Architecture:           i686
Internal build version: 4.24.1.1F-17172302.42411F
Internal build ID:      cf3ba327-e192-46b4-8e8e-4aeaee798dd2

Uptime:                 12 weeks, 6 days, 19 hours and 50 minutes
Total memory:           1872580 kB
Free memory:            1057228 kB

^^^^ END netmiko_send_command ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Working with main tasks

Sending a command to all the devices in the inventory is nice for starters. But how can we create something more involved? And what is going on here?

In this section, I am breaking down the example script found here.

This script will start of initializing Nornir and loading the inventory. After this, the script will execute the task called main_task against every device in the inventory. The main task will execute 7 subtasks:

Nornir tasks

Organizing tasks in this way let’s you create and manage bigger programs. Note that this is not the way you HAVE to do things in Nornir, it is just a way in which you COULD choose to do things in Nornir.

The first 4 subtasks should help you understand how Nornir does something. The subtasks log something, take arguments and inspect the task and the result objects.

The last three subtasks illustrate how to perform various operations using the NAPALM plugin. We will send a command, use a NAPALM getter and finish up configuring a device.

Since we are now using NAPALM instead of Netmiko, we also need to change the host file we are using. Netmiko uses SSH to connect to devices. When NAPALM is used to communicate with an Arista, the eAPI is used. For this reason, we change the group the hosts are placed in from arista to arista_eapi:

veos01:
  hostname: 10.254.169.50
  groups:
    - arista_eapi
veos02:
  hostname: 10.254.169.51
  groups:
    - arista_eapi

Initializing Nornir

The following function is used to initialize the Nornir object:

def get_nornir_cfg():
    """
    Returns the Nornir object.
    """
    InventoryPluginRegister.register("SimpleInventory", SimpleInventory)
    nr = InitNornir(
        runner={
            "plugin": "threaded",
            "options": {
                "num_workers": 100,
            },
        },
        inventory={
            "plugin": "SimpleInventory",
            "options": {
                    "host_file": "/inventory/hosts.yaml",
                    "group_file": "/inventory/groups.yaml",
                    "defaults_file": "/inventory/defaults.yaml"
            },
        }
    )
    return nr

The object that is returned can be used to run a task against the hosts defined in the inventory. The task will be threaded using 100 workers.

The following shows how we can first construct the nr object and then run a task called main_task against all the devices. We finish up printing the result of the task using print_result

if __name__ == "__main__":

    
    nr = get_nornir_cfg()

    result = nr.run(
        name="Task example and explanation function.",
        task=main_task,
        example_arg_1="arg_1",
        example_arg_2="arg_2",
        template_string=template_string,
        dry_run=True
    )

    print_result(result)

Tasks and subtasks

The previous code runs the main_task against all the hosts in the inventory. In the example script, the main task is defined as follows:

def main_task(task: Task, example_arg_1, example_arg_2, template_string, dry_run=True) -> Result:
    """
    This is the main task or function of the program. 
    
    We will call several sub tasks from this task.
    """
    
    task.run(
        name="Logging example.",
        task=log_something,
    )
    
    task.run(
        name="example_task",
        task=example_task,
        example_arg_1=example_arg_1,
        example_arg_2=example_arg_2,
    )
    
    result_to_examine = task.run(
        name="examine task",
        task=examine_task,
    )

    task.run(
        name="examine result",
        task=examine_result,
        result = result_to_examine        
    )    
    
    task.run(
        name="example command using NAPALM",
        task=example_command_using_napalm,
    )
    
    task.run(
        name="example using NAPALM getters",
        task=example_napalm_getters,
    )

    task.run(
        name="example configuration using NAPALM",
        task=example_napalm_configure,        
        template_string=template_string,        
        dry_run=dry_run,
    )

    return Result(
        host=task.host,
        result="Example task finished!",
    )

The main task shown here groups the subtasks together and will run them one after the other.

Illustration tasks

The first subtask that is called in the main task will log a message:

logger = logging.getLogger('nornir')


def log_something(task: Task,) -> Result:
    """
    Nornir will log to 'nornir.log' by default.
    """    
    logger.info(f"{task.host.name} says hi!")
    logger.warning(f"Warning {task.host.name} is running task {task.name}")    
    return Result(
        host=task.host,
        result=f"Task {task.name} made some log updates"
    )

The first line grabs the default logger used in Nornir. You can use this to output messages to the nornir.log file that is used by default. Next is the function that defines the subtask. The function logs 2 messages and then returns a result. When we run the example script, we will see the following appear on screen for every host:

---- Logging example. ** changed : False --------------------------------------- INFO
Task Logging example. made some log updates

When we inspect the nornir.log, we can see veos01 logged the following:

2020-11-28 20:10:03,769 -       nornir -     INFO - log_something() - veos01 sayd hi
2020-11-28 20:10:03,770 -       nornir -  WARNING - log_something() - Warning 'veos01' is running task 'Logging example.'

The following subtasks run after the logging task:

  • illustrate passing additional arguments to a subtask
  • inspect the task object
  • inspect the result object
def example_task(task: Task, example_arg_1, example_arg_2) -> Result:
    """
    This is an example sub-task that can be used in a Nornir main task.

    Purpose of this task it to return the example arguments.
    """
    task_result = f"Got the following:\nexample_arg_1: {example_arg_1}\nexample_arg_2: {example_arg_2}"
    return Result(
        host=task.host,
        result=task_result
    )


def examine_task(task: Task,) -> Result:
    """
    This task will return information that describes the task object.
    """
    ret_dict ={
        "task" : task,
        "task name" : task.name,
        "host dict" : task.host.dict(),
        "groups content" : task.host.groups[0].dict(),
        "default content" : task.host.defaults.dict(),
        "task methods" : dir(task),
        "host methods" : dir(task.host),
        "task type" : type(task),        
    }
    # to have a detailed look around, consider setting a trace by removing next line's comment:
    #import pdb; pdb.set_trace()
    return Result(
        host=task.host,
        result=ret_dict
    )


def examine_result(task: Task, result) -> Result:
    """
    This task will return information describing the result object.
    """
    ret_dict ={
        "result" : result,
        "result methods" : dir(result),
        "task type" : type(result),        
    }
    # to have a detailed look around, consider setting a trace by removing next line's comment:
    #import pdb; pdb.set_trace()
    return Result(
        host=task.host,
        result=ret_dict
    )

Since the output for these tasks is quite lengthy, I pasted the output here. A thing to point out in this last function is the way in which you can troubleshoot scripts that use Nornir. You can insert import pdb; pdb.set_trace() at any point in the code and have a look around to see what is happening.

NAPALM tasks

NAPALM logo

There are plugins available to Nornir that come with tasks that allow you to interface with a library that is not part of the Nornir core repo. This example script uses the several of the NAPALM tasks that nornir_napalm provides. The Nornir NAPALM repo can be found here.

As mentioned earlier, plugins are installed separately. To install the NAPALM plugin, we do the following:

pip install nornir_napalm

After this, you will be able to import the tasks into your scripts:

from nornir_napalm.plugins.tasks import napalm_configure, napalm_cli, napalm_get

The example script has three subtask that use the NAPALM plugin. The first subtask that uses the plugin is calling napalm_cli to send a few commands to a device:

def example_command_using_napalm(task: Task,) -> Result:
    """
    Send commands using napalm_cli
    """
    cmd_ret = task.run(
        task=napalm_cli, commands=[
            'show version',
            'show hostname',
            ],
    )

    return Result(
        host=task.host,
        result=cmd_ret
    )

The previous subtask will return the output of the commands:

vvvv example command using NAPALM ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
MultiResult: [Result: "napalm_cli"]
---- napalm_cli ** changed : False --------------------------------------------- INFO
{ 'show hostname': 'Hostname: veos01\nFQDN:     veos01\n',
  'show version': ' vEOS\n'
                  'Hardware version:      \n'
                  'Serial number:         \n'
                  'Hardware MAC address:  444c.a809.182b\n'
                  'System MAC address:    444c.a809.182b\n'
                  '\n'
                  'Software image version: 4.24.1.1F\n'
                  'Architecture:           i686\n'
                  'Internal build version: 4.24.1.1F-17172302.42411F\n'
                  'Internal build ID:      '
                  'cf3ba327-e192-46b4-8e8e-4aeaee798dd2\n'
                  '\n'
                  'Uptime:                 12 weeks, 4 days, 16 hours and 41 '
                  'minutes\n'
                  'Total memory:           1872580 kB\n'
                  'Free memory:            1072492 kB\n'
                  '\n'}
^^^^ END example command using NAPALM ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

The main task then continues to run a subtask that let’s you access the NAPALM getters:

def example_napalm_getters(task: Task,) -> Result:
    """
    Example on how to use the NAPALM getters.

    There are a lot more getters, find them here:
        https://napalm.readthedocs.io/en/latest/support/
    """
    napalm_getters = task.run(
        task = napalm_get, getters=["get_facts"],
        )
    
    return Result(
        host=task.host,
        result=napalm_getters
    )

The getters you are interested are put in a list and then passed to the napalm_get task. In this example, we are retrieving the facts:

vvvv example using NAPALM getters ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
MultiResult: [Result: "napalm_get"]
---- napalm_get ** changed : False --------------------------------------------- INFO
{ 'get_facts': { 'fqdn': 'veos01',
                 'hostname': 'veos01',
                 'interface_list': [ 'Ethernet1',
                                     'Loopback0',
                                     'Management1',
                                     'Port-Channel1000',
                                     'Vlan4094'],
                 'model': 'vEOS',
                 'os_version': '4.24.1.1F-17172302.42411F',
                 'serial_number': '',
                 'uptime': 7660573,
                 'vendor': 'Arista'}}
^^^^ END example using NAPALM getters ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

The final example details how you can use the napalm_configure plugin:

template_string = """
interface loopback 0
description Nornir
"""

def example_napalm_configure(task: Task, template_string, dry_run=True) -> Result:
    """
    Example on how to use the NAPALM to configure devices.
        
    If dry_run is set to True, this task will only retrieve a diff.
    """
    
    napalm_ret = task.run(
        task=napalm_configure,
        dry_run=dry_run,
        configuration=template_string,
    )
    return Result(
        host=task.host,
        result=napalm_ret
    )

The task was passed a string in the main task. And because we set dry_run to True, we are not committing the configuration. We enter configure sessions, obtain a diff and perform a rollback. The task result will tell us whether applying the configuration will result in a change and what the actual change would look like. In case there is a diff, it is shown as follows:

vvvv example configuration using NAPALM ** changed : False vvvvvvvvvvvvvvvvvvvvv INFO
MultiResult: [Result: "napalm_configure"]
---- napalm_configure ** changed : True ---------------------------------------- INFO
@@ -41,6 +41,7 @@
    channel-group 1000 mode active
 !
 interface Loopback0
+   description Nornir
    ip address 5.5.5.5/32
 !
 interface Management1
^^^^ END example configuration using NAPALM ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

The + or - characters indicated what lines will be added/removed from the configuration.

Working with the result

Initially, the result left me a bit puzzled. I did not really understand the AggregatedResult and MultiResult you end up with after nr.run:

    result = nr.run(
        name="Task example and explanation function.",
        task=main_task,
        example_arg_1="arg_1",
        example_arg_2="arg_2",
        template_string=template_string,
        dry_run=True
    )

To better understand the result, I ran the script with python -i. This will enter interactive mode after executing the script. As we can see, the result is a dict-like object that shows us the results of all the hosts we ran the task against:

>>> result
AggregatedResult (Task example and explanation function.): {'veos01': MultiResult: [Result: "Task example and explanation function.", Result: "Logging example.", Result: "example_task", Result: "examine task", Result: "examin result", MultiResult: [Result: "example command using NAPALM", Result: "napalm_cli"], MultiResult: [Result: "example using NAPALM getters", Result: "napalm_get"], MultiResult: [Result: "example configuration using NAPALM", Result: "napalm_configure"]], 'veos02': MultiResult: [Result: "Task example and explanation function.", Result: "Logging example.", Result: "example_task", Result: "examine task", Result: "examin result", MultiResult: [Result: "example command using NAPALM", Result: "napalm_cli"], MultiResult: [Result: "example using NAPALM getters", Result: "napalm_get"], MultiResult: [Result: "example configuration using NAPALM", Result: "napalm_configure"]]}
>>> 

Inside the AggregatedResult, we can find the results for all the individual hosts. To check what hosts we have a result for, we can look at the keys of the result:

>>> result.keys()
dict_keys(['veos01', 'veos02'])
>>>

Supplying the hostname as key will lead to the result of an individual host. The result for an individual host is called Multiresult. This multiresult contains a list of the results for every subtask that was executed for the host.

>>> type(result['veos01'])
nornir.core.task.MultiResult
>>> result['veos01']      
MultiResult: [Result: "Task example and explanation function.", Result: "Logging example.", Result: "example_task", Result: "examine task", Result: "examin result", MultiResult: [Result: "example command using NAPALM", Result: "napalm_cli"], MultiResult: [Result: "example using NAPALM getters", Result: "napalm_get"], MultiResult: [Result: "example configuration using NAPALM", Result: "napalm_configure"]]
>>>

We can access the result of a subtask by entering the index number. In the following example, we access the logging subtask and the NAPALM configure subtask:

>>> result['veos01'][1]
Result: "Logging example."
>>> result['veos01'][7]
MultiResult: [Result: "example configuration using NAPALM", Result: "napalm_configure"]
>>> 

For the logging task, we can see a result. For the configuration task, we can see a (nested) Multiresult.

With individual tasks, we can retrieve the parameters associated with the result of a task like this:

>>> result['veos01'][1].result
'Task Logging example. some log updates'
>>> result['veos01'][1].failed
False

Accessing the nested Multiresult for the configuration task would be done using result[‘veos02’][7].result[0].diff.

The task documentation should reflect what you can expect a task to return. In the above example, we could see the string that was returned in our example task. When we check the NAPALM plugin documentation for the configure function, we can see that it returns the following:

    Returns:
        Result object with the following attributes set:
          * changed (``bool``): whether the task is changing the system or not
          * diff (``string``): change in the system

This means that we should be able to retrieve these two values:

>>> result['veos01'][7].result[0].diff  
'@@ -41,6 +41,7 @@\n    channel-group 1000 mode active\n !\n interface Loopback0\n+   description Nornir\n    ip address 5.5.5.5/32\n !\n interface Management1'
>>> 
>>> result['veos01'][7].result[0].changed
True

The description for the return for the NAPALM plugin was found here.

Last thing to point out is that, using the Aggregated result, you can quickly see what hosts failed at a task:

>>> result.failed_hosts
{}
>>> 

Any failed hosts will show up in that dictionary. To see if any host failed at a task, you could also use the following:

>>> result['veos01'].failed
False
>>> 

Final thoughts

Developing automations with Nornir is a lot of fun. What I like about it is the fact that it is so minimal and that you can things in the way you want to.

It takes care of the inventory and let’s you run tasks against devices, but it does not impose. It is lightweight, fast, and running tasks uses surprisingly little memory and CPU.

If you like working on network automation using Python, Nornir is something to consider.

I was looking for something lightweight that would just ‘get the job done’. After playing and working with Nornir for a little while, I found just that.