Generate and push configs from NetBox

An inventory read from NetBox, one Jinja2 template per network OS, a renderer that writes one file per device, a push with a dry run first, and BGP established between the spine and its leaves without typing a single address.

intermediate~45 min hands-on
#netbox#nornir#scrapli#ansible#jinja2#bgp#netdevops

Not validated end to end yet — be the first.Report a problem

Draft — not yet run end to end. This page was written but its author has not yet run it on a real machine. Commands may be wrong: read before you run, and tell us what breaks.

The gistFrom the model to the nodes
Lab repository
Lab ${LAB_NAME}
RESTwritesreadsSSH
NetBoxdevices · interfaces · IPs · cables · contexts
Renderrender.py
configs/spine1.cfg · leaf1.cfg · leaf2.cfg
Pushdry-run → commit
spine1 · leaf1 · leaf2eBGP · loopbacks

The inventory comes from NetBox, the template turns each device's interfaces, addresses and BGP numbers into configuration text under configs/, and only then is the text pushed to the three nodes.

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=
Check
$pip show nornir nornir-scrapli | grep -c '^Name:'
Expected output
2

The inventory from NetBox

${LAB_DIR}/config.yaml
inventory:
plugin: NetBoxInventory2
options:
nb_url: ""
filter_parameters: { tag: "" }
use_platform_slug: true
defaults_file: inventory/defaults.yaml
runner:
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()]'
Check
$cd  && NB_TOKEN=$NETBOX_TOKEN .venv/bin/python -c 'from nornir import InitNornir; print(len(InitNornir(config_file="config.yaml").inventory.hosts))'
Expected output
3

One template per network OS

${LAB_DIR}/templates/nokia_srl.j2
{% for i in interfaces %}
set / interface {{ i.name }} admin-state enable
set / interface {{ i.name }} subinterface 0 admin-state enable
set / interface {{ i.name }} subinterface 0 ipv4 admin-state enable
set / 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 accept
set / network-instance default protocols bgp admin-state enable
set / 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 enable
set / network-instance default protocols bgp group {{ bgp.peer_group }} admin-state enable
set / 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 enable
set / 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

${LAB_DIR}/render.py
import os
import pynetbox
from nornir import InitNornir
from nornir_jinja2.plugins.tasks import template_file
from nornir_utils.plugins.functions import print_result
from nornir_utils.plugins.tasks.files import write_file
os.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)
  1. One token, two names: Nornir's plugin wants NB_TOKEN, everything else on this series uses NETBOX_TOKEN.
  2. The inventory already holds the device JSON; we fetch it again through pynetbox to get a live object with config_context and to walk its relations.
  3. mgmt_only=False is the whole reason the management interface was flagged on the previous page: the template never sees it.
  4. The router ID is the loopback address, the same convention on both platforms.
  5. link_peers is 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.
  6. The rendered config context of the device itself: bgp.asn (from the role or the local context) and bgp.peer_group.
  7. Nornir runs render for every host in parallel; severity_level=30 prints only warnings and failures, so a green run is silent.
$python render.py
$ls configs/
$cat configs/spine1.cfg
Check
$ls /configs | wc -l
Expected output
3
Check
$grep -o '10\.1\.0\.[13]' /configs/spine1.cfg | sort -u | wc -l
Expected output
2
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

${LAB_DIR}/push.py
import os, sys
from nornir import InitNornir
from nornir_scrapli.tasks import send_command, send_configs
from nornir_utils.plugins.functions import print_result
os.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 --commit

Verify BGP

text
A:spine1# show network-instance default protocols bgp neighbor
A:leaf1# show network-instance default route-table ipv4-unicast summary
A:leaf1# ping -c 3 10.0.0.3 -I 10.0.0.2 network-instance default
Check
$docker exec clab--spine1 sr_cli 'info from state network-instance default protocols bgp neighbor * session-state' | grep -c 'session-state established'
Expected output
2
Check
$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'
Expected output
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 -3

The 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.

Did everything work?

If you followed this page to the end on a real machine, say so. Your validation is dated and records your stack, so the next reader on the same path knows it still works.

This copy is read-only. To report that it works, or that it does not, open an issue

Only your stack choices are recorded, never your values. The pseudonym stays on this browser.