Set-OPS-Public/scripts/appliquer_sdn.py

277 lines
12 KiB
Python
Raw Normal View History

#!/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())