Model the lab in NetBox

What belongs in a source of truth and what does not, then a pynetbox script that creates the site, the devices, their interfaces, addresses and cables, and a config context with the BGP numbers. Safe to run twice.

intermediate~40 min hands-on
#netbox#pynetbox#source-of-truth#ipam#dcim#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 a script to a model
Lab repository
NetBox
HTTPScreatesowns
seed.pypynetbox · get, then create
REST API/api/
Site & devices · spine1 · leaf1 · leaf2
Interfaces & cables4 per device · 2 cables

seed.py calls the REST API of ${NETBOX_URL} with your token and creates, once, the site, the devices with their interfaces, addresses and cables, and the BGP numbers in config contexts. Run it again: it finds everything and changes nothing.

The previous page typed addresses into three CLIs. That is the last time: from here on, the lab is described in NetBox, devices, interfaces, cables, addresses and the BGP numbers, and everything else is derived from it. This page decides what belongs in the source of truth, then writes one pynetbox script that creates all of it, and that you can run again tomorrow without harm.

Before you start

$cd
$python3 -m venv .venv
$source .venv/bin/activate
$pip install pynetbox
$export NETBOX_TOKEN= # for this shell only; a secret manager for anything longer-lived
Check
$pip show pynetbox | head -1
Expected output
Name: pynetbox
Check
$curl -sf -o /dev/null -w '%{http_code}' -H "Authorization: Token $NETBOX_TOKEN" /api/dcim/sites/
Expected output
200

What to model, and what not

Belongs in NetBoxStays out
Sites, devices, roles, device types, platformsSerial numbers of containers, uptime
Interfaces, cables between themInterface counters, link state
Prefixes, the /31 and loopback addresses, the primary IPARP tables, routes learned
AS numbers and BGP groups (config context)BGP session state
A tag that says "this belongs to the lab"The rendered configuration itself

The seed script

${LAB_DIR}/sot/seed.py
import ipaddress, os, re
import pynetbox
nb = pynetbox.api("", token=os.environ["NETBOX_TOKEN"])
SITE, TAG = "", ""
LOOPBACKS = list(ipaddress.ip_network("").hosts())
MGMT_LEN = ipaddress.ip_network("").prefixlen
ASN_BASE = int("")
NOS = …
DEVICES = {"spine1": ("spine", None, "", LOOPBACKS[0]),
"leaf1": ("leaf", ASN_BASE + 1, "", LOOPBACKS[1]),
"leaf2": ("leaf", ASN_BASE + 2, "", LOOPBACKS[2])}
P = NOS["ports"]
LINKS = [("spine1", P[0], "10.1.0.0/31", "leaf1", P[0], "10.1.0.1/31"),
("spine1", P[1], "10.1.0.2/31", "leaf2", P[0], "10.1.0.3/31")]
slug = lambda s: re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
def ensure(endpoint, lookup, **fields):
obj = endpoint.get(**lookup)
return obj if obj is not None else endpoint.create(**fields)
tag = ensure(nb.extras.tags, {"slug": TAG}, name=TAG, slug=TAG)
site = ensure(nb.dcim.sites, {"slug": slug(SITE)}, name=SITE, slug=slug(SITE), status="active")
mfr = ensure(nb.dcim.manufacturers, {"slug": slug(NOS["manufacturer"])}, name=NOS["manufacturer"], slug=slug(NOS["manufacturer"]))
dtype = ensure(nb.dcim.device_types, {"slug": NOS["slug"]}, manufacturer=mfr.id, model=NOS["model"], slug=NOS["slug"])
plat = ensure(nb.dcim.platforms, {"slug": NOS["platform"][1]}, name=NOS["platform"][0], slug=NOS["platform"][1], manufacturer=mfr.id)
roles = {r: ensure(nb.dcim.device_roles, {"slug": r}, name=r, slug=r, color=c) for r, c in (("spine", "2196f3"), ("leaf", "4caf50"))}
for p in ("", "", "10.1.0.0/24"):
ensure(nb.ipam.prefixes, {"prefix": p}, prefix=p, site=site.id, status="active", tags=[tag.id])
def iface(dev, name, **extra):
return ensure(nb.dcim.interfaces, {"device_id": dev.id, "name": name}, device=dev.id, name=name, type="1000base-t", **extra)
def address(i, addr):
return ensure(nb.ipam.ip_addresses, {"address": addr}, address=addr, status="active",
assigned_object_type="dcim.interface", assigned_object_id=i.id, tags=[tag.id])
devices = {}
for name, (role, asn, mgmt, lo) in DEVICES.items():
dev = ensure(nb.dcim.devices, {"name": name, "site_id": site.id}, name=name, site=site.id, role=roles[role].id,
device_type=dtype.id, platform=plat.id, status="active", tags=[tag.id])
mgmt_ip = address(iface(dev, NOS["mgmt"], mgmt_only=True), f"{mgmt}/{MGMT_LEN}")
address(iface(dev, NOS["loopback"], type="virtual"), f"{lo}/32")
if dev.primary_ip4 is None or dev.primary_ip4.id != mgmt_ip.id:
dev.update({"primary_ip4": mgmt_ip.id})
if asn and (dev.local_context_data or {}).get("bgp", {}).get("asn") != asn:
dev.update({"local_context_data": {"bgp": {"asn": asn}}})
devices[name] = dev
for a_dev, a_port, a_addr, b_dev, b_port, b_addr in LINKS:
a, b = iface(devices[a_dev], a_port), iface(devices[b_dev], b_port)
address(a, a_addr); address(b, b_addr)
if a.cable is None:
nb.dcim.cables.create(a_terminations=[{"object_type": "dcim.interface", "object_id": a.id}],
b_terminations=[{"object_type": "dcim.interface", "object_id": b.id}], status="connected", tags=[tag.id])
ensure(nb.extras.config_contexts, {"name": "bgp-spine"}, name="bgp-spine", roles=[roles["spine"].id], data={"bgp": {"asn": ASN_BASE, "peer_group": "leaves"}})
ensure(nb.extras.config_contexts, {"name": "bgp-leaf"}, name="bgp-leaf", roles=[roles["leaf"].id], data={"bgp": {"peer_group": "spines"}})
print(f"ok: {len(devices)} devices, {len(LINKS)} cables, site {site.name}")
  1. The URL is a lab constant; the token comes from the environment so the file can be committed.
  2. The OS-specific names, shown below. Everything after this line is vendor-neutral.
  3. Per device: role, its own AS (none for the spine, whose AS comes from the role), management IP, loopback taken in order from .
  4. The platform slug is what the inventory plugins of the next page hand to the automation tool as the driver name: nokia_srl or arista_eos, not a pretty name.
  5. The primary IP is what the inventory plugins connect to. It must be assigned to an interface of that device first, hence the order.
  6. A per-device config context: only the leaves get one, with their own AS. update() is skipped when the value is already there, so the change log stays quiet on a rerun.
  7. A cable is created once, checked from its A side. NetBox 4 cables take lists of terminations, which is how breakout and multi-strand cables are modelled; here each list has one interface.
  8. Role-level contexts: every spine shares AS , every leaf peers with the group spines. The device's rendered context is the merge of both levels.

