Set-OPS-Public/scripts/devis_sdn.py

283 lines
13 KiB
Python
Raw Normal View History

SDN : make devis-sdn — zone, VNets et sous-réseaux dérivés du seed (D-43/44, P30) Ajouter un tenant implique 1 zone EVPN + 6 VNets + 6 sous-réseaux sur le cluster. Aucun générateur ne les produisait : dernière lacune dans un dépôt où tout dérive. 26 objets pour les deux tenants, VNI = 1000 + index×10 + zone, sous-réseau et passerelle par les mêmes fonctions que l'inventaire. Nommage, en deux temps. D'abord VRF0017 / v1174, alignés sur ce que le cluster portait — réflexe inverse du bon : cette convention venait d'une création à la main et ne disait pas de quel tenant il s'agissait. Forme retenue : t<index> pour la zone, t<index><zone abrégée> pour le VNet (t17, t17serv). C'est le préfixe que le pare-feu Proxmox utilisait déjà (t17-cli-metrique), donc un seul schéma dans tout le dépôt. Abréviation = 4 premières lettres du libellé, accents retirés. Pas de tiret entre index et zone, contrairement aux IPSets : t245-serv ferait 9 caractères, t245serv en fait 8 — la forme reste uniforme jusqu'au dernier index. Contrainte cadrante : zones ET VNets sont limités à 8 caractères par Proxmox. sdn-evpn.md annonçait chez17-services-infra (21) : il aurait été refusé à l'application. P30 refuse tout dépassement et toute collision de nom, de VNI ou de sous-réseau. Éprouvé aux bornes et par sabotage. Vérification la plus forte : avant renommage, la dérivation reproduisait à l'identique les deux zones créées à la main — nom, VNI de VRF, MTU, contrôleur. voute.py saisir : le pendant de la génération. On génère un secret dont le dépôt est la source, on saisit celui dont un tiers est la source — inventer une clé d'API OPNsense donnerait une valeur refusée à la première requête, avec P18 au vert sur une voûte inutilisable. Sans écho, double confirmation, rien sur la ligne de commande. Reste ouvert : aucun nœud de sortie déclaré. Le devis émet un marqueur, pas une valeur plausible. Deux points à trancher — le nœud de sortie route selon sa propre table (défaut actuel : 192.168.11.254, pas la frontière), et l'entrée n'est pas redondante puisqu'elle dépend d'une route statique d'OPNsense vers un seul nœud. D-45 : l'affinité de VM attend Proxmox 9 (cluster en 8.4.19), tenue à la main. 30 preuves OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:06:42 -04:00
#!/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<index>` DEJA utilise par le pare-feu Proxmox
(`t17-cli-metrique`, `t17-flotte`) : un seul schema se lit dans tout le depot.
- zone : `t<index>` -> t17, t11
- VNet : `t<index><zone>` -> 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
`<depot hebergeur>/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 = "<NOEUDS-DE-SORTIE-A-DESIGNER>"
# 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<index>` — 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<index><zone abregee>` — 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 "<CONTROLEUR-EVPN-A-DECLARER>",
"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:]))