Python // Misc: An Introduction to Nornir

As with all things a fundamental question is why should I care about this? For Nornir there are a few answers: 1) Nornir provides built-in concurrency so you don’t have to re-solve concurrency, 2) Nornir provides a flexible, systematic approach to inventory that can be shared across applications and team members, 3) Nornir does this entirely in a Python execution framework so you have your entire Python tool-chain easily available to you (pdb, linters, CI-CD, et cetera). 

Recently David Barroso and others have created a new network automation framework named Nornir. It was originally named Brigade which we all liked better, but we quickly ran into a naming conflict with a Kubernetes project of the same name so the new name is Nornir. Note, I have been involved with some amount of work on this project.

Nornir is a Python framework that provides inventory management and concurrency. It belongs in the same category as Ansible and Salt. I am excited about Nornir and the possibility of an all Python framework. Note, while I refer to an all Python framework, this is referring to the execution environment (i.e. what you write your program in and what you execute). Nornir’s inventory system supports a YAML based inventory format (there is some nuance in this, which I will discuss below). You can also couple Nornir with Jinja2 for configuration templating (just as you can couple Jinja2 directly with a Python program). 

Let’s start with a simple inventory. 

Now the first thing to note about Nornir is that there are two components to the inventory system. There are core inventory objects and there are plugins that feed/parse some data source to create these core inventory objects.

For example, Nornir has a SimpleInventory plugin which uses two YAML files (hosts.yaml and groups.yaml). These YAML files are parsed and used to create the core inventory objects. Similarly, there is a Nornir-Ansible inventory plugin and a Nornir-NetBox inventory plugin being worked on. You could also create your own inventory plugin.

For this article, I am just going to use the SimpleInventory plugin. Additionally, I will only have three devices in my inventory: one Cisco IOS device, one Arista EOS device, and one Juniper SRX. While this is a very simple inventory, it will illustrate many of the relevant principles. 

Let’s start with a blank slate, two empty inventory files named hosts.yaml and groups.yaml. Now, I need to figure out how these inventory files should be structured so I look at the Nornir docs and copy an example from there:

http://nornir.readthedocs.io/en/stable/tutorials/intro/inventory.html

Note, I am using Nornir 1.1.0 and am referring to documentation corresponding to that. Nornir’s inventory will be changing in a meaningful way in Nornir 2.0 so you will need to review the differences between Nornir 1.x and Nornir 2.x (once Nornir 2.x is released).

I am going to copy the below block directly from the above referenced tutorial and place it into a file named hosts.yaml.

​---
host1.cmh:
    nornir_host: 127.0.0.1
    nornir_ssh_port: 2201
    nornir_username: vagrant
    nornir_password: vagrant
    site: cmh
    role: host
    groups:
        - cmh
    nornir_nos: linux
    type: host

I will start with the following groups.yaml file which once again is copy-and-pasted from the above tutorial. I then removed a set of unused groups.

​---
defaults:
    domain: acme.local

cmh:
    asn: 65000
    vlans:
      100: frontend
      200: backend

I then updated the hosts.yaml to reflect one of the devices in my environment.

​---
rtr1:
    nornir_host: cisco1.domain.com
    nornir_ssh_port: 22
    nornir_username: admin
    nornir_password: <password>
    groups:
        - cisco_ios
    nornir_nos: cisco_ios

Similarly I updated the groups.yaml as follows:

---
defaults: {}

cisco_ios: {}

For now I just set groups.yaml to a null dictionary for both the ‘cisco_ios’ group and for the ‘defaults’. The defaults is a catch-all that applies if there is not a more specific setting at the groups or hosts level.

Now let’s see if this inventory is parsed and loaded into core Nornir inventory objects.

In order to do this, we can create a Python program that only contains:

from nornir.core import InitNornir
nr = InitNornir()

Now I can test this using the Python debugger:

$ python -m pdb simple_test.py

[Output simplified]

(Pdb) list .
  1  ->    from nornir.core import InitNornir
  2        nr = InitNornir()
[EOF]

(Pdb) n
(Pdb) n
(Pdb) p nr
<nornir.core.Nornir object at 0x7f1c928ebf28>

I can see that ‘nr’ is a Nornir object.

I can also further dig into the Nornir object and see that the inventory has been parsed:

(Pdb) p nr.inventory.hosts
{'rtr1': Host: rtr1}
(Pdb) p nr.inventory.groups
{'cisco_ios': Group: cisco_ios}
(Pdb) 

Now let’s expand our inventory to three devices:

$ cat hosts.yaml
---
rtr1:
    nornir_host: cisco1.domain.com
    groups:
        - cisco_ios

