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$pip show pynetbox | head -1Name: pynetbox
$curl -sf -o /dev/null -w '%{http_code}' -H "Authorization: Token $NETBOX_TOKEN" /api/dcim/sites/200
What to model, and what not
| Belongs in NetBox | Stays out |
|---|---|
| Sites, devices, roles, device types, platforms | Serial numbers of containers, uptime |
| Interfaces, cables between them | Interface counters, link state |
| Prefixes, the /31 and loopback addresses, the primary IP | ARP 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
import ipaddress, os, reimport pynetboxnb = pynetbox.api("", token=os.environ["NETBOX_TOKEN"])SITE, TAG = "", ""LOOPBACKS = list(ipaddress.ip_network("").hosts())MGMT_LEN = ipaddress.ip_network("").prefixlenASN_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] = devfor 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}")- The URL is a lab constant; the token comes from the environment so the file can be committed.
- The OS-specific names, shown below. Everything after this line is vendor-neutral.
- Per device: role, its own AS (none for the spine, whose AS comes from the role), management IP, loopback taken in order from
. - The platform slug is what the inventory plugins of the next page hand to the automation tool as the driver name:
nokia_srlorarista_eos, not a pretty name. - The primary IP is what the inventory plugins connect to. It must be assigned to an interface of that device first, hence the order.
- 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. - 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.
- Role-level contexts: every spine shares AS
, every leaf peers with the groupspines. The device's rendered context is the merge of both levels.
The NOS line for your network OS:
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"$cd && .venv/bin/python sot/seed.pyok: 3 devices, 2 cables, site
$curl -sf -H "Authorization: Token $NETBOX_TOKEN" '/api/dcim/devices/?tag=' | python3 -c 'import sys, json; print(json.load(sys.stdin)["count"])'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
$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"])'$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"])'3
$curl -sf -H "Authorization: Token $NETBOX_TOKEN" '/api/dcim/cables/?tag=' | python3 -c 'import sys, json; print(json.load(sys.stdin)["count"])'2
$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"])'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.