#!/usr/bin/env python3 """Devis SDN EVPN — le plan de donnees d'un tenant. Ajouter un tenant n'ajoute pas qu'un plan et un inventaire : cela implique, sur le cluster, UNE ZONE EVPN (le VRF du tenant), UN VNET par zone de securite, et UN SOUS-RESEAU par VNet avec sa passerelle anycast. Sans ces objets, aucune VM ne peut etre branchee — le plan de controle existe, le plan de donnees non. Tout DERIVE du seed `index` (cf. docs/sdn-evpn.md §2) : le VNI est `1000 + index*10 + zone`, le sous-reseau et la passerelle viennent des memes fonctions que l'inventaire. Rien n'est saisi deux fois. NOMMAGE — aligne sur le prefixe `t` DEJA utilise par le pare-feu Proxmox (`t17-cli-metrique`, `t17-flotte`) : un seul schema se lit dans tout le depot. - zone : `t` -> t17, t11 - VNet : `t` -> t17fron, t17iden, t17donn, t17serv, t17obse, t17appl L'abreviation de zone vient des 4 premieres lettres du libelle de la nomenclature, sans accent : elle DIT la zone, la ou un simple chiffre demandait la table des categories. `chez174` n'etait pas lisible ; `t17serv` l'est. Pas de tiret entre l'index et la zone, contrairement aux IPSets : `t245-serv` ferait 9 caracteres. Sans separateur, `t245serv` en fait 8 — la forme reste UNIFORME jusqu'au dernier index de la federation, ce qu'un separateur qui saute selon la taille de l'index ne permettrait pas. Le cluster portait `VRF0011` / `VRF0017`, herites d'une creation a la main. Ce nommage-la ne dit ni de quel tenant il s'agit, ni rien qu'on puisse relier au plan : il fallait un tableau de correspondance pour le lire. On le remplace pendant que les zones sont VIDES — le cout est nul aujourd'hui, ce serait une migration une fois des VM branchees dessus. LA SEULE CONTRAINTE QUI SURVIT, parce qu'elle vient du produit et non d'une habitude : zones ET VNets sont **limites a 8 caracteres** (l'identifiant sert de base aux noms de bridge, veth, tap ; message amont : « zone ID ... can't be more length than 8 characters »). Le nommage retenu tient : `CHEZ245` = 7, `chez2456` = 8 au pire cas de la federation. P30 refuse tout depassement — la faute ne se decouvrirait qu'a l'application. CE QUI APPARTIENT A L'HEBERGEUR : le controleur EVPN, son ASN et les noeuds de sortie sont des objets de CLUSTER (D-35). Ils se lisent dans `/proxmox-hebergeur.yml`, jamais chez le tenant. NON destructif : ce script n'ecrit rien sur le cluster. Usage : python3 scripts/devis_sdn.py # devis lisible python3 scripts/devis_sdn.py --json # meme contenu, pour l'API python3 scripts/devis_sdn.py --verifier # garde : aucune collision, noms valides """ from __future__ import annotations import argparse import json import os import sys import unicodedata from pathlib import Path import yaml RACINE = Path(__file__).resolve().parents[1] sys.path.insert(0, str(RACINE / "scripts")) from inventory_rules import passerelle_de, sous_reseau_de, vlan_de # noqa: E402 from devis_reseau import decouvrir # noqa: E402 import underlay as underlay_mod # noqa: E402 # Limite Proxmox, pour les zones ET les VNets : l'identifiant sert de base aux noms # de bridge, veth et tap, auxquels le produit ajoute prefixes et suffixes. LONGUEUR_MAX_ID = 8 MARQUEUR_SORTIE = "" # Nommages precedents, a retirer : VRF00xx (cree a la main) puis CHEZ17/TECH11 # (premiere derivation, remplacee — les chiffres de zone n'etaient pas lisibles). ANCIEN_NOMMAGE = ("VRF0011", "VRF0017", "CHEZ17", "TECH11") def zone_de(index: int) -> str: """Zone EVPN : `t` — meme prefixe que les IPSets du pare-feu Proxmox.""" return f"t{int(index)}" def abreger(libelle: str, taille: int = 4) -> str: """4 premieres lettres du libelle, sans accent ni ponctuation, en minuscules. Mecanique, donc derivee : « Services-infra » -> `serv`, « Observabilite » -> `obse`. Deux libelles partageant leurs 4 premieres lettres produiraient le meme VNet — la garde de collision l'attrape et exige de renommer la categorie, plutot que d'inventer un suffixe illisible. """ plat = unicodedata.normalize("NFKD", str(libelle)) plat = "".join(c for c in plat if not unicodedata.combining(c)) plat = "".join(c for c in plat if c.isalnum()) return plat.lower()[:taille] or "zone" def vnet_de(index: int, libelle: str) -> str: """VNet : `t` — t17serv, t17obse. Minuscules : cet identifiant devient une base de nom d'interface.""" return f"{zone_de(index)}{abreger(libelle)}" def sdn_hebergeur() -> dict: """Controleur, ASN et noeuds de sortie — propriete de l'HEBERGEUR (D-35). Trouve par derivation du symlink `underlay.yml`, qui designe deja l'hebergeur : rien de nouveau n'est declare (D-17). Vide si aucun hebergeur n'est monte. """ lien = Path(os.environ.get("SETOPS_UNDERLAY") or (RACINE / "underlay.yml")) if not lien.exists(): return {} fichier = lien.resolve().parent / "proxmox-hebergeur.yml" if not fichier.is_file(): return {} data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {} return data.get("proxmox_sdn") or {} def construire(tenants: list[tuple[str, str, dict]]) -> dict: sdn = sdn_hebergeur() u = underlay_mod.charger() mtu = underlay_mod.mtu_overlay(u) blocs = [] for nom_instance, _prefixe, nomenclature in tenants: index = int(nomenclature["index"]) vnets = [] for cle, cat in sorted((nomenclature.get("categories") or {}).items(), key=lambda x: int(x[0])): z = int(cle) vnets.append({ "vnet": vnet_de(index, cat.get("libelle", f"zone{z}")), "libelle": str(cat.get("libelle", f"zone-{z}")), "tag": vlan_de(index, z), "sous_reseau": sous_reseau_de(index, z), "passerelle": passerelle_de(index, z), }) blocs.append({ "tenant": nom_instance, "index": index, "zone": zone_de(index), "vrf_vxlan": index, "mtu": mtu, "vnets": vnets, }) return { "controleur": sdn.get("controleur") or "", "asn": sdn.get("asn"), "noeuds_de_sortie": sdn.get("noeuds_de_sortie") or [], "anciennes": list(ANCIEN_NOMMAGE), "zones": blocs, } def rendre(devis: dict) -> str: sortie = devis["noeuds_de_sortie"] out = [ "# Devis SDN EVPN — une zone, ses VNets, ses sous-reseaux, par tenant", "#", "# Tout derive du seed `index` : VNI = 1000 + index*10 + zone ; le sous-reseau et", "# la passerelle viennent des memes fonctions que l'inventaire.", "#", "# Le `.1` ne change pas d'adresse, il change de PORTEUR : du SVI d'un commutateur", "# vers la passerelle anycast du VNet, presente sur chaque hyperviseur.", "", f"# Controleur (hebergeur) : {devis['controleur']}" + (f" ASN {devis['asn']}" if devis.get("asn") else ""), "", ] if devis.get("anciennes"): out += [ "## 0. Retirer l'ancien nommage (fait a la main, remplace)", "#", "# Ces zones ne portent AUCUN VNet : les supprimer ne debranche rien. C'est", "# precisement pourquoi on renomme maintenant — avec des VM attachees, ce", "# serait une migration. Verifier l'absence de VNet avant de supprimer :", "# pvesh get /cluster/sdn/vnets --output-format json", ] for z in devis["anciennes"]: out.append(f"pvesh delete /cluster/sdn/zones/{z}") out.append("") for b in devis["zones"]: out += [ f"## {b['tenant']} — zone `{b['zone']}` (index {b['index']}, VNI de VRF {b['vrf_vxlan']})", "", "### 1. La zone (le VRF du tenant)", f"pvesh create /cluster/sdn/zones --zone {b['zone']} --type evpn \\", f" --controller {devis['controleur']} --vrf-vxlan {b['vrf_vxlan']} \\", f" --mtu {b['mtu']} --ipam pve \\", f" --exitnodes {','.join(sortie) if sortie else MARQUEUR_SORTIE}", ] if not sortie: out += [ "# /!\\ AUCUN noeud de sortie declare chez l'hebergeur.", "# Un VRF sans sortie n'a AUCUN chemin vers l'exterieur : ni frontiere,", "# ni inter-tenant, ni Internet. Le trafic intra-tenant fonctionnerait,", "# le reste echouerait sans message clair. Declarer", "# `proxmox_sdn.noeuds_de_sortie` dans proxmox-hebergeur.yml.", ] out += ["", "### 2. Les VNets (une par zone de securite)"] for v in b["vnets"]: out.append(f"pvesh create /cluster/sdn/vnets --vnet {v['vnet']} " f"--zone {b['zone']} --tag {v['tag']} # {v['libelle']}") out += ["", "### 3. Les sous-reseaux (passerelle anycast sur chaque hyperviseur)"] for v in b["vnets"]: out.append(f"pvesh create /cluster/sdn/vnets/{v['vnet']}/subnets " f"--subnet {v['sous_reseau']} --type subnet " f"--gateway {v['passerelle']} --snat 0") out += ["", "# VNet tag/VNI sous-reseau passerelle zone"] for v in b["vnets"]: out.append(f"# {v['vnet']:<8} {v['tag']:<8} {v['sous_reseau']:<18} " f"{v['passerelle']:<17} {v['libelle']}") out.append("") out += [ "## 4. Appliquer", "# Les objets ci-dessus ne sont que DECLARES tant que la configuration SDN n'est", "# pas poussee sur les noeuds. Rien ne change avant cette ligne.", "pvesh set /cluster/sdn", "", "## Ce que ce devis ne fait pas", "# Il ne branche aucune VM : le `bridge` d'une VM se regle au clonage, et vient du", "# plan (proxmox_clone_pont). Il ne lit pas non plus le cluster — il dit l'etat", "# cible, pas l'ecart. Les commandes echouent si l'objet existe deja, ce qui est", "# le comportement voulu pour un devis qu'on relit avant d'appliquer.", ] return "\n".join(out) def main(argv: list[str]) -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--json", action="store_true") ap.add_argument("--verifier", action="store_true") args = ap.parse_args(argv) devis = construire(decouvrir()) if args.verifier: zones = devis["zones"] if not zones: print("erreur: aucun tenant federe decouvert.", file=sys.stderr) return 2 fautes: list[str] = [] vus_zone: dict[str, str] = {} vus_vnet: dict[str, str] = {} vus_reseau: dict[str, str] = {} for b in zones: if len(b["zone"]) > LONGUEUR_MAX_ID: fautes.append(f"zone {b['zone']} : {len(b['zone'])} caracteres " f"(max {LONGUEUR_MAX_ID})") if b["zone"] in vus_zone: fautes.append(f"zone {b['zone']} revendiquee par {vus_zone[b['zone']]} " f"et {b['tenant']}") vus_zone[b["zone"]] = b["tenant"] for v in b["vnets"]: # La limite de 8 caracteres est la faute la plus couteuse : Proxmox # refuse l'objet, et un devis invalide se decouvre a l'application. if len(v["vnet"]) > LONGUEUR_MAX_ID: fautes.append(f"VNet {v['vnet']} : {len(v['vnet'])} caracteres " f"(max {LONGUEUR_MAX_ID})") if v["vnet"] in vus_vnet: fautes.append(f"VNet {v['vnet']} revendique par {vus_vnet[v['vnet']]} " f"et {b['tenant']}") vus_vnet[v["vnet"]] = b["tenant"] if v["sous_reseau"] in vus_reseau: fautes.append(f"sous-reseau {v['sous_reseau']} revendique par " f"{vus_reseau[v['sous_reseau']]} et {b['tenant']}") vus_reseau[v["sous_reseau"]] = b["tenant"] if fautes: print("erreur: " + " ; ".join(fautes[:6]), file=sys.stderr) return 2 total_v = sum(len(b["vnets"]) for b in zones) manque = "" if devis["noeuds_de_sortie"] else \ " ; /!\\ aucun noeud de sortie declare (VRF sans chemin vers l'exterieur)" print(f"CONFORME : SDN EVPN, {len(zones)} zone(s), {total_v} VNet(s), " f"{total_v} sous-reseau(x), aucune collision{manque}.") return 0 print(json.dumps(devis, indent=2, ensure_ascii=False) if args.json else rendre(devis)) return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))