diff --git a/CHANGELOG.md b/CHANGELOG.md index 607d04a..a0af142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ ## 2026-08-06 — le chemin nord-sud devient dérivable +### Un applicateur pour le SDN, et pour la sortie des VRF + +`scripts/appliquer_sdn.py` — deuxième applicateur du dépôt, même contrat que celui de la +frontière : ce qui manque est créé, **ce que le devis ne demande plus est retiré**. + +**Deux cibles, parce qu'elles n'ont pas la même prise.** Les objets de cluster (zone, VNets, +sous-réseaux) passent par l'API Proxmox ; la sortie du VRF est un *fichier* sur chaque nœud de +sortie, qu'aucune API n'expose — donc SSH. `make sdn-plan` lit, `make sdn-appliquer +CONFIRMER=true` agit. + +**Périmètre strict.** Seules les zones nommées par le devis et celles de la liste explicite des +anciens nommages sont touchées. Une zone inconnue est signalée et **laissée intacte** : ce +dépôt n'est pas seul au monde sur ce cluster. + +**Deux garde-fous sur le retrait.** Un VNet encore branché à une VM est refusé, avec le nom des +machines — on ne débranche personne par inadvertance. Et l'ordre suit les dépendances : +sous-réseaux, puis VNets, puis zones à la suppression ; l'inverse à la création. Proxmox refuse +tout autre ordre, et l'avait déjà appris à ses dépens. + +**Une source unique pour la strophe.** `devis_sdn.strophe_frr()` sert à la fois au devis qui +l'affiche et à l'applicateur qui la compare au fichier distant. Deux rendus séparés auraient +fini par diverger — c'est le mode de panne que ce dépôt passe ses journées à fermer. + +**Un défaut trouvé par la première exécution.** L'applicateur lisait `frr.conf.local` sans +`sudo` ; la lecture échouait, un `|| true` masquait l'échec, et un fichier **présent** était +déclaré absent — donc réécrit sans raison. Il distingue désormais « absent », « illisible » et +« différent », trois états qui appellent trois réponses. + +Le rejeu ne trouve plus rien à faire, les six VRF portent leur défaut, et la sortie tenant +répond toujours. + + ### Trois nœuds de sortie, et le primaire enfin émis `t17` passe à `asgard,gandalf,vishnu`, primaire `asgard` — `t11` l'était déjà. La strophe FRR diff --git a/Makefile b/Makefile index 6326ffd..d1f76dd 100644 --- a/Makefile +++ b/Makefile @@ -348,6 +348,18 @@ devis-reseau: ansible-runtime ## Devis switch (VLANs/SVIs/ACLs) du reseau conver devis-opnsense: ansible-runtime ## Devis OPNsense (frontiere nord/sud), derive du registre des flux (pair: externe) python3 scripts/devis_opnsense.py $(if $(JSON),--json,) +.PHONY: sdn-plan sdn-appliquer +sdn-plan: ansible-runtime ## Ecart entre le SDN EVPN (+ sortie des VRF) et son devis (aucune ecriture) + python3 scripts/appliquer_sdn.py + +sdn-appliquer: ansible-runtime ## Reconcilie le SDN : cree ce qui manque, RETIRE ce qui est perime. CONFIRMER=true + @if [[ "$(CONFIRMER)" != "true" ]]; then \ + printf '%s\n' "Refus: cette cible ECRIT sur le cluster (zones, VNets, sous-reseaux) et sur" \ + "les noeuds de sortie (/etc/frr/frr.conf.local), et RETIRE ce qui est perime." \ + "Relire d'abord 'make sdn-plan', puis: make sdn-appliquer CONFIRMER=true"; \ + exit 2; fi + CONFIRMER=true python3 scripts/appliquer_sdn.py + .PHONY: frontiere-plan frontiere-appliquer frontiere-plan: ansible-runtime ## Ecart entre la frontiere OPNsense et son devis (aucune ecriture) python3 scripts/appliquer_opnsense.py diff --git a/scripts/appliquer_sdn.py b/scripts/appliquer_sdn.py new file mode 100644 index 0000000..c7fb8b8 --- /dev/null +++ b/scripts/appliquer_sdn.py @@ -0,0 +1,329 @@ +#!/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 `--`. + 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()) diff --git a/scripts/devis_sdn.py b/scripts/devis_sdn.py index 1df194d..70ac584 100644 --- a/scripts/devis_sdn.py +++ b/scripts/devis_sdn.py @@ -161,6 +161,34 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: } +EN_TETE_FRR = ( + "! Set-OPS — sortie des tenants par la frontiere. GENERE : `make devis-sdn`.\n" + "! NE PAS EDITER A LA MAIN — `make sdn-appliquer` reecrit ce fichier.\n" + "!\n" + "! `nexthop-vrf default` emprunte UNE adresse a la table principale au lieu de\n" + "! l'importer : la route par defaut des hyperviseurs ne gouverne pas la sortie\n" + "! des tenants. `import vrf default` ferait contourner la frontiere et fuiterait\n" + "! le transport VXLAN, la gestion et les VLAN herites dans le VRF.\n" +) + + +def strophe_frr(devis: dict) -> str: + """Contenu attendu de /etc/frr/frr.conf.local, ou '' s'il n'y a rien a poser. + + SOURCE UNIQUE : le devis l'affiche, l'applicateur la compare au fichier distant. + Deux rendus separes finiraient par diverger — c'est le mode de panne que ce + depot passe son temps a fermer. + """ + if not devis.get("frontiere") or not devis.get("noeuds_de_sortie"): + return "" + lignes = [EN_TETE_FRR.rstrip("\n")] + for b in devis["zones"]: + lignes += [f"vrf vrf_{b['zone']}", + f" ip route 0.0.0.0/0 {devis['frontiere']} nexthop-vrf default", + "exit-vrf"] + return "\n".join(lignes) + "\n" + + def rendre(devis: dict) -> str: sortie = devis["noeuds_de_sortie"] out = [ @@ -263,11 +291,10 @@ def rendre(devis: dict) -> str: elif not sortie: out += ["# /!\\ Aucun noeud de sortie : strophe OMISE (elle n'aurait aucun porteur)."] else: - out += [f"# A poser sur : {', '.join(sortie)}", "cat >> /etc/frr/frr.conf.local <<'EOF'"] - for b in devis["zones"]: - out += [f"vrf vrf_{b['zone']}", - f" ip route 0.0.0.0/0 {devis['frontiere']} nexthop-vrf default", - "exit-vrf"] + out += [f"# A poser sur : {', '.join(sortie)}", + "# `make sdn-appliquer CONFIRMER=true` le fait, et le RETIRE s'il ne l'est plus.", + "cat > /etc/frr/frr.conf.local <<'EOF'"] + out += strophe_frr(devis).rstrip("\n").split("\n") out += ["EOF", "systemctl reload frr"] out += [ "",