330 lines
15 KiB
Python
330 lines
15 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
|
||
|
|
|
||
|
|
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())
|