If you have been keeping an eye on LinkedIn you might have seen that OpsMill have announced they are going to be the "stewards" of Nornir. If you haven't come across Nornir yet then here's a quick run down! It's an open source Python framework used for network automation, it utilises plugins to handle specific tasks related to the device - think of config backups, config replacements, running SSH commands etc. Nornir core handles the device inventory and dispatches the work - which we call "tasks" - while the tasks themselves use the specific plugins to do the talking - in our case we are looking at napalm. It sounds kinda complicated, but let's dig into it and it should all become clear!
By the end we'll have a script that backs up the running config of every device in a group, and another that pushes changes with a proper diff and a dry-run safety catch.
Lab setup
If you read my previous containerlab post you will know I'm a fan of Containerlab for my lab work, so you won't be surprised I will be doing the same here! You can clone the repo on my GitHub or create your own topology files as you go.
I have created a new directory called clab in my project root, here I will create my topology file and the related containerlab generated topology files will be created here and I have a second directory called nornir - you guessed it, this is where I will keep all of the nornir specific scripts.
Lastly I have created a docker-compose.yml in the root of the directory, this will be used to spin up both the containerlab topology and respective container as well as the nornir container.
nornir_with_napalm SirHoppington$ tree
.
├── clab
│ ├── entrypoint.sh
│ ├── startup-configs
│ │ ├── r1-lab.cfg
│ │ └── r2-lab.cfg
│ └── topology.clab.yml
├── docker-compose.yml
├── nornir
│ ├── backup_devices.py
│ ├── config-backups
│ ├── config.yml
│ ├── configure_devices.py
│ ├── Dockerfile
│ ├── inventory
│ │ ├── defaults.yml
│ │ ├── groups.yml
│ │ └── hosts.yml
│ ├── network-changes
│ │ ├── r1-lab.cfg
│ │ └── r2-lab.cfg
│ └── requirements.txt
└── README.md
7 directories, 16 files
The docker-compose file is relatively straight forward, it has a volume mount for each of the directories above, with the nornir service having a dependency on clab - as it is useless without devices to connect to!
Notice how we have an added environment variable for containerlab, this sets the naming convention to use napalm when it generates the nornir inventory file. Without this it will fallback to using the kind for the specific node - which nornir won't recognise.
services:
clab:
image: ghcr.io/srl-labs/clab
container_name: containerlab
stdin_open: true
tty: true
privileged: true
network_mode: host
# Set the working directory
working_dir: "$PWD/clab"
environment:
CLAB_NORNIR_PLATFORM_NAME_SCHEMA: "napalm"
# volumes can be mounted from our host
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /var/run/netns:/var/run/netns
- /etc/hosts:/etc/hosts
- /var/lib/docker/containers:/var/lib/docker/containers
- ${PWD}/clab:${PWD}/clab:rw
pid: host
entrypoint: ./entrypoint.sh
command: bash
nornir-deploy:
build: nornir
container_name: nornir
network_mode: host
working_dir: /usr/src/app/nornir
volumes:
- ${PWD}/nornir:/usr/src/app/nornir:rw
depends_on:
clab:
condition: service_started
Nornir container
Before we can run this we also need a Dockerfile within the nornir directory, this is because we are opting to build our own image - as shown by the build instruction in the nornir-deploy service. The Dockerfile is simply a python base image with our requirements installed and scripts copied over:
# nornir/Dockerfile
# base
FROM python:3.11
WORKDIR /usr/src/app
# copy over the start.sh script
COPY ./ ./
# make the script executable
RUN pip3 install -r requirements.txt
ENTRYPOINT ["tail", "-f", "/dev/null"]
Then our requirements.txt file has our nornir libraries, for our example nornir is required for the nornir core framework and nornir-napalm for using the napalm plugin.
# nornir/requirements.txt
nornir==3.5.0
nornir-utils==0.2.0
nornir-napalm==0.5.0
Containerlab setup
We need a simple containerlab topology file, I've used 2x cEOS images in my example but you can opt to use any that are supported by napalm.
# topology.clab.yml
---
mgmt:
ipv4-gw: 10.2.0.1
ipv4-subnet: 10.2.0.0/24
network: custom_mgmt_net
name: nornir-napalm
topology:
kinds:
arista_ceos:
env:
SKIP_ZEROTOUCH_BARRIER_IN_SYSDBINIT: 1
image: ceos:4.34.2.1F
links:
- endpoints:
- r1-lab:eth1
- r2-lab:eth1
- endpoints:
- r1-lab:eth2
- r2-lab:eth2
nodes:
r1-lab:
kind: arista_ceos
labels:
nornir-group-1: dev
mgmt-ipv4: 10.2.0.99
startup-config: startup-configs/r1-lab.cfg
r2-lab:
kind: arista_ceos
labels:
nornir-group-1: dev
mgmt-ipv4: 10.2.0.100
startup-config: startup-configs/r2-lab.cfg
If you don't want to specify a startup config then remove the
startup-configline, otherwise it will look for the config file in the specified directory. Startup configs are optional, the default containerlab config enables management connectivity by default but check the Kind information on the containerlab docs to confirm for the platform you are using.
Lastly, to ensure our containerlab topology deploys after bringing the container up we will use an entrypoint script for the container to issue the clab deploy command:
# clab/entrypoint.sh
#!/bin/sh
clab deploy
exec "$@"
Spin it up
Great so now we should be able to spin up both our containers:
SirHoppington$ docker compose up -d --build
[+] Building 22.1s (11/11) FINISHED
[+] up 3/3
✔ Image nornir_with_napalm-nornir-deploy Built 22.2s
✔ Container containerlab Started 0.7s
✔ Container nornir Started
We can check the health of our containers by issuing docker ps -a and with a bit of luck we should see all of our containers are Up and Healthy!
SirHoppington$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS
829513b77e99 ceos:4.34.2.1F "bash -c '/mnt/flash…" About a minute ago Up About a minute clab-nornir-napalm-r1-lab
93b1c77935ad ceos:4.34.2.1F "bash -c '/mnt/flash…" About a minute ago Up About a minute clab-nornir-napalm-r2-lab
c10d20747c69 nornir_with_napalm-nornir-deploy "tail -f /dev/null t…" About a minute ago Up About a minute nornir
Now this isn't actually all that exciting, so let's check the logs to see what's happening. Because we're running Docker-Outside-Of-Docker (DooD) we can view the router containers from the host - hence being visible in the above command, we can also both login and check the logs via Docker. Let's start by checking the logs, where we should see a bunch of healthy boot messages:
SirHoppington$ docker logs -f clab-nornir-napalm-r1-lab
[ OK ] Stopped System Logging Service.
Starting System Logging Service...
[ OK ] Started System Logging Service.
Starting Nginx is a HTTP(S)server,…proxy and IMAP/POP3 proxy server...
[ OK ] Started Nginx is a HTTP(S)server, …e proxy and IMAP/POP3 proxy server.
[109035.206547] Aaa.sh[616]: Completing EOS initialization: [ OK ]
[ OK ] Finished EOS Warmup Service.
Starting This init script starts t… system and hardware properties....
[109035.298799] EosStage3[1291]: Model and Serial Number: unknown
[109035.298858] EosStage3[1291]: System RAM: 32883580 kB
[109035.298877] EosStage3[1291]: Flash Memory size: 461G
[ OK ] Finished This init script starts t…in system and hardware properties..
[ OK ] Reached target Multi-User System.
[ OK ] Reached target Graphical Interface.
[ OK ] Started Service to capture snapshot of systemd journal at boot.
Starting Record Runlevel Change in UTMP...
[ OK ] Finished Record Runlevel Change in UTMP.
Aug 5 15:26:14 r1-lab Stp: %SPANTREE-6-STABLE_CHANGE: Stp state is now stable
Aug 5 15:26:42 r1-lab SystemInitMonitor: %SYS-5-SYSTEM_INITIALIZED: System is initialized
Aug 5 15:26:42 r1-lab SuperServer: %SYS-5-CLI_SCHEDULER_ENABLED: CliScheduler is enabled, continuing its execution of scheduled CLI jobs.
Aug 5 15:26:42 r1-lab ProcLauncher-1: %LAUNCHER-6-PROCESS_STOP: Configuring process 'SystemInitMonitor' to stop in role 'ActiveSupervisor'
Aug 5 15:26:42 r1-lab ProcMgr: %PROCMGR-6-COMMAND_RECEIVED: ProcMgr has received 'warm start' command
Aug 5 15:26:42 r1-lab ProcMgr: %PROCMGR-6-WORKER_WARMSTART: ProcMgr worker warm start. (PID=559)
Aug 5 15:26:42 r1-lab ProcMgr: %PROCMGR-6-TERMINATE_RUNNING_PROCESS: Terminating deconfigured/reconfigured process 'SystemInitMonitor' (PID=846)
Aug 5 15:26:42 r1-lab ProcMgr: %PROCMGR-6-PROCESS_TERMINATED: 'SystemInitMonitor' (PID=846, status=-9) has terminated.
Aug 5 15:27:46 r1-lab SuperServer: %SYS-5-SYSTEM_RESTARTED: System restarted
Aug 5 15:27:46 r1-lab SuperServer: %SYS-6-SYSTEM_INFO: Software image version: 4.34.2.1F-43860280.43421F (engineering build)
Aug 5 15:27:46 r1-lab SuperServer: %SYS-6-SYSTEM_INFO: Model: cEOSLab
Aug 5 15:27:46 r1-lab SuperServer: %SYS-6-SYSTEM_INFO: Serial number: BBA96AE928B9DCFA2F5B62156CDEA2F1
Perfect, one last check before we dive into testing connectivity using Nornir - let's exec into the Nornir container and verify we can connect to each router via SSH. I've kept nornir on a separate container so we can lift it and use it elsewhere, connectivity is still via IPs defined in the Nornir inventory file so it will work with any device provided it has IP reachability.
SirHoppington$ docker exec -it nornir bash
root@orbstack:/usr/src/app/nornir# ssh admin@10.2.0.99
(admin@10.2.0.99) Password:
r1-lab>en
r1-lab#show ip int brief
Address
Interface IP Address Status Protocol MTU Owner
----------------- ------------------- ------------ -------------- ----------- -------
Ethernet1 10.0.0.1/30 up up 1500
Loopback0 10.10.10.1/32 up up 65535
Management0 10.2.0.99/24 up up 1500
So we can SSH over and my startup configuration has been applied - yeehaaa!
Nornir Inventory
If you have ever used an automation framework or IaC tool, then you will be aware that they need some form of inventory so it knows what devices it is interested in and what authentication to use. Nornir is no different! However there are many ways you can approach this, we will stick to the default and "simple" one, which is using vanilla inventory files.
As we're using Containerlab for our devices we can opt to simply use the auto-generated file which will be in the topology directory under clab/clab-nornir-napalm. Remember if you plan to use this then you need to ensure you set the environment variable in containerlab to set the platform type to match napalm!
---
r1-lab:
username: admin
password: admin
platform: eos
hostname: 10.2.0.99
groups:
- dev
r2-lab:
username: admin
password: admin
platform: eos
hostname: 10.2.0.100
groups:
- dev
Alternatively you can create your own inventory files, the common approach is to have a single file for the hosts, another for groups - which allows you to group similar attributes and assign groups to the hosts. Lastly there is a defaults file which will apply to every host but can be overwritten by more specifics in groups and then hosts.
Crucially you need a config file to tie them all together, this points nornir to the inventory and also the runner config, here is my basic config file, groups and defaults:
# config.yml
---
inventory:
plugin: SimpleInventory
options:
# replace with clab/clab-<topology-name>/nornir-simple-inventory.yml
host_file: "inventory/hosts.yml"
group_file: "inventory/groups.yml"
defaults_file: "inventory/defaults.yml"
runner:
plugin: threaded
options:
num_workers: 2
# groups.yml
---
dev:
platform: eos
# defaults.yml
---
username: admin
password: admin
You're probably getting bored and want to get into some coding, I feel you! Now we can create our script, I've called mine backup_devices.py and we will start off by importing the core nornir module. We will then import the InitNornir class and instantiate it with our config file to create our Nornir object.
from nornir import InitNornir
# Initiate Nornir object via config file
nr = InitNornir(config_file="config.yml")
After creating our Nornir object we should query our inventory, to do this I will add the F filter which allows us to filter by groups:
from nornir.core.filter import F
from nornir import InitNornir
# Initiate Nornir object via config file
nr = InitNornir(config_file="config.yml")
devices = nr.filter(F(groups__contains="dev"))
print(devices.inventory.hosts)
This will print to the console our hosts from the inventory file:
root@orbstack:/usr/src/app/nornir# python3 backup_devices.py
{'r1-lab': Host: r1-lab, 'r2-lab': Host: r2-lab}
Running your first Nornir task
Right, we have an inventory and Nornir knows about our two devices. Now we need to actually do something with them, and this is where tasks and plugins come in.
A task is just a Python function that Nornir runs against every host in your filtered inventory - in parallel, using the runner we configured earlier (2 workers in my case, so both routers at once). The plugin is what does the talking to the device. We're using napalm_get, which is NAPALM's read-only interface, and you tell it what you want using getters.
There are loads of getters - facts, interfaces, bgp_neighbors, lldp_neighbors and so on - but the one we care about for backups is config, which pulls the running, startup and candidate configs in one go.
from nornir import InitNornir
from nornir.core.filter import F
from nornir_napalm.plugins.tasks import napalm_get
nr = InitNornir(config_file="config.yml")
devices = nr.filter(F(groups__contains="dev"))
results = devices.run(task=napalm_get, getters=["config"])
print(results)
root@orbstack:/usr/src/app/nornir# python3 backup_devices.py
AggregatedResult (napalm_get): {'r1-lab': MultiResult: [Result: "napalm_get"], 'r2-lab': MultiResult: [Result: "napalm_get"]}
If you get
Connection refusedhere rather than a result, it's almost certainly eAPI rather than Nornir. NAPALM'seosdriver talks to the device over eAPI (HTTPS on 443 by default), not SSH - so being able to SSH in proves nothing. Make suremanagement api http-commandsis in your config with ano shutdownunder it.
Wait, what on earth is a MultiResult?
This bit can be slightly confusing, so let's break it down before we go any further - it'll save you a good half hour of random print statements!
What comes back from .run() is an AggregatedResult. It behaves like a dictionary keyed by hostname, which is exactly what you'd expect. But the value for each host isn't a result, it's a MultiResult, which is like a list. That's because a single task can call other tasks inside itself (we'll do exactly that later), so Nornir gives you a list of everything that ran, with the parent task at index 0.
So getting to the actual config is a three-step dig:
backup_results["r1-lab"] # MultiResult (a list)
backup_results["r1-lab"][0] # Result (the napalm_get run)
backup_results["r1-lab"][0].result # the getters dict
backup_results["r1-lab"][0].result["config"]["running"] # finally, our config!
Miss that [0] and you'll get the classic TypeError: string indices must be integers, not 'str', which if you're used to parsing JSON in Python you will be familiar with - you're trying to get an index for a list with a string instead of an integer!
The other thing worth knowing is that Nornir doesn't raise an exception when a task fails. It records the failure on the result and carries on. So if your credentials are wrong, or a device is unreachable, your script will happily finish, exit 0, and back up absolutely nothing. Which is fine right up until it's running unattended on a schedule. So if you want to catch failures you can check for failed and raise or log it as you please:
if backup_results.failed:
raise RuntimeError(f"Backup failed for: {list(backup_results.failed_hosts)}")
The catch is that .run() has already finished by this point - every reachable router was backed up fine, the configs are sat there in memory. Raising just means none of them get written to disk because one device was offline.
root@orbstack:/usr/src/app/nornir# python3 backup_devices.py
OSError: [Errno 113] No route to host
r1-lab: ConnectionError('Socket error during eAPI connection: [Errno 113] No route to host')
Traceback (most recent call last):
File "/usr/src/app/nornir/backup_devices.py", line 57, in <module>
main()
File "/usr/src/app/nornir/backup_devices.py", line 53, in main
get_all_backups("dev")
File "/usr/src/app/nornir/backup_devices.py", line 35, in get_all_backups
raise RuntimeError(f"Backup failed for: {list(backup_results.failed_hosts)}")
RuntimeError: Backup failed for: ['r1-lab']
If you want it to fail gracefully you can still catch the failed task and print the exception so we can see it fails but continue with the program:
for host, multi_result in backup_results.items():
if multi_result.failed:
print(f"{host}: {multi_result[0].exception!r}")
else:
config = multi_result[0].result["config"]["running"]
save_config_to_file(hostname=host, config=config)
If you want to play around with these errors you can simply shutdown the management interface on one of your CLAB devices so it isn't reachable from Nornir.
The full config backup script
With that out of the way, here's the finished article. Two small helper functions to handle the filesystem side, and one function to do the work:
# backup_devices.py
import os
from nornir import InitNornir
from nornir_napalm.plugins.tasks import napalm_get
from nornir.core.filter import F
backup_dir="config-backups"
# Initiate Nornir object via config file
nr = InitNornir(config_file="config.yml")
# Function to create backup directory if it doesn't already exist
def create_backups_dir(backup_dir):
os.makedirs(backup_dir, exist_ok=True)
# Function to save configuration to a txt file with hostname
def save_config_to_file(hostname, config):
create_backups_dir(backup_dir)
filename = f"{hostname}.txt"
with open(os.path.join(backup_dir, filename), "w") as f:
f.write(config)
print(f"Backed up {hostname} -> {os.path.join(backup_dir, filename)}")
# Use Napalm backup feature to retrieve backup for each device in a given group
def get_all_backups(environment):
devices = nr.filter(F(groups__contains=environment))
if not devices.inventory.hosts:
raise ValueError(f"No hosts in group '{environment}'")
backup_results = devices.run(task=napalm_get, getters=["config"])
for host, multi_result in backup_results.items():
if multi_result.failed:
print(f"{host}: {multi_result[0].exception!r}")
else:
config = multi_result[0].result["config"]["running"]
save_config_to_file(hostname=host, config=config)
def main():
create_backups_dir(backup_dir)
get_all_backups("dev")
if __name__ == "__main__":
main()
The script when called will run main() which first runs our create_backups_dir function followed by get_all_backups which takes an environment (nornir group) as an argument and filters the inventory on it and runs the task.
Let's give it a go:
root@orbstack:/usr/src/app/nornir# python3 backup_devices.py
Backed up r1-lab -> config-backups/r1-lab.txt
Backed up r2-lab -> config-backups/r2-lab.txt
root@orbstack:/usr/src/app/nornir# head -10 config-backups/r1-lab.txt
! Command: show running-config
! device: r1-lab (cEOSLab, EOS-4.34.2.1F-43860280.43421F (engineering build))
!
no aaa root
!
username admin privilege 15 role network-admin secret <redacted>
!
management api http-commands
no shutdown
!
And there we go, we have a config backup script written in Python - you can throw it into a cron job or a CI workflow, commit the output to a Git repo, and you've got yourself config versioning with a full diff history for free. Not too shabby for an afternoon's work!
Pushing configuration with napalm_configure
Reading configs is only half the story though, let's do the cool bit and actually make some changes.
NAPALM handles this with the napalm_configure task, which we import from the napalm plugin in the same way we imported the napalm_get task:
from nornir_napalm.plugins.tasks import napalm_configure
There are two parameters worth understanding before you point this at anything you care about:
replace-Falsemerges your snippet into the existing config,Truewipes the config and replaces it, also known as "full config replacement". Merge is what we will be doing here, replace is great if you are confident that your automated config is always 100% accurate and no ad-hoc changes are expected on the network - I'm looking at you Ops engineers!dry_run- generates the diff and shows you what would change, then discards it. NAPALM does this natively using the device's config sessions, so you get a proper diff from the device itself rather than something Python has tried to work out.
I've wired dry_run up to Python's builtin argparse module so it defaults to safe, and you have to explicitly ask for it to touch the devices.
import argparse
# Create config parser to set dry_run when running script
parser = argparse.ArgumentParser()
parser.add_argument(
"--dry_run", dest="dry", action="store_true", help="Will not run on devices"
)
parser.add_argument(
"--no_dry_run", dest="dry", action="store_false", help="Will run on devices"
)
parser.set_defaults(dry=True)
args = parser.parse_args()
Now with napalm_configure we can either use a configuration or a filename parameter, I prefer to use a filename so we can version control our config changes in device specific files - or whatever method you prefer. The configuration option lets you reference a multi-line string variable like this:
my_change="""
hostname "r1-new-name"
"""
task.run(
name=f"Configuring {task.host.name}!",
task=napalm_configure,
configuration=my_change,
dry_run=args.dry,
replace=False
)
Using a Python f-string you can format the filename using variables, in my example I've used the hostname like so:
device = task.host.name
task.run(
name=f"Configuring {task.host.name}!",
task=napalm_configure,
filename=f"{new_config}/{device}.cfg",
dry_run=args.dry,
replace=False
)
The full change script
The full script is fairly straightforward, if we remove the argparse code it's actually super short, we are again creating our Nornir object by passing into the inventory, we then fire a simple function called deploy_network() which sends the config to the device:
# configure_devices.py
import argparse
from nornir import InitNornir
from nornir.core.filter import F
from nornir.core.exceptions import NornirExecutionError
from nornir_napalm.plugins.tasks import napalm_configure
from nornir_utils.plugins.functions import print_result
new_config = "network-changes"
# Create config parser to set dry_run when running script
parser = argparse.ArgumentParser()
parser.add_argument(
"--dry_run", dest="dry", action="store_true", help="Will not run on devices"
)
parser.add_argument(
"--no_dry_run", dest="dry", action="store_false", help="Will run on devices"
)
parser.set_defaults(dry=True)
args = parser.parse_args()
nr = InitNornir(config_file="config.yml")
# Deploy network configuration task to hosts
def deploy_network(task):
"""Configures network with NAPALM"""
device = task.host.name
print(f"Deploying to Device: {device}")
task.run(
name=f"Configuring {device}!",
task=napalm_configure,
filename=f"{new_config}/{device}.cfg",
dry_run=args.dry,
replace=False
)
def main():
devices = nr.filter(F(groups__contains="dev"))
result = devices.run(task=deploy_network)
print_result(result)
if result.failed:
raise NornirExecutionError(result)
if __name__ == "__main__":
main()
Each device gets its own change file in the network-changes directory, named after the host. Here's network-changes/r1-lab.cfg, adding a second Ethernet interface:
interface Ethernet2
description customer-port-1
no switchport
ip address 192.168.0.1/24
Run it without any arguments and you get the dry run, and this is where print_result earns its keep - it prints the diff the device generated:
root@orbstack:/usr/src/app/nornir# python3 configure_devices.py
Deploying to Device: r1-lab
Deploying to Device: r2-lab
deploy_network******************************************************************
* r1-lab ** changed : True *****************************************************
vvvv deploy_network ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
---- Configuring r1-lab! ** changed : True ------------------------------------- INFO
interface Ethernet2
+ description customer-port-1
+ no switchport
+ ip address 192.168.0.1/24
^^^^ END deploy_network ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* r2-lab ** changed : True *****************************************************
vvvv deploy_network ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
---- Configuring r2-lab! ** changed : True ------------------------------------- INFO
interface Ethernet2
+ description customer-port-2
+ no switchport
+ ip address 192.168.1.1/24
^^^^ END deploy_network ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The changed part of the output can be a bit confusing because at first glance those three lines look like they contradict each other. Read them as a hierarchy:
* r1-lab ** changed : True <- the host: did anything below it change?
vvvv deploy_network ** changed : False <- our wrapper function, which touches nothing itself
---- Configuring r1-lab! ** changed : True <- the napalm_configure plugin, the one actually doing the work
Remember the MultiResult from earlier? This is that same structure, just printed nicely. deploy_network is the parent at index 0 - all it does is call another task, so it has nothing of its own to report and will always say False, dry run or not. The line that matters is Configuring r1-lab!, and the host-level line at the top rolls up whether any of its children changed.
Note NAPALM works out
changedfrom whether the diff came back empty, not from whether it committed anything - so a dry run with pending changes still sayschanged : True. It's telling you "there's a delta between the box and what you've asked for", not "I've applied it". You can double check this by re-running the script and you will get the same diff.
Let's also see what error handling we have - this is where dry runs are great! Let's change the ip address to an invalid subnet for r1:
interface Ethernet2
description customer-port-1
no switchport
ip address 192.168.0.1/54
When we re-run the script we can see that Nornir raised a NornirExecutionError and if we follow the traceback you can see this was caught by the Napalm plugin's MergeConfigException and right at the top the real error from pyeapi (The Python Arista eAPI module) which returned:
pyeapi.eapilib.CommandError: Error [1002]: CLI command 6 of 7 'ip address 192.168.0.1/54' failed: invalid command [errors: ["Invalid input (at token 2: '192.168.0.1/54')"]]
print_result gives us the failure inline against the host it happened on, and our raise then dumps a summary of every failed host at the end:
root@orbstack:/usr/src/app/nornir# python3 configure_devices.py
deploy_network******************************************************************
* r1-lab ** changed : False ****************************************************
vvvv deploy_network ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv ERROR
Subtask: Configuring r1-lab! (failed)
---- Configuring r1-lab! ** changed : False ------------------------------------ ERROR
Traceback (most recent call last):
<--redacted for brevity-->
napalm.base.exceptions.MergeConfigException: Error [1002]: CLI command 6 of 7 'ip address 192.168.0.1/54' failed: invalid command [errors: ["Invalid input (at token 2: '192.168.0.1/54')"]]
^^^^ END deploy_network ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* r2-lab ** changed : True *****************************************************
vvvv deploy_network ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
---- Configuring r2-lab! ** changed : True ------------------------------------- INFO
interface Ethernet2
+ description customer-port-2
+ no switchport
+ ip address 192.168.1.1/24
^^^^ END deploy_network ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Traceback (most recent call last):
File "/usr/src/app/nornir/configure_devices.py", line 50, in <module>
main()
File "/usr/src/app/nornir/configure_devices.py", line 45, in main
raise NornirExecutionError(result)
nornir.core.exceptions.NornirExecutionError:
########################################
# r1-lab (failed)
########################################
**** deploy_network
Subtask: Configuring r1-lab! (failed)
**** Configuring r1-lab!
Error [1002]: CLI command 6 of 7 'ip address 192.168.0.1/54' failed: invalid command [errors: ["Invalid input (at token 2: '192.168.0.1/54')"]]
########################################
# r2-lab (succeeded)
########################################
**** deploy_network
None
**** Configuring r2-lab!
None
Ok that's enough playing around, revert the subnet back to a valid one, then if you're happy with the diff - add the --no_dry_run argument or if you haven't added the argparse you should set the dry_run parameter to False.
root@orbstack:/usr/src/app/nornir# python3 configure_devices.py --no_dry_run
Deploying to Device: r1-lab
Deploying to Device: r2-lab
deploy_network******************************************************************
* r1-lab ** changed : True *****************************************************
vvvv deploy_network ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
---- Configuring r1-lab! ** changed : True ------------------------------------- INFO
interface Ethernet2
+ description customer-port-1
+ no switchport
+ ip address 192.168.0.1/24
^^^^ END deploy_network ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* r2-lab ** changed : True *****************************************************
vvvv deploy_network ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
---- Configuring r2-lab! ** changed : True ------------------------------------- INFO
interface Ethernet2
+ description customer-port-2
+ no switchport
+ ip address 192.168.1.1/24
^^^^ END deploy_network ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The output looks exactly the same - same diff, same changed : True. Which is precisely why you can't use that indicator to tell whether something landed. The difference shows up on the second run, which shows no changes and that our script is idempotent!
root@orbstack:/usr/src/app/nornir# python3 configure_devices.py --no_dry_run
Deploying to Device: r1-lab
Deploying to Device: r2-lab
deploy_network******************************************************************
* r1-lab ** changed : False ****************************************************
vvvv deploy_network ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
---- Configuring r1-lab! ** changed : False ------------------------------------ INFO
^^^^ END deploy_network ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* r2-lab ** changed : False ****************************************************
vvvv deploy_network ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
---- Configuring r2-lab! ** changed : False ------------------------------------ INFO
Have a look at the config if you don't believe me - I certainly didn't the first time!
r1-lab#show run int Ethernet2
interface Ethernet2
description customer-port-1
no switchport
ip address 192.168.0.1/24
If you're a fan of an automatic revert or the more nuclear
reload inthen you may be interested in using therevert_inparameter which allows you to set the amount of time to revert the commit. This can then be confirmed using napalm_confirm_commit task or napalm_rollback
Wrapping up
So where does that leave us? We've got a containerlab topology that spins up in seconds, an inventory that generates itself - also thanks to Containerlab, a script that pulls configs off every device in a chosen group, and another that pushes changes with a proper diff and a safety catch. All of it plain Python, all of it version controllable.
And honestly, this is the bit that drew me to Nornir. There's no DSL to learn (cough, cough Ansible), small amounts of YAML (but this can be replaced with plugins) - it's just pure Python, so it's easy to read and most importantly integrate. The framework handles inventory, filtering, concurrency and results, and then gets out of your way.
We have only just scraped the top of the iceberg, there is nornir-jinja2 for templating configs rather than hand-writing per-device change files, and the obvious next step is wiring this into a CI so config backups run on a schedule and changes go through a pull request rather than manual change reviews. Which is actually what my next post will be on :)
If you want to have a play, everything is on GitHub. And if you're new to containerlab, start with my previous post and come back - it'll make a lot more sense.
$ comments