Modéliser le lab dans NetBox

Ce qui a sa place dans une source de vérité et ce qui n'y a pas, puis un script pynetbox qui crée le site, les équipements, leurs interfaces, adresses et câbles, et un config context avec les numéros BGP. Rejouable sans risque.

intermédiaire~40 min de manipulation
#netbox#pynetbox#source-of-truth#ipam#dcim#netdevops

Pas encore validée de bout en bout — sois le premier.Signaler un problème

Brouillon — pas encore exécuté de bout en bout. Cette page est écrite mais son auteur ne l'a pas encore déroulée sur une vraie machine. Des commandes peuvent être fausses : lis avant de lancer, et dis-nous ce qui casse.

En un coup d'œilD'un script à un modèle
Dépôt du lab
NetBox
HTTPScreatesowns
seed.pypynetbox · get, then create
API REST/api/
Site & équipements · spine1 · leaf1 · leaf2
Interfaces & câbles4 per device · 2 cables

seed.py appelle l'API REST de ${NETBOX_URL} avec ton jeton et crée, une fois, le site, les équipements avec leurs interfaces, adresses et câbles, et les numéros BGP dans des config contexts. Relance-le : il retrouve tout et ne change rien.

La page précédente a tapé des adresses dans trois CLI. C'est la dernière fois : à partir d'ici, le lab est décrit dans NetBox, équipements, interfaces, câbles, adresses et numéros BGP, et tout le reste en dérive. Cette page décide de ce qui a sa place dans la source de vérité, puis écrit un script pynetbox qui crée tout ça, et que tu peux relancer demain sans dégât.

Avant de commencer

$cd
$python3 -m venv .venv
$source .venv/bin/activate
$pip install pynetbox
$export NETBOX_TOKEN= # pour ce shell seulement ; un gestionnaire de secrets pour tout ce qui dure
Vérification
$pip show pynetbox | head -1
Retour attendu
Name: pynetbox
Vérification
$curl -sf -o /dev/null -w '%{http_code}' -H "Authorization: Token $NETBOX_TOKEN" /api/dcim/sites/
Retour attendu
200

Quoi modéliser, et quoi laisser dehors

Va dans NetBoxReste dehors
Sites, équipements, rôles, types, plateformesNuméros de série des conteneurs, uptime
Interfaces, câbles entre ellesCompteurs d'interface, état des liens
Préfixes, les /31 et les loopbacks, l'IP primaireTables ARP, routes apprises
Numéros d'AS et groupes BGP (config context)État des sessions BGP
Un tag qui dit « ceci appartient au lab »La configuration rendue elle-même

Le script de seed

${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. L'URL est une constante du lab ; le jeton vient de l'environnement pour que le fichier puisse être commité.
  2. Les noms propres à l'OS, montrés plus bas. Tout ce qui suit cette ligne est indépendant du constructeur.
  3. Par équipement : rôle, son propre AS (aucun pour le spine, dont l'AS vient du rôle), IP de management, loopback prise dans l'ordre dans .
  4. Le slug de la plateforme est ce que les plugins d'inventaire de la page suivante donnent à l'outil d'automatisation comme nom de driver : nokia_srl ou arista_eos, pas un joli nom.
  5. L'IP primaire est celle à laquelle les plugins d'inventaire se connectent. Elle doit d'abord être assignée à une interface de cet équipement, d'où l'ordre.
  6. Un config context par équipement : seules les leaves en ont un, avec leur propre AS. update() est sauté quand la valeur est déjà là, le journal des changements reste silencieux au second passage.
  7. Un câble est créé une fois, vérifié depuis son côté A. Les câbles de NetBox 4 prennent des listes de terminaisons, c'est ainsi qu'on modélise les breakouts et les câbles multi-brins ; ici chaque liste a une interface.
  8. Les contextes au niveau du rôle : tous les spines partagent l'AS , toutes les leaves peerent avec le groupe spines. Le contexte rendu d'un équipement est la fusion des deux niveaux.

La ligne NOS pour ton OS réseau :

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"]}

Le lancer, deux fois

$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"
Vérification
$cd  && .venv/bin/python sot/seed.py
Retour attendu
ok: 3 devices, 2 cables, site 
Vérification
$curl -sf -H "Authorization: Token $NETBOX_TOKEN" '/api/dcim/devices/?tag=' | python3 -c 'import sys, json; print(json.load(sys.stdin)["count"])'
Retour attendu
3
S'il s'arrête en route

pynetbox remonte le message d'erreur de NetBox lui-même, qui nomme le champ. Les classiques : un 400 sur le type d'équipement veut dire que le slug existe déjà sous un autre fabricant ; un 400 sur une adresse IP veut dire qu'elle est déjà assignée ailleurs (une expérience précédente, peut-être) ; un 403 veut dire que le jeton n'a pas la permission sur ce modèle. Corrige la cause et relance : tout ce qui est déjà créé est retrouvé, pas recréé.

Vérifier le modèle

Commande sensible — exécute un script distant. Vérifie avant d'exécuter.
$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"])'
Vérification
$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"])'
Retour attendu
3
Vérification
$curl -sf -H "Authorization: Token $NETBOX_TOKEN" '/api/dcim/cables/?tag=' | python3 -c 'import sys, json; print(json.load(sys.stdin)["count"])'
Retour attendu
2
Vérification
$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"])'
Retour attendu

Terminé

NetBox contient maintenant le lab : le site , trois équipements tagués avec chacun une IP primaire, leurs interfaces, deux câbles, les /31, les loopbacks tirées de , et les numéros BGP dans des config contexts. Le script qui a construit tout ça est dans git et se relance à volonté ; à la dernière page, un pipeline de nuit fait exactement ça.

La page suivante transforme ce modèle en configuration : un inventaire lu depuis NetBox, un template par OS réseau, un rendu et un push, jusqu'à ce que BGP soit établi entre le spine et ses leaves.

Tout a fonctionné ?

Si tu as suivi cette page jusqu'au bout sur une vraie machine, dis-le. Ta validation est datée et enregistre ta stack : le prochain lecteur sur le même chemin sait que ça marche toujours.

Cette copie est en lecture seule. Pour dire que ça marche, ou que ça ne marche pas, ouvre une issue

Seuls tes choix de stack sont enregistrés, jamais tes valeurs. Le pseudo reste sur ce navigateur.