Set-OPS-Public/scripts/appliquer_sdn.py
Daniel Allaire 4462c13b3d P03 : la preuve comparait chaque instance a l'inventaire d'UNE SEULE
Trouve en validant une mise a jour du CHANGELOG. Deux invocations de la meme preuve,
deux verdicts : `make prouver` -> NON CONFORME (« lab : 17 hotes avec ecart »),
`python3 scripts/prouver.py` -> CONFORME 37/37. Le lab n'avait aucun ecart.

DEUX VARIABLES DESIGNENT LA CIBLE, ET LA SECONDE GAGNE. Le Makefile exporte
SETOPS_INVENTAIRE (ligne 13), derive de l'instance ACTIVE ; instancier.py:68 lui fait
FORCER la cible par-dessus SETOPS_INSTANCE. P03 (prouver.py:505) ne redirigeait que
SETOPS_INSTANCE : elle generait le plan de CHAQUE instance federee et le comparait a
l'inventaire applique de la SEULE instance active.

LE ROUGE N'ETAIT PAS LE PROBLEME, LE VERT L'ETAIT. Sous `make`, l'inventaire applique
de lab et de Technolibre n'etait JAMAIS lu — l'angle meme pour lequel P03 a ete ecrite
le 2026-08-12 (un tenant qu'on ne regarde pas imposant ses vieilles adresses au pare-feu
partage). La preuve etait aveugle a son propre cas, par l'invocation documentee. Les
rapports du 13 et du 14 sortent de cette invocation-la. Signature visible sans lire le
code : les hosts.genere.yml de lab et de Technolibre ne bougeaient pas.

CORRECTIF, cinq sites : env.pop("SETOPS_INVENTAIRE") partout ou l'on redirige
SETOPS_INSTANCE — P03 et P15, plus les trois applicateurs (opnsense, proxmox_fw, sdn) qui
pointent vers l'HEBERGEUR. Ces trois sont sans effet tant qu'hebergeur et tenant actif
coincident, c'est-a-dire jusqu'au second site. Le geste existait deja (modeles.py:96).

GARDE, pour que la classe cesse d'etre silencieuse : inventory_rules.inventaire_force()
REFUSE une cible hors de l'instance visee, en nommant les deux valeurs. Eprouvee dans les
deux sens (contradiction -> code 1 ; cible legitime dans l'instance -> passe). Branchee
sur les quatre resolutions de _inventaire (instancier, serveurs, applications,
config_proxmox). Le GUI garde la sienne : il ne redirige jamais SETOPS_INSTANCE pour un
fils et resout par symlink a chaque requete.

make prouver : 37 OK, 0 echec, 0 saute — et les hosts.genere.yml des TROIS instances
portent l'horodatage du passage. make test 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:35:01 -04:00

279 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()
# `base` est l'HEBERGEUR : SETOPS_INVENTAIRE (exporte par le Makefile) gagnerait sur
# SETOPS_INSTANCE et ferait lire l'inventaire du tenant actif. Cf. prouver.py P03.
env_heb = {**os.environ, "SETOPS_INSTANCE": str(base)}
env_heb.pop("SETOPS_INVENTAIRE", None)
r = subprocess.run([sys.executable, str(RACINE / "scripts" / "devis_sdn.py"), "--json"],
cwd=RACINE, capture_output=True, text=True, env=env_heb)
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())