Set-OPS-Public/scripts/devis_sdn.py
Daniel Allaire 3b1d9b6660 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

282 lines
13 KiB
Python

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