sw1:
    nornir_host: arista1.domain.com
    groups:
        - arista

srx1:
    nornir_host: srx1.domain.com
    groups:
        - juniper

$ cat groups.yaml
---
defaults:
    nornir_username: admin
    nornir_password: <password>

cisco_ios:
    nornir_nos: cisco_ios

arista:
    nornir_nos: arista_eos

juniper:
    nornir_nos: juniper_junos

I moved the ‘nornir_username’ and ‘nornir_password’ to the ‘defaults’ section as the credentials are the same for all of the devices. I also moved the nornir_nos attribute to each group as I am anticipating I will eventually have multiple devices in each group and this attribute will be the same for all of them.

Now let’s once again repeat our Python program and inspect the inventory parsing:

from nornir.core import InitNornir
nr = InitNornir()

And in PDB:

$ python -m pdb simple_test.py

[Output simplified]

(Pdb) list .
  1  ->    from nornir.core import InitNornir
  2        nr = InitNornir()
[EOF]

(Pdb) n
(Pdb) n

(Pdb) p nr.inventory.hosts
{'rtr1': Host: rtr1, 'sw1': Host: sw1, 'srx1': Host: srx1}
(Pdb) p nr.inventory.groups
{'cisco_ios': Group: cisco_ios, 'arista': Group: arista, 'juniper': Group: juniper}

We are once again parsing the inventory and now have three hosts and three different groups (in addition to the defaults).

Now we have an initial inventory setup, let’s try to do something with these devices.

In particular, let’s try to execute a Netmiko task on all three devices. Now one nice thing about Nornir is that concurrency is built-in so execution of this task will happen concurrently on all three devices. Additionally, Nornir has Netmiko plugins built into it (i.e. there is a built-in integration to Netmiko). In particular, there are the following Netmiko plugins:

https://github.com/nornir-automation/nornir/tree/master/nornir/plugins/tasks/networking

Additionally, there is a separate Netmiko connection plugin:

https://github.com/nornir-automation/nornir/tree/master/nornir/plugins/tasks/connections

Let’s use this netmiko_send_command plugin and retrieve ‘show arp’ from all three devices. Note, I intentionally chose a command that would directly work on all three platforms (IOS, EOS, Junos). Our code now looks as follows:

from nornir.core import InitNornir
from nornir.plugins.tasks.networking import netmiko_send_command
from nornir.plugins.functions.text import print_result

nr = InitNornir()

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

print_result(result)

We have our InitNornir as we previously had. We now also have an import for the ‘netmiko_send_command’ plugin and also an import or the ‘print_result’ task. I will explain ‘print_result’ and why we need it shortly.

Now, let’s look at where we actually execute our code against all of the devices in the inventory. This happens here:

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

Basically, the Nornir object (‘nr’ in this example) has a method named ‘run’ which by default will run the specified task concurrently on the hosts in the inventory. We then specify an additional argument named ‘command_string’ which is just the first argument passed into the Netmiko send_command() method (i.e. the command to be run on the remote devices).

When we execute this, the task will be executed concurrently on all three of the devices in the inventory.

Now there is a bit of complexity with the ‘result’. 

Let’s look at this in the Python debugger:

(Pdb) p result
AggregatedResult (netmiko_send_command): 
{
    'rtr1': MultiResult: [Result: "netmiko_send_command"], 
    'sw1': MultiResult: [Result: "netmiko_send_command"], 
    'srx1': MultiResult: [Result: "netmiko_send_command"]
}

We see that ‘result’ is an object of type ‘AggregatedResult’. AggregatedResult is an object that contains the results for all the hosts the task was executed on. The AggregatedResult object behaves like a dictionary so you can look at a particular host-key and see:

(Pdb) p result['rtr1']
MultiResult: [Result: "netmiko_send_command"]

This entry returns a ‘MultiResult’ object. MultiResult covers the case where a given task might have multiple sub-tasks each with their own result. In our code, we only have one task. Consequently, MultiResult only has a single entry (for each host). MultiResult behaves like a list so I can do the following:

(Pdb) p result['rtr1'][0]
Result: "netmiko_send_command"

Now finally I have the actual ‘Result’ object (i.e. the object that contains the ‘show arp’ output). For this object, I need to access the ‘result’ attribute:

(Pdb) !print(result['rtr1'][0].result)
Protocol  Address  Age (min) Hardware Addr   Type   Interface
Internet  10.220.88.1     26 0062.ec29.70fe  ARPA   FastEthernet4
Internet  10.220.88.20     - c89c.1dea.0eb6  ARPA   FastEthernet4
Internet  10.220.88.21    62 1c6a.7aaf.576c  ARPA   FastEthernet4
Internet  10.220.88.28   173 5254.aba8.9aea  ARPA   FastEthernet4
Internet  10.220.88.29    67 5254.abbe.5b7b  ARPA   FastEthernet4
Internet  10.220.88.30   138 5254.ab71.e119  ARPA   FastEthernet4
Internet  10.220.88.32   230 5254.abc7.26aa  ARPA   FastEthernet4
Internet  10.220.88.33    60 5254.ab3a.8d26  ARPA   FastEthernet4
Internet  10.220.88.35   222 5254.abfb.af12  ARPA   FastEthernet4
Internet  10.220.88.37    12 0001.00ff.0001  ARPA   FastEthernet4
Internet  10.220.88.38   203 0002.00ff.0001  ARPA   FastEthernet4
Internet  10.220.88.39     2 6464.9be8.08c8  ARPA   FastEthernet4

That was quite a bit of processing to just obtain the output, but ‘print_result’ will unwrap all of this for you automatically.

from nornir.core import InitNornir
from nornir.plugins.tasks.networking import netmiko_send_command
from nornir.plugins.functions.text import print_result

nr = InitNornir()

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

print_result(result)

Let’s see what happens when we execute this entire program:

$ date; python simple_test.py; date
Mon Aug  6 13:42:12 PDT 2018
netmiko_send_command *************************
* rtr1 ** changed : False ********************
vvvv netmiko_send_command ** changed : False vvvvvvvv INFO
Protocol Address Age (min) Hardware Addr Type Interface
Internet 10.220.88.1     1 0062.ec29.70fe  ARPA   FastEthernet4
Internet  10.220.88.20   - c89c.1dea.0eb6  ARPA   FastEthernet4
Internet  10.220.88.21  69 1c6a.7aaf.576c  ARPA   FastEthernet4
Internet  10.220.88.28 180 5254.aba8.9aea  ARPA   FastEthernet4
Internet  10.220.88.29  75 5254.abbe.5b7b  ARPA   FastEthernet4
Internet  10.220.88.30 145 5254.ab71.e119  ARPA   FastEthernet4
Internet  10.220.88.32 238 5254.abc7.26aa  ARPA   FastEthernet4
Internet  10.220.88.33  67 5254.ab3a.8d26  ARPA   FastEthernet4
Internet  10.220.88.35 230 5254.abfb.af12  ARPA   FastEthernet4
Internet  10.220.88.37  20 0001.00ff.0001  ARPA   FastEthernet4
Internet  10.220.88.38 211 0002.00ff.0001  ARPA   FastEthernet4
Internet  10.220.88.39  10 6464.9be8.08c8  ARPA   FastEthernet4
^^^^ END netmiko_send_command ^^^^^^^^^^^^^^^
* srx1 ** changed : False *******************
vvvv netmiko_send_command ** changed : False vvvvvvvvv INFO

MAC Address       Address       Name         Interface   Flags
00:62:ec:29:70:fe 10.220.88.1   10.220.88.1  vlan.0      none
c8:9c:1d:ea:0e:b6 10.220.88.20  10.220.88.20 vlan.0      none
Total entries: 2

^^^^ END netmiko_send_command ^^^^^^^^^^^^^^^^^^^
* sw1 ** changed : False ************************
vvvv netmiko_send_command ** changed : False vvvvvv INFO
Address    Age (min)  Hardware Addr   Interface
10.220.88.1        0  0062.ec29.70fe  Vlan1, Ethernet1
10.220.88.20       0  c89c.1dea.0eb6  Vlan1, not learned
10.220.88.21       0  1c6a.7aaf.576c  Vlan1, not learned
10.220.88.29       0  5254.abbe.5b7b  Vlan1, not learned
10.220.88.30       0  5254.ab71.e119  Vlan1, not learned
10.220.88.31       0  5254.ab81.5693  Vlan1, not learned
10.220.88.37       0  0001.00ff.0001  Vlan1, not learned
10.220.88.38       0  0002.00ff.0001  Vlan1, not learned
^^^^ END netmiko_send_command ^^^^^^^^^^^^^^^^^^^^^^^^^
Mon Aug  6 13:42:21 PDT 2018

As you can see, I retrieved the output from all three devices using ‘print_result’.

Additionally, I recorded the execution time and it took nine seconds to execute. It is a bit hard to see given there are only three devices, but the SSH connections are being executed concurrency.

For additional references see:

Nornir Tutorial

Exploring Nornir: The Python Automation Framework