Hands-on Nornir: InitNornir + inventory files, fleet-wide show commands, F-object filtering, config backup with grouped subtasks, Jinja2 render + NAPALM dry-run/commit, failure handling & retries, and pytest.
Theory into muscle memory: initialize Nornir, structure an inventory, then run the everyday flows — collect, filter, template, and deploy with a dry-run. Companion to Nornir — Theory; the same SoT→render→ diff→deploy shape appears in Python Automation Workflows.
# config.yaml
inventory:
plugin: SimpleInventory
options:
host_file: "inventory/hosts.yaml"
group_file: "inventory/groups.yaml"
defaults_file: "inventory/defaults.yaml"
runner:
plugin: threaded
options: { num_workers: 20 }
# inventory/hosts.yaml
r1:
hostname: 10.0.0.1
platform: ios # netmiko/napalm driver name
groups: [edge]
data: { site: lhr, role: edge }
r2:
hostname: 10.0.0.2
platform: ios
groups: [edge]
# inventory/groups.yaml
edge:
username: admin
password: "{{ env 'NET_PASS' }}" # or inject at runtime, never commit secrets
from nornir import InitNornir
nr = InitNornir(config_file="config.yaml")
from nornir_netmiko import netmiko_send_command
from nornir_utils.plugins.functions import print_result
result = nr.run(task=netmiko_send_command, command_string="show version")
print_result(result) # per-host, structured
failed = list(nr.data.failed_hosts) # who errored
from nornir.core.filter import F
edge_lhr = nr.filter(F(groups__contains="edge") & F(site="lhr"))
edge_lhr.run(task=netmiko_send_command, command_string="show ip int brief")
from nornir.core.task import Task, Result
def backup(task: Task) -> Result:
out = task.run(task=netmiko_send_command, command_string="show run")
path = f"backups/{task.host.name}.cfg"
with open(path, "w") as fh:
fh.write(out.result)
return Result(host=task.host, result=f"saved {path}")
nr.run(task=backup)
The safe pattern: render from a template, push in merge mode, review the diff, then commit.
from nornir_jinja2.plugins.tasks import template_file
from nornir_napalm.plugins.tasks import napalm_configure
def push_ntp(task: Task, dry_run: bool = True) -> Result:
cfg = task.run(task=template_file,
template="ntp.j2", path="templates/").result
task.run(task=napalm_configure, configuration=cfg, dry_run=dry_run)
nr.run(task=push_ntp, dry_run=True) # shows the diff, changes nothing
# ... review ...
nr.run(task=push_ntp, dry_run=False) # commit
# templates/ntp.j2
{% for s in host['ntp_servers'] %}
ntp server {{ s }}
{% endfor %}
result = nr.run(task=push_ntp, dry_run=False)
if result.failed:
for host, mr in result.items():
if mr.failed:
print(host, mr.exception)
# only the failed hosts, to retry:
retry = nr.filter(filter_func=lambda h: h.name in result.failed_hosts)
try/except, add backoff, or gate on
result.changed.# tests/test_render.py — pure functions are easy to unit-test
def render_ntp(servers):
return "\n".join(f"ntp server {s}" for s in servers)
def test_render_ntp():
assert render_ntp(["10.0.0.53"]) == "ntp server 10.0.0.53"
Nornir tasks are ordinary functions, so pytest covers your logic and CI can block a bad change — the same gate you'd wire for Ansible in CI/CD.
nr.run + threaded runner)dry_run=True → diff)filter / F)