Network Automation: Nornir, NAPALM & Containerlab

Network Automation: Nornir, NAPALM & Containerlab

nick hopgood
nick hopgood
07-08-2026 • 26 min read

python containerlab

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-config line, 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 refused here rather than a result, it's almost certainly eAPI rather than Nornir. NAPALM's eos driver talks to the device over eAPI (HTTPS on 443 by default), not SSH - so being able to SSH in proves nothing. Make sure management api http-commands is in your config with a no shutdown under 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 - False merges your snippet into the existing config, True wipes 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 changed from whether the diff came back empty, not from whether it committed anything - so a dry run with pending changes still says changed : 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 in then you may be interested in using the revert_in parameter 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