An Introduction to Nornir

Author: Kirk Byers
Date: 2022-01-19

This article was updated in January of 2022 to work properly with Nornir Version 3.

Introduction

In late 2017 and early 2018, David Barroso created a new network automation framework named Nornir.

Nornir is a Python framework that provides inventory management and concurrency. It belongs in the same category as Ansible and Salt.

When I refer to an all Python framework, I am referring to the execution environment. In other words, the language you write your main program in and what you ultimately execute.

While your main programming environment is Python, both YAML and Jinja2 are also supported. YAML is supported as an inventory plugin and also for loading external data. Similarly, Jinja2 is supported via plugins—generally for configuration templating.

Inventory System

Let's use 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, a Nornir-NetBox plugin, and a Nornir-NSOT (Network Source of Truth) plugin. 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.

We will start with a blank slate: two empty inventory files named hosts.yaml and groups.yaml.

Now, we need to figure out how these inventory files should be structured so let's look at the Nornir inventory docs and copy an example from there.

Note, I am using Nornir 3.2.0 and am using the documentation corresponding to that.

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

---
# Strings were quoted to render better on the web
host1.cmh:
  hostname: '127.0.0.1'
  port: 2201
  username: 'vagrant'
  password: 'vagrant'
  platform: 'linux'
  groups:
    - 'cmh'
  data:
    site: 'cmh'
    role: 'host'
    type: 'host'
    nested_data:
      a_dict:
        a: 1
        b: 2
      a_list: [1, 2]
      a_string: 'asdasd' 

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.

---
# Strings were quoted to render better on the web
cmh:
  data:
    asn: 65000
    vlans:
      100: 'frontend'
      200: 'backend' 

I then updated the hosts.yaml to reflect one of the devices in my environment. In this case we can eliminate the additional "data" fields as they are not needed for this basic example.

In practice you may wish to use the additional data attributes to store other useful information about a device such as an API port, management IP (if different from your hostname for example), or any number of other relevant facts.

---
rtr1:
  hostname: 'cisco1.domain.com'
  port: 22
  username: 'pyclass'
  password: 'invalid'
  platform: 'cisco_ios'
  groups:
    - 'cisco_ios' 

Similarly I updated the groups.yaml as follows:

---
cisco_ios: {} 

For now I set the 'cisco_ios' group to be a null dictionary in the groups.yaml file.


Now let's test if this inventory is properly loaded.

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

from nornir import InitNornir
nr = InitNornir() 

Now I can test this using the Python debugger:

$ python -m pdbr simple_test.py 
> /home/kbyers/ARTICLES/NORNIR/INTRO/simple_test.py(1)()
----> 1 from nornir import InitNornir
      2 nr = InitNornir()

(Pdbr) n
> /home/kbyers/ARTICLES/NORNIR/INTRO/simple_test.py(2)()
      1 from nornir import InitNornir
----> 2 nr = InitNornir()

(Pdbr) n
None
> /home/kbyers/ARTICLES/NORNIR/INTRO/simple_test.py(2)()
      1 from nornir import InitNornir
----> 2 nr = InitNornir()

(Pdbr) nr
<nornir.core.Nornir object at 0x7fb40b528130> 

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:

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

Now let's expand our inventory to three devices:

---
# hosts.yaml file
cisco3:
  hostname: 'cisco3.lasthop.io'
  groups:
    - 'cisco_ios'

sw1:
  hostname: 'arista1.lasthop.io'
  groups:
    - 'arista'

vmx1:
  hostname: 'vmx1.lasthop.io'
  groups:
    - 'juniper' 
---
# groups.yaml file
cisco_ios:
  platform: 'cisco_ios'

arista:
  platform: 'arista_eos'

juniper:
  platform: 'juniper_junos' 

I moved the 'platform' attribute to each group as I am anticipating that I will eventually have multiple devices per group and this attribute will be the same for all of them.

Similarly, I moved the 'username' and 'password' to a centralized location as these are the same for all of the devices. I did this by creating a "defaults.yaml" file.

---
# defaults.yaml file
username: 'pyclass'
password: 'invalid' 

Now let's once again execute our Python program and inspect the inventory:

from nornir import InitNornir
nr = InitNornir() 

And looking at this using a debugger:

$ python -m pdbr simple_test.py
> /home/kbyers/ARTICLES/NORNIR/INTRO/state2/simple_test.py(1)()
----> 1 from nornir import InitNornir
      2 nr = InitNornir()

(Pdbr) n
> /home/kbyers/ARTICLES/NORNIR/INTRO/state2/simple_test.py(2)()
      1 from nornir import InitNornir
----> 2 nr = InitNornir()

