Générer et pousser les configs depuis NetBox

Un inventaire lu depuis NetBox, un template Jinja2 par OS réseau, un rendu qui écrit un fichier par équipement, un push précédé d'un dry-run, et BGP établi entre le spine et ses leaves sans taper une seule adresse.

intermédiaire~45 min de manipulation
#netbox#nornir#scrapli#ansible#jinja2#bgp#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'œilDu modèle aux nœuds
Dépôt du lab
Lab ${LAB_NAME}
RESTwritesreadsSSH
NetBoxdevices · interfaces · IPs · cables · contexts
Rendurender.py
configs/spine1.cfg · leaf1.cfg · leaf2.cfg
Pushdry-run → commit
spine1 · leaf1 · leaf2eBGP · loopbacks

L'inventaire vient de NetBox, le template transforme les interfaces, adresses et numéros BGP de chaque équipement en texte de configuration sous configs/, et seulement ensuite le texte est poussé sur les trois nœuds.

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=
Vérification
$pip show nornir nornir-scrapli | grep -c '^Name:'
Retour attendu
2

L'inventaire depuis 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()]'
Vérification
$cd  && NB_TOKEN=$NETBOX_TOKEN .venv/bin/python -c 'from nornir import InitNornir; print(len(InitNornir(config_file="config.yaml").inventory.hosts))'
Retour attendu
3

Un template par OS réseau

${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 %}

Rendre

${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. Un jeton, deux noms : le plugin de Nornir veut NB_TOKEN, tout le reste de cette série utilise NETBOX_TOKEN.
  2. L'inventaire contient déjà le JSON de l'équipement ; on le recharge via pynetbox pour avoir un objet vivant avec config_context et parcourir ses relations.
  3. mgmt_only=False est toute la raison pour laquelle l'interface de management a été marquée à la page précédente : le template ne la voit jamais.
  4. Le router-id est l'adresse de loopback, même convention sur les deux plateformes.
  5. link_peers est 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.
  6. Le config context rendu de l'équipement lui-même : bgp.asn (venu du rôle ou du contexte local) et bgp.peer_group.
  7. Nornir lance render sur chaque hôte en parallèle ; severity_level=30 n'affiche que les avertissements et les échecs, un passage vert est silencieux.
$python render.py
$ls configs/
$cat configs/spine1.cfg
Vérification
$ls /configs | wc -l
Retour attendu
3
Vérification
$grep -o '10\.1\.0\.[13]' /configs/spine1.cfg | sort -u | wc -l
Retour attendu
2
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

${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

Vérifier 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
Vérification
$docker exec clab--spine1 sr_cli 'info from state network-instance default protocols bgp neighbor * session-state' | grep -c 'session-state established'
Retour attendu
2
Vérification
$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'
Retour attendu
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 -3

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

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.