The NOS line for your network OS:

python
NOS = {"manufacturer": "Nokia", "model": "SR Linux (container)", "slug": "srlinux",
"platform": ("Nokia SR Linux", "nokia_srl"), "mgmt": "mgmt0", "loopback": "system0",
"ports": ["ethernet-1/1", "ethernet-1/2"]}

Run it, twice

$cd
$.venv/bin/python sot/seed.py
$.venv/bin/python sot/seed.py
$git add sot/seed.py && git commit -m "sot: seed NetBox with the lab"
Check
$cd  && .venv/bin/python sot/seed.py
Expected output
ok: 3 devices, 2 cables, site 
Check
$curl -sf -H "Authorization: Token $NETBOX_TOKEN" '/api/dcim/devices/?tag=' | python3 -c 'import sys, json; print(json.load(sys.stdin)["count"])'
Expected output
3
If it fails halfway

pynetbox raises with NetBox's own error message, which names the field. The usual ones: a 400 on the device type means the slug already exists under another manufacturer; a 400 on an IP address means it is already assigned elsewhere (a previous experiment, maybe); a 403 means the token lacks a permission on that model. Fix the cause and rerun: everything already created is found, not recreated.

Check the model

Sensitive command — runs a remote script. Review before running.
$api=/api
$auth="Authorization: Token $NETBOX_TOKEN"
$curl -s -H "$auth" "$api/dcim/devices/?tag=&brief=1" | python3 -m json.tool
$curl -s -H "$auth" "$api/dcim/interfaces/?device=spine1&cabled=true" | python3 -m json.tool | grep -E '"name"|"address"'
$curl -s -H "$auth" "$api/dcim/devices/?name=spine1" | python3 -c 'import sys, json; print(json.load(sys.stdin)["results"][0]["config_context"])'
Check
$curl -sf -H "Authorization: Token $NETBOX_TOKEN" '/api/dcim/devices/?tag=&has_primary_ip=true' | python3 -c 'import sys, json; print(json.load(sys.stdin)["count"])'
Expected output
3
Check
$curl -sf -H "Authorization: Token $NETBOX_TOKEN" '/api/dcim/cables/?tag=' | python3 -c 'import sys, json; print(json.load(sys.stdin)["count"])'
Expected output
2
Check
$curl -sf -H "Authorization: Token $NETBOX_TOKEN" '/api/dcim/devices/?name=spine1' | python3 -c 'import sys, json; print(json.load(sys.stdin)["results"][0]["config_context"]["bgp"]["asn"])'
Expected output

Done

NetBox now holds the lab: site , three devices tagged with a primary IP each, their interfaces, two cables, the /31s, the loopbacks from , and the BGP numbers in config contexts. The script that built it is in git and can be rerun at will; on the last page, a nightly pipeline does exactly that.

The next page turns this model into configuration: an inventory read from NetBox, one template per network OS, a renderer and a push, until BGP is established between the spine and its leaves.

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.