NetBox knows the lab; the nodes do not, yet. This page closes the gap: the automation tool reads its inventory from NetBox, a template per network OS turns each device's interfaces, addresses and BGP numbers into configuration text, the text lands in configs/ where you can read and diff it, and only then is it pushed. At the end, BGP is up between the spine and both leaves and the loopbacks ping each other, and not one address was typed by hand.
Before you start
$cd && source .venv/bin/activate$pip install nornir nornir-netbox nornir-scrapli nornir-jinja2 nornir-utils scrapli-community pynetbox$mkdir -p inventory templates configs$export NETBOX_TOKEN=$pip show nornir nornir-scrapli | grep -c '^Name:'2
The inventory from NetBox
inventory: plugin: NetBoxInventory2 options: nb_url: "" filter_parameters: { tag: "" } use_platform_slug: true defaults_file: inventory/defaults.yamlrunner: plugin: threaded options: { num_workers: 3 }$export NB_TOKEN=$NETBOX_TOKEN$python -c 'from nornir import InitNornir; nr = InitNornir(config_file="config.yaml"); [print(h.name, h.hostname, h.platform) for h in nr.inventory.hosts.values()]'$cd && NB_TOKEN=$NETBOX_TOKEN .venv/bin/python -c 'from nornir import InitNornir; print(len(InitNornir(config_file="config.yaml").inventory.hosts))'3
One template per network OS
{% for i in interfaces %}set / interface {{ i.name }} admin-state enableset / interface {{ i.name }} subinterface 0 admin-state enableset / interface {{ i.name }} subinterface 0 ipv4 admin-state enableset / interface {{ i.name }} subinterface 0 ipv4 address {{ i.address }}set / network-instance default interface {{ i.name }}.0{% endfor %}set / routing-policy policy all default-action policy-result acceptset / network-instance default protocols bgp admin-state enableset / network-instance default protocols bgp autonomous-system {{ bgp.asn }}set / network-instance default protocols bgp router-id {{ router_id }}set / network-instance default protocols bgp afi-safi ipv4-unicast admin-state enableset / network-instance default protocols bgp group {{ bgp.peer_group }} admin-state enableset / network-instance default protocols bgp group {{ bgp.peer_group }} export-policy [ all ]set / network-instance default protocols bgp group {{ bgp.peer_group }} import-policy [ all ]{% for p in peers %}set / network-instance default protocols bgp neighbor {{ p.address }} admin-state enableset / network-instance default protocols bgp neighbor {{ p.address }} peer-as {{ p.asn }}set / network-instance default protocols bgp neighbor {{ p.address }} peer-group {{ bgp.peer_group }}{% endfor %}Render
import osimport pynetboxfrom nornir import InitNornirfrom nornir_jinja2.plugins.tasks import template_filefrom nornir_utils.plugins.functions import print_resultfrom nornir_utils.plugins.tasks.files import write_fileos.environ.setdefault("NB_TOKEN", os.environ["NETBOX_TOKEN"])nb = pynetbox.api("", token=os.environ["NETBOX_TOKEN"])def facts(host): dev = nb.dcim.devices.get(host.data["id"]) interfaces, peers, router_id = [], [], None for i in nb.dcim.interfaces.filter(device_id=dev.id, mgmt_only=False): ip = nb.ipam.ip_addresses.get(interface_id=i.id) if ip is None: continue loopback = i.type.value == "virtual" interfaces.append({"name": i.name, "address": ip.address, "loopback": loopback}) if loopback: router_id = ip.address.split("/")[0] for peer in i.link_peers: peer_dev = nb.dcim.devices.get(peer.device.id) peer_ip = nb.ipam.ip_addresses.get(interface_id=peer.id) peers.append({"name": peer_dev.name, "address": peer_ip.address.split("/")[0], "asn": peer_dev.config_context["bgp"]["asn"]}) return {"hostname": dev.name, "interfaces": interfaces, "peers": peers, "router_id": router_id, "bgp": dev.config_context["bgp"]}def render(task): cfg = task.run(template_file, template=f"{task.host.platform}.j2", path="templates", **facts(task.host)).result task.run(write_file, filename=f"configs/{task.host.name}.cfg", content=cfg)nr = InitNornir(config_file="config.yaml")print_result(nr.run(task=render), severity_level=30)- One token, two names: Nornir's plugin wants
NB_TOKEN, everything else on this series usesNETBOX_TOKEN. - The inventory already holds the device JSON; we fetch it again through pynetbox to get a live object with
config_contextand to walk its relations. mgmt_only=Falseis the whole reason the management interface was flagged on the previous page: the template never sees it.- The router ID is the loopback address, the same convention on both platforms.
link_peersis the far end of the cable. From it: the peer device (for its AS, out of its rendered config context) and the address on the peer interface. This is the loop that makes the template vendor- and role-agnostic.- The rendered config context of the device itself:
bgp.asn(from the role or the local context) andbgp.peer_group. - Nornir runs
renderfor every host in parallel;severity_level=30prints only warnings and failures, so a green run is silent.
$python render.py$ls configs/$cat configs/spine1.cfg$ls /configs | wc -l3
$grep -o '10\.1\.0\.[13]' /configs/spine1.cfg | sort -u | wc -l2
If the render fails on a missing key
A KeyError: 'bgp' or an undefined config_context.bgp means the device has no rendered context: the config contexts of the previous page are attached to roles, so check the device's role and that the contexts are active. An interface with no link_peers is a missing cable. An empty interfaces means the tag filter matched nothing: tag=<V name="LAB_NAME" /> must be the slug, not the name.
Push
import os, sysfrom nornir import InitNornirfrom nornir_scrapli.tasks import send_command, send_configsfrom nornir_utils.plugins.functions import print_resultos.environ.setdefault("NB_TOKEN", os.environ["NETBOX_TOKEN"])mode = sys.argv[1] if len(sys.argv) > 1 else "--dry-run"def push(task): lines = open(f"configs/{task.host.name}.cfg").read().splitlines() if mode == "--dry-run": return "\n".join(lines) if task.host.platform == "nokia_srl": tail = ["diff", "discard now"] if mode == "--diff" else ["commit now"] task.run(send_configs, configs=lines + tail) else: task.run(send_configs, configs=lines) task.run(send_command, command="write memory")print_result(InitNornir(config_file="config.yaml").run(task=push))$python push.py --dry-run$python push.py --commitVerify BGP
A:spine1# show network-instance default protocols bgp neighborA:leaf1# show network-instance default route-table ipv4-unicast summaryA:leaf1# ping -c 3 10.0.0.3 -I 10.0.0.2 network-instance default$docker exec clab--spine1 sr_cli 'info from state network-instance default protocols bgp neighbor * session-state' | grep -c 'session-state established'2
$docker exec clab--leaf1 sr_cli 'ping -c 3 10.0.0.3 -I 10.0.0.2 network-instance default' | grep -o '3 received'3 received
If a session stays in Active or Connect
In order of likelihood: the /31 does not ping (the interface part of the push failed, look at the push output for that host); the peer AS is wrong (compare configs/leaf1.cfg with what the spine expects: the leaf's AS is in its local context); the ipv4-unicast family or the group is not admin-state enable; or the session is up but no routes cross: the export policy is missing on one side. Fix the template or NetBox, never the node, then render and push again.
Done
The lab now runs a configuration nobody typed: NetBox holds the intent, the templates hold the vendor syntax, configs/ holds the result, and the push made the nodes match. Change a loopback in NetBox, run render and push again, and the fabric follows. The whole chain is in git:
$cd && git add -A && git commit -m "render and push from NetBox" && git log --oneline | head -3The last page makes that chain run by itself: a pipeline that lints the repository, deploys a fresh lab, renders and pushes, tests BGP and the pings, and tears the lab down, on every change.