(Pdbr) n
None
> /home/kbyers/ARTICLES/NORNIR/INTRO/state2/simple_test.py(2)()
      1 from nornir import InitNornir
----> 2 nr = InitNornir()

(Pdbr) nr.inventory.hosts
{'cisco3': Host: cisco3, 'sw1': Host: sw1, 'vmx1': Host: vmx1}
(Pdbr) 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.


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, there are nornir-netmiko plugins that you can install and use.

nornir-netmiko Plugins

Let's use the 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 import InitNornir
from nornir_utils.plugins.functions import print_result
from nornir_netmiko import netmiko_send_command

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. Additionally, Nornir will automatically establish the Netmiko connection using information that we provided in inventory.


Now there is a bit of complexity with the 'result'.

Let's look at this in the Python debugger:

(Pdbr) result
AggregatedResult (netmiko_send_command): 
    {
        'cisco3': MultiResult: [Result: "netmiko_send_command"], 
        'sw1': MultiResult: [Result: "netmiko_send_command"], 
        'vmx1': 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:

(Pdbr) p result["cisco3"]
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:

(Pdbr) p result["cisco3"][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:

(Pdbr) print(result["cisco3"][0].result)
# I changed the formatting of this output slightly for the web
Protocol  Address       Age   Hardware Addr   Type   Interface
Internet  10.220.88.1     3   0062.ec29.70fe  ARPA   GigabitEthernet0/0/0
Internet  10.220.88.21  154   1c6a.7aaf.576c  ARPA   GigabitEthernet0/0/0
Internet  10.220.88.22    -   a093.5141.b780  ARPA   GigabitEthernet0/0/0
Internet  10.220.88.28  209   00aa.fc05.b513  ARPA   GigabitEthernet0/0/0
Internet  10.220.88.31  103   00ac.fc59.97f2  ARPA   GigabitEthernet0/0/0
Internet  10.220.88.37  183   0001.00ff.0001  ARPA   GigabitEthernet0/0/0
Internet  10.220.88.38  180   0002.00ff.0001  ARPA   GigabitEthernet0/0/0 

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 import InitNornir
from nornir_utils.plugins.functions import print_result
from nornir_netmiko import netmiko_send_command

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. Note, I cleaned up the output a little bit to make it easier to read.

$ python netmiko_arp.py 
# I changed the formatting of this output slightly for the web
netmiko_send_command****************************************
* cisco3 ** changed : False ********************************
vvvv netmiko_send_command ** changed : False vvvvvvvvvvvvvvv INFO

Protocol  Address       Age   Hardware Addr   Type   Interface
Internet  10.220.88.1     4   0062.ec29.70fe  ARPA   GigabitEthernet0/0/0
Internet  10.220.88.21  166   1c6a.7aaf.576c  ARPA   GigabitEthernet0/0/0
Internet  10.220.88.22    -   a093.5141.b780  ARPA   GigabitEthernet0/0/0
Internet  10.220.88.28  221   00aa.fc05.b513  ARPA   GigabitEthernet0/0/0
Internet  10.220.88.31  114   00ac.fc59.97f2  ARPA   GigabitEthernet0/0/0
Internet  10.220.88.37  194   0001.00ff.0001  ARPA   GigabitEthernet0/0/0
Internet  10.220.88.38  192   0002.00ff.0001  ARPA   GigabitEthernet0/0/0
^^^^ END netmiko_send_command ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* sw1 ** changed : False ***********************************
vvvv netmiko_send_command ** changed : False vvvvvvvvvvvvvvv INFO
Address         Age (min)  Hardware Addr   Interface
10.220.88.1           N/A  0062.ec29.70fe  Vlan1, Ethernet1
10.220.88.22          N/A  a093.5141.b780  Vlan1, not learned
10.220.88.29          N/A  00af.fc9a.e49e  Vlan1, not learned
10.220.88.30          N/A  00ab.fcc0.f97c  Vlan1, Ethernet1
10.220.88.31          N/A  00ac.fc59.97f2  Vlan1, not learned
10.220.88.37          N/A  0001.00ff.0001  Vlan1, not learned
10.220.88.38          N/A  0002.00ff.0001  Vlan1, not learned
^^^^ END netmiko_send_command ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* vmx1 ** changed : False **********************************
vvvv netmiko_send_command ** changed : False vvvvvvvvvvvvvvv INFO
MAC Address       Address      Name         Interface  Flags
02:00:00:00:00:10 128.0.0.16   fpc0         em1.0      none
06:ec:49:d0:bd:27 172.30.0.1   172.30.0.1   fxp0.0     none
Total entries: 2

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

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

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

For additional references see:

Nornir Tutorial

Kirk Byers

@kirkbyers

You might also be interested in: