Set-OPS-Public/scripts/appliquer_sdn.py
Daniel Allaire b3447d5a96 proxmox-fw : un applicateur, et un refus assume
Reconcilie les trois couches du devis : 26 IPSets, 36 groupes, affectations aux
VM. Cree, met a jour, retire — meme contrat que les applicateurs de la frontiere
et du SDN.

Il n'active JAMAIS le pare-feu du datacenter : ce reglage vaut pour toutes les
VM du cluster, y compris les 37 heritees sans regle, et le basculer couperait le
parc. L'ecart est signale a chaque execution ; la decision reste humaine.

Les 28 VM du devis n'existent pas encore : listees comme differees, pas comme
erreurs. Les objets poses sont donc inertes, ce qui rend l'application sure.

`scripts/proxmox_api.py` extrait ce que les deux applicateurs Proxmox partagent,
en particulier la recomposition du jeton dont la voute ne porte que le nom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 19:39:33 -04:00

276 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()
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 {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())