NetBox connaît le lab ; les nœuds, pas encore. Cette page comble l'écart : l'outil d'automatisation lit son inventaire dans NetBox, un template par OS réseau transforme les interfaces, adresses et numéros BGP de chaque équipement en texte de configuration, le texte atterrit dans configs/ où tu peux le lire et le differ, et seulement ensuite il est poussé. À la fin, BGP est établi entre le spine et les deux leaves, les loopbacks se pinguent, et pas une adresse n'a été tapée à la main.
Avant de commencer
$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
L'inventaire depuis 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
Un template par OS réseau
{% 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 %}Rendre
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)- Un jeton, deux noms : le plugin de Nornir veut
NB_TOKEN, tout le reste de cette série utiliseNETBOX_TOKEN. - L'inventaire contient déjà le JSON de l'équipement ; on le recharge via pynetbox pour avoir un objet vivant avec
config_contextet parcourir ses relations. mgmt_only=Falseest toute la raison pour laquelle l'interface de management a été marquée à la page précédente : le template ne la voit jamais.- Le router-id est l'adresse de loopback, même convention sur les deux plateformes.
link_peersest le bout d'en face du câble. On en tire l'équipement pair (pour son AS, dans son config context rendu) et l'adresse sur l'interface paire. C'est cette boucle qui rend le template indépendant du constructeur et du rôle.- Le config context rendu de l'équipement lui-même :
bgp.asn(venu du rôle ou du contexte local) etbgp.peer_group. - Nornir lance
rendersur chaque hôte en parallèle ;severity_level=30n'affiche que les avertissements et les échecs, un passage vert est silencieux.
$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
Si le rendu échoue sur une clé manquante
Un KeyError: 'bgp' ou un config_context.bgp indéfini veut dire que l'équipement n'a pas de contexte rendu : les config contexts de la page précédente sont attachés aux rôles, vérifie le rôle de l'équipement et que les contextes sont actifs. Une interface sans link_peers, c'est un câble qui manque. Un interfaces vide veut dire que le filtre sur le tag n'a rien trouvé : tag=<V name="LAB_NAME" /> doit être le slug, pas le nom.
Pousser
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 --commitVérifier 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
Si une session reste en Active ou Connect
Par ordre de probabilité : le /31 ne pingue pas (la partie interfaces du push a échoué, regarde la sortie du push pour cet hôte) ; l'AS du pair est faux (compare configs/leaf1.cfg avec ce que le spine attend : l'AS de la leaf est dans son contexte local) ; la famille ipv4-unicast ou le groupe n'est pas en admin-state enable ; ou la session est montée mais aucune route ne passe : la politique d'export manque d'un côté. Corrige le template ou NetBox, jamais le nœud, puis rends et pousse à nouveau.
Terminé
Le lab tourne maintenant avec une configuration que personne n'a tapée : NetBox porte l'intention, les templates portent la syntaxe constructeur, configs/ porte le résultat, et le push a mis les nœuds en conformité. Change une loopback dans NetBox, relance le rendu et le push, et la fabric suit. Toute la chaîne est dans git :
$cd && git add -A && git commit -m "render and push from NetBox" && git log --oneline | head -3La dernière page fait tourner cette chaîne toute seule : un pipeline qui linte le dépôt, déploie un lab neuf, rend et pousse, teste BGP et les pings, et démonte le lab, à chaque changement.