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>
282 lines
13 KiB
Python
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:]))
|