sdn : un applicateur qui cree, met a jour et RETIRE

Deuxieme applicateur du depot, meme contrat que celui de la frontiere. Deux
cibles : les objets de cluster par l'API Proxmox, la sortie du VRF par SSH —
`/etc/frr/frr.conf.local` n'est expose par aucune API.

Perimetre strict : seules les zones du devis et les anciens nommages listes sont
touches ; une zone inconnue est signalee et laissee intacte. Un VNet encore
branche a une VM est refuse, avec le nom des machines. L'ordre suit les
dependances : sous-reseaux, VNets, zones.

`devis_sdn.strophe_frr()` est la source unique : le devis l'affiche,
l'applicateur la compare au fichier distant.

Defaut trouve a la premiere execution : la lecture sans `sudo` echouait, un
`|| true` masquait l'echec, et un fichier present etait declare absent puis
reecrit. Trois etats distingues desormais : absent, illisible, different.

Rejeu a vide, six VRF avec leur defaut, sortie tenant fonctionnelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-06 19:30:13 -04:00
parent 917b22ccc8
commit 3ed25ff703
4 changed files with 405 additions and 5 deletions

View file

@ -2,6 +2,38 @@
## 2026-08-06 — le chemin nord-sud devient dérivable
### Un applicateur pour le SDN, et pour la sortie des VRF
`scripts/appliquer_sdn.py` — deuxième applicateur du dépôt, même contrat que celui de la
frontière : ce qui manque est créé, **ce que le devis ne demande plus est retiré**.
**Deux cibles, parce qu'elles n'ont pas la même prise.** Les objets de cluster (zone, VNets,
sous-réseaux) passent par l'API Proxmox ; la sortie du VRF est un *fichier* sur chaque nœud de
sortie, qu'aucune API n'expose — donc SSH. `make sdn-plan` lit, `make sdn-appliquer
CONFIRMER=true` agit.
**Périmètre strict.** Seules les zones nommées par le devis et celles de la liste explicite des
anciens nommages sont touchées. Une zone inconnue est signalée et **laissée intacte** : ce
dépôt n'est pas seul au monde sur ce cluster.
**Deux garde-fous sur le retrait.** Un VNet encore branché à une VM est refusé, avec le nom des
machines — on ne débranche personne par inadvertance. Et l'ordre suit les dépendances :
sous-réseaux, puis VNets, puis zones à la suppression ; l'inverse à la création. Proxmox refuse
tout autre ordre, et l'avait déjà appris à ses dépens.
**Une source unique pour la strophe.** `devis_sdn.strophe_frr()` sert à la fois au devis qui
l'affiche et à l'applicateur qui la compare au fichier distant. Deux rendus séparés auraient
fini par diverger — c'est le mode de panne que ce dépôt passe ses journées à fermer.
**Un défaut trouvé par la première exécution.** L'applicateur lisait `frr.conf.local` sans
`sudo` ; la lecture échouait, un `|| true` masquait l'échec, et un fichier **présent** était
déclaré absent — donc réécrit sans raison. Il distingue désormais « absent », « illisible » et
« différent », trois états qui appellent trois réponses.
Le rejeu ne trouve plus rien à faire, les six VRF portent leur défaut, et la sortie tenant
répond toujours.
### Trois nœuds de sortie, et le primaire enfin émis
`t17` passe à `asgard,gandalf,vishnu`, primaire `asgard``t11` l'était déjà. La strophe FRR

View file

@ -348,6 +348,18 @@ devis-reseau: ansible-runtime ## Devis switch (VLANs/SVIs/ACLs) du reseau conver
devis-opnsense: ansible-runtime ## Devis OPNsense (frontiere nord/sud), derive du registre des flux (pair: externe)
python3 scripts/devis_opnsense.py $(if $(JSON),--json,)
.PHONY: sdn-plan sdn-appliquer
sdn-plan: ansible-runtime ## Ecart entre le SDN EVPN (+ sortie des VRF) et son devis (aucune ecriture)
python3 scripts/appliquer_sdn.py
sdn-appliquer: ansible-runtime ## Reconcilie le SDN : cree ce qui manque, RETIRE ce qui est perime. CONFIRMER=true
@if [[ "$(CONFIRMER)" != "true" ]]; then \
printf '%s\n' "Refus: cette cible ECRIT sur le cluster (zones, VNets, sous-reseaux) et sur" \
"les noeuds de sortie (/etc/frr/frr.conf.local), et RETIRE ce qui est perime." \
"Relire d'abord 'make sdn-plan', puis: make sdn-appliquer CONFIRMER=true"; \
exit 2; fi
CONFIRMER=true python3 scripts/appliquer_sdn.py
.PHONY: frontiere-plan frontiere-appliquer
frontiere-plan: ansible-runtime ## Ecart entre la frontiere OPNsense et son devis (aucune ecriture)
python3 scripts/appliquer_opnsense.py

329
scripts/appliquer_sdn.py Normal file
View file

@ -0,0 +1,329 @@
#!/usr/bin/env python3
"""Reconcilie le SDN EVPN et la sortie des VRF avec le devis (`devis_sdn.py`).
DEUX SENS, comme l'applicateur de la frontiere : ce que le devis demande et qui
manque est cree ; ce qu'il ne demande plus est RETIRE. Un devis qui change sans
retrait laisse des objets orphelins, et la lecture du cluster cesse de dire la
verite.
DEUX CIBLES, parce qu'elles n'ont pas la meme prise :
- les objets de cluster (zone, VNets, sous-reseaux) : API Proxmox ;
- la sortie du VRF (`/etc/frr/frr.conf.local`) : un FICHIER sur chaque noeud de
sortie, qu'aucune API n'expose. SSH, donc.
PERIMETRE STRICT : seules les zones nommees par le devis, et celles de la liste
explicite des anciens nommages, sont touchees. Une zone inconnue est signalee et
LAISSEE INTACTE ce depot n'est pas seul au monde sur ce cluster.
NON DESTRUCTIF PAR DEFAUT : sans `CONFIRMER=true`, aucune ecriture (regle 4).
Usage :
python3 scripts/appliquer_sdn.py # plan seul
CONFIRMER=true python3 scripts/appliquer_sdn.py # applique
"""
from __future__ import annotations
import json
import os
import ssl
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
import yaml
RACINE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RACINE / "scripts"))
import devis_sdn as devis_mod # noqa: E402
CHEMIN_FRR = "/etc/frr/frr.conf.local"
def _hebergeur() -> Path:
lien = Path(os.environ.get("SETOPS_UNDERLAY") or (RACINE / "underlay.yml"))
if not lien.exists():
raise SystemExit("Aucun underlay ne designe d'hebergeur : pas de cluster a piloter.")
return lien.resolve().parent
def _voute(base: Path) -> dict:
for nom in ("principal", "production", "lab"):
p = base / "inventories" / nom / "group_vars" / "all" / "vault.yml"
if p.is_file():
r = subprocess.run(["ansible-vault", "view", str(p)], capture_output=True, text=True)
if r.returncode != 0:
raise SystemExit("Voute illisible : renseigner ANSIBLE_VAULT_PASSWORD_FILE.\n"
+ r.stderr.strip()[:300])
return yaml.safe_load(r.stdout) or {}
raise SystemExit(f"Aucune voute sous {base}/inventories/*/group_vars/all/.")
class Cluster:
"""Le minimum d'API Proxmox pour reconcilier le SDN."""
def __init__(self, hote: str, port, user: str, tid: str, secret: str):
self.base = f"https://{hote}:{port}/api2/json"
# Le jeton complet est `utilisateur@royaume!nom` : la voute ne porte que le NOM,
# l'utilisateur vit dans proxmox-hebergeur.yml. Les recomposer ici evite de
# stocker deux fois la meme identite — et le 401 muet quand elles divergent.
self.tok = f"PVEAPIToken={user}!{str(tid).split('!')[-1]}={secret}"
self.ctx = ssl._create_unverified_context()
def __call__(self, chemin: str, methode: str = "GET", corps: dict | None = None):
data = urllib.parse.urlencode(corps).encode() if corps else None
req = urllib.request.Request(self.base + chemin, data=data, method=methode,
headers={"Authorization": self.tok})
try:
with urllib.request.urlopen(req, context=self.ctx, timeout=45) as rep:
return json.loads(rep.read()).get("data")
except urllib.error.HTTPError as e:
return {"_erreur": f"{e.code} {e.read().decode()[:200]}"}
except OSError as e:
return {"_erreur": str(e)[:200]}
def _ssh(hote: str, commande: str, entree: str | None = None) -> tuple[int, str]:
"""SSH vers un noeud de sortie. La cle vient de l'agent, ou de SETOPS_SSH_KEY."""
cmd = ["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no",
"-o", "ConnectTimeout=10"]
cle = os.environ.get("SETOPS_SSH_KEY")
if cle:
cmd += ["-o", "IdentitiesOnly=yes", "-i", os.path.expanduser(cle)]
cmd += [f"{os.environ.get('SETOPS_SSH_USER', 'ansible')}@{hote}", commande]
r = subprocess.run(cmd, input=entree, capture_output=True, text=True, timeout=90)
return r.returncode, (r.stdout or "") + (r.stderr or "")
def _vm_par_vnet(api: Cluster) -> dict[str, list[str]]:
"""Quelles VM sont branchees sur quel VNet — pour ne jamais debrancher personne."""
dedans: dict[str, list[str]] = {}
for r in (api("/cluster/resources?type=vm") or []):
if not isinstance(r, dict) or not r.get("vmid"):
continue
cfg = api(f"/nodes/{r['node']}/qemu/{r['vmid']}/config")
if not isinstance(cfg, dict):
continue
for k, v in cfg.items():
if k.startswith("net") and "bridge=" in str(v):
pont = str(v).split("bridge=")[1].split(",")[0]
dedans.setdefault(pont, []).append(f"{r['vmid']} ({r.get('name', '?')})")
return dedans
def plan(api: Cluster, devis: dict) -> dict:
zones_voulues = {b["zone"]: b for b in devis["zones"]}
connues = set(zones_voulues) | set(devis.get("anciennes") or [])
zones_posees = {z["zone"]: z for z in (api("/cluster/sdn/zones") or [])
if isinstance(z, dict)}
vnets_poses = {v["vnet"]: v for v in (api("/cluster/sdn/vnets") or [])
if isinstance(v, dict)}
vnets_voulus = {v["vnet"]: (b["zone"], v)
for b in devis["zones"] for v in b["vnets"]}
# Sous-reseaux : l'identifiant Proxmox est `<zone>-<reseau>-<masque>`.
sr_poses = {}
for nom, v in vnets_poses.items():
if v.get("zone") not in connues:
continue
for s in (api(f"/cluster/sdn/vnets/{nom}/subnets") or []):
if isinstance(s, dict):
sr_poses[(nom, s.get("cidr"))] = s
sr_voulus = {(v["vnet"], v["sous_reseau"]): (b["zone"], v)
for b in devis["zones"] for v in b["vnets"]}
def zone_differe(nom, b):
z = zones_posees[nom]
attendu = {
"controller": devis["controleur"],
"exitnodes": ",".join(sorted(devis["noeuds_de_sortie"])),
"exitnodes-primary": devis.get("sortie_primaire") or "",
"mtu": str(b["mtu"]),
"vrf-vxlan": str(b["vrf_vxlan"]),
}
reel = {
"controller": str(z.get("controller") or ""),
"exitnodes": ",".join(sorted(str(z.get("exitnodes") or "").split(","))).strip(","),
"exitnodes-primary": str(z.get("exitnodes-primary") or ""),
"mtu": str(z.get("mtu") or ""),
"vrf-vxlan": str(z.get("vrf-vxlan") or ""),
}
return {k: (reel[k], attendu[k]) for k in attendu if reel[k] != attendu[k]}
# La strophe FRR, comparee sur chaque noeud de sortie.
voulue = devis_mod.strophe_frr(devis)
frr = {}
for n in devis.get("noeuds_de_sortie") or []:
# `sudo` : le fichier appartient a root. Sans lui, la lecture echoue et un
# fichier PRESENT serait declare absent — puis reecrit sans raison.
# `test -e` d'abord, pour distinguer « absent » de « illisible ».
rc, _ = _ssh(n, "true")
if rc != 0:
frr[n] = {"present": None, "joignable": False, "conforme": False}
continue
rc_e, _ = _ssh(n, f"sudo test -e {CHEMIN_FRR}")
contenu = ""
if rc_e == 0:
rc_c, contenu = _ssh(n, f"sudo cat {CHEMIN_FRR}")
if rc_c != 0:
frr[n] = {"present": None, "joignable": False, "conforme": False,
"illisible": True}
continue
frr[n] = {"present": contenu, "joignable": True, "conforme": contenu == voulue}
return {
"zones_creer": {n: b for n, b in zones_voulues.items() if n not in zones_posees},
"zones_majer": {n: zone_differe(n, b) for n, b in zones_voulues.items()
if n in zones_posees and zone_differe(n, b)},
"zones_retirer": {n: z for n, z in zones_posees.items()
if n in (devis.get("anciennes") or []) and n not in zones_voulues},
"zones_etrangeres": sorted(n for n in zones_posees if n not in connues),
"vnets_creer": {n: v for n, v in vnets_voulus.items() if n not in vnets_poses},
"vnets_retirer": {n: v for n, v in vnets_poses.items()
if v.get("zone") in connues and n not in vnets_voulus},
"sr_creer": {k: v for k, v in sr_voulus.items() if k not in sr_poses},
"sr_retirer": {k: v for k, v in sr_poses.items() if k not in sr_voulus},
"frr": frr,
"frr_voulue": voulue,
}
def afficher(p: dict) -> bool:
for n in sorted(p["zones_creer"]):
print(f" + zone {n}")
for n, d in sorted(p["zones_majer"].items()):
for k, (avant, apres) in sorted(d.items()):
print(f" ~ zone {n:<8} {k:<18} {avant or '(vide)'} -> {apres}")
for n, (z, v) in sorted(p["vnets_creer"].items()):
print(f" + VNet {n:<10} zone={z} tag={v['tag']}")
for (vn, cidr), (z, v) in sorted(p["sr_creer"].items()):
print(f" + sous-reseau {cidr:<18} vnet={vn} passerelle={v['passerelle']}")
for k in sorted(p["sr_retirer"]):
print(f" - sous-reseau PERIME {k[1]} (vnet {k[0]})")
for n in sorted(p["vnets_retirer"]):
print(f" - VNet PERIME {n}")
for n in sorted(p["zones_retirer"]):
print(f" - zone PERIMEE {n}")
for n, e in sorted(p["frr"].items()):
if e.get("illisible"):
print(f" ! sortie du VRF {n} : {CHEMIN_FRR} ILLISIBLE (sudo ?)")
elif not e["joignable"]:
print(f" ! sortie du VRF {n} : INJOIGNABLE en SSH")
elif not e["conforme"]:
quoi = "absente" if not e["present"] else "differente"
print(f" ~ sortie du VRF {n} : strophe {quoi}")
if p["zones_etrangeres"]:
print(f"\n zone(s) hors devis, LAISSEE(S) INTACTE(S) : {', '.join(p['zones_etrangeres'])}")
creer = len(p["zones_creer"]) + len(p["vnets_creer"]) + len(p["sr_creer"])
retirer = len(p["zones_retirer"]) + len(p["vnets_retirer"]) + len(p["sr_retirer"])
frr_a_faire = sum(1 for e in p["frr"].values() if e["joignable"] and not e["conforme"])
print(f"\n a creer : {creer} | a retirer : {retirer} | a mettre a jour : "
f"{len(p['zones_majer'])} | noeuds a corriger : {frr_a_faire}")
return bool(creer or retirer or p["zones_majer"] or frr_a_faire)
def appliquer(api: Cluster, p: dict, devis: dict) -> int:
echecs = 0
def _fait(rep, quoi):
nonlocal echecs
if isinstance(rep, dict) and rep.get("_erreur"):
echecs += 1
print(f" ! ECHEC {quoi} : {rep['_erreur'][:150]}")
return False
return True
# Un VNet encore branche a une VM ne se retire pas : on debrancherait la machine.
if p["vnets_retirer"]:
occupe = _vm_par_vnet(api)
for n in list(p["vnets_retirer"]):
if occupe.get(n):
print(f" ! REFUS de retirer le VNet {n} : VM branchee(s) — "
f"{', '.join(occupe[n])}")
del p["vnets_retirer"][n]
echecs += 1
# 1. Creations, du contenant au contenu.
for n, b in sorted(p["zones_creer"].items()):
corps = {"zone": n, "type": "evpn", "controller": devis["controleur"],
"vrf-vxlan": b["vrf_vxlan"], "mtu": b["mtu"], "ipam": "pve",
"exitnodes": ",".join(devis["noeuds_de_sortie"])}
if devis.get("sortie_primaire"):
corps["exitnodes-primary"] = devis["sortie_primaire"]
_fait(api("/cluster/sdn/zones", "POST", corps), f"zone {n}")
for n, d in sorted(p["zones_majer"].items()):
_fait(api(f"/cluster/sdn/zones/{n}", "PUT",
{k: apres for k, (_, apres) in d.items() if apres}), f"maj zone {n}")
for n, (z, v) in sorted(p["vnets_creer"].items()):
_fait(api("/cluster/sdn/vnets", "POST", {"vnet": n, "zone": z, "tag": v["tag"]}),
f"vnet {n}")
for (vn, cidr), (z, v) in sorted(p["sr_creer"].items()):
_fait(api(f"/cluster/sdn/vnets/{vn}/subnets", "POST",
{"subnet": cidr, "type": "subnet", "gateway": v["passerelle"], "snat": 0}),
f"sous-reseau {cidr}")
# 2. Retraits, du contenu au contenant : Proxmox refuse l'inverse.
for (vn, cidr), s in sorted(p["sr_retirer"].items()):
_fait(api(f"/cluster/sdn/vnets/{vn}/subnets/{s['id']}", "DELETE"),
f"retrait sous-reseau {cidr}")
for n in sorted(p["vnets_retirer"]):
_fait(api(f"/cluster/sdn/vnets/{n}", "DELETE"), f"retrait vnet {n}")
for n in sorted(p["zones_retirer"]):
_fait(api(f"/cluster/sdn/zones/{n}", "DELETE"), f"retrait zone {n}")
# 3. La sortie du VRF, fichier par noeud. `cat >` et non `>>` : le fichier est
# GENERE, donc remplace — un ajout repete l'empilerait a chaque passage.
for n, e in sorted(p["frr"].items()):
if not e["joignable"] or e["conforme"]:
continue
rc, out = _ssh(n, f"sudo tee {CHEMIN_FRR} >/dev/null && sudo systemctl reload frr",
entree=p["frr_voulue"])
if rc != 0:
echecs += 1
print(f" ! ECHEC strophe sur {n} : {out.strip()[:150]}")
else:
print(f" ~ strophe posee sur {n}, FRR recharge")
if echecs:
print(f"\n {echecs} echec(s) — le SDN n'est PAS applique, la config reste en attente.")
return 1
rep = api("/cluster/sdn", "PUT")
print(" application du SDN :", "ok" if not (isinstance(rep, dict) and rep.get("_erreur"))
else rep["_erreur"][:120])
return 0
def main() -> int:
base = _hebergeur()
heb = yaml.safe_load((base / "proxmox-hebergeur.yml").read_text(encoding="utf-8")) or {}
v = _voute(base)
api = Cluster(heb.get("proxmox_api_host"), heb.get("proxmox_api_port", 8006),
heb.get("proxmox_api_user"), v["proxmox_api_token_id"],
v["proxmox_api_token_secret"])
r = subprocess.run([sys.executable, str(RACINE / "scripts" / "devis_sdn.py"), "--json"],
cwd=RACINE, capture_output=True, text=True,
env={**os.environ, "SETOPS_INSTANCE": str(base)})
if r.returncode != 0:
raise SystemExit("Le devis ne se genere pas :\n" + r.stderr.strip()[:400])
devis = json.loads(r.stdout)
print(f"Cluster {heb.get('proxmox_api_host')}{len(devis['zones'])} zone(s) au devis\n")
p = plan(api, devis)
if not afficher(p):
print("\n Le cluster dit deja ce que le devis dit. Rien a faire.")
return 0
if os.environ.get("CONFIRMER") != "true":
print("\n PLAN SEUL — aucune ecriture. Rejouer avec CONFIRMER=true pour appliquer.")
return 0
print()
return appliquer(api, p, devis)
if __name__ == "__main__":
sys.exit(main())

View file

@ -161,6 +161,34 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
}
EN_TETE_FRR = (
"! Set-OPS — sortie des tenants par la frontiere. GENERE : `make devis-sdn`.\n"
"! NE PAS EDITER A LA MAIN — `make sdn-appliquer` reecrit ce fichier.\n"
"!\n"
"! `nexthop-vrf default` emprunte UNE adresse a la table principale au lieu de\n"
"! l'importer : la route par defaut des hyperviseurs ne gouverne pas la sortie\n"
"! des tenants. `import vrf default` ferait contourner la frontiere et fuiterait\n"
"! le transport VXLAN, la gestion et les VLAN herites dans le VRF.\n"
)
def strophe_frr(devis: dict) -> str:
"""Contenu attendu de /etc/frr/frr.conf.local, ou '' s'il n'y a rien a poser.
SOURCE UNIQUE : le devis l'affiche, l'applicateur la compare au fichier distant.
Deux rendus separes finiraient par diverger c'est le mode de panne que ce
depot passe son temps a fermer.
"""
if not devis.get("frontiere") or not devis.get("noeuds_de_sortie"):
return ""
lignes = [EN_TETE_FRR.rstrip("\n")]
for b in devis["zones"]:
lignes += [f"vrf vrf_{b['zone']}",
f" ip route 0.0.0.0/0 {devis['frontiere']} nexthop-vrf default",
"exit-vrf"]
return "\n".join(lignes) + "\n"
def rendre(devis: dict) -> str:
sortie = devis["noeuds_de_sortie"]
out = [
@ -263,11 +291,10 @@ def rendre(devis: dict) -> str:
elif not sortie:
out += ["# /!\\ Aucun noeud de sortie : strophe OMISE (elle n'aurait aucun porteur)."]
else:
out += [f"# A poser sur : {', '.join(sortie)}", "cat >> /etc/frr/frr.conf.local <<'EOF'"]
for b in devis["zones"]:
out += [f"vrf vrf_{b['zone']}",
f" ip route 0.0.0.0/0 {devis['frontiere']} nexthop-vrf default",
"exit-vrf"]
out += [f"# A poser sur : {', '.join(sortie)}",
"# `make sdn-appliquer CONFIRMER=true` le fait, et le RETIRE s'il ne l'est plus.",
"cat > /etc/frr/frr.conf.local <<'EOF'"]
out += strophe_frr(devis).rstrip("\n").split("\n")
out += ["EOF", "systemctl reload frr"]
out += [
"",