Set-OPS-Public/scripts/appliquer_sdn.py
Daniel Allaire d1db332ed3
Some checks are pending
verifier / verifier (push) Waiting to run
frontiere : lire ses reglages a la racine du depot de site
`opnsense.yml` decrivait le monde physique depuis les group_vars d'un tenant. Le devis et
le GUI le cherchent desormais d'abord a la racine du depot de site, comme underlay.yml et
proxmox-hebergeur.yml, par la meme derivation depuis le symlink.

CE QUE LE MAUVAIS RANGEMENT A COUTE : l'adresse d'API de la frontiere y etait restee a
10.0.0.1 apres migration vers 10.17.0.1. `make frontiere-appliquer` restait suspendu sur
une adresse morte, sans aucun message — trouve par l'exploitant en lancant la commande
dans son terminal, apres que j'aie moi-meme conclu deux fois a tort.

DEUX LECONS, ecrites plutot que corrigees en silence :
- un objet range chez celui qui n'en est pas responsable derive sans que personne le voie ;
- mes commandes s'executent dans ma session : l'exploitant ne voit pas leur sortie. Une
  commande lente ressemble alors a un blocage, et un blocage a une commande lente. Pour
  toute ecriture longue sur du materiel, c'est a lui de la lancer.

Les anciens emplacements restent lus : un site pas encore migre continue de fonctionner.
make verifier 41/41.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:47:32 -04:00

284 lines
12 KiB
Python

#!/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
from proxmox_api import Cluster, ssh as _ssh # noqa: E402
CHEMIN_FRR = "/etc/frr/frr.conf.local"
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:
api, base = Cluster.depuis_hebergeur()
# NE PAS FORCER SETOPS_INSTANCE VERS L'HEBERGEUR (2026-08-22). Le devis a besoin d'un
# TENANT — il lit son inventaire pour resoudre les flux — et l'hebergeur n'en est pas
# un : depuis que le monde physique a son propre depot (SITE-<nom>), il ne porte ni
# plan ni inventaire. Ce qui vient de l'hebergeur (underlay, proxmox-hebergeur, voute)
# est DERIVE du symlink `underlay.yml`, jamais de cette variable.
#
# L'hypothese « le depot de l'hebergeur est aussi une instance » n'a tenu que tant que
# les deux etaient confondus. La separation l'a revelee, ce qui est son interet.
env_devis = {k: v for k, v in os.environ.items() if k != "SETOPS_INVENTAIRE"}
r = subprocess.run([sys.executable, str(RACINE / "scripts" / "devis_sdn.py"), "--json"],
cwd=RACINE, capture_output=True, text=True, env=env_devis)
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 {api.hote} — {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())