`proxmox_sdn.sortie_primaire` etait declare et utilise par `devis_opnsense` pour deriver le prochain saut de la frontiere, mais `devis_sdn` ne l'emettait jamais : une zone creee depuis ce devis n'aurait pas eu de primaire, et les deux devis se seraient contredits. Le devis l'emet, et refuse un primaire absent de la liste des noeuds de sortie. Cluster aligne : t17 passe a asgard,gandalf,vishnu (primaire asgard), strophe FRR posee sur les trois noeuds, six VRF portant leur defaut. Note de methode : une adresse ANYCAST ne peut pas servir de source de test. Le ping depuis gandalf semblait mort a 100 % ; la capture a montre asgard recevant les quatre reponses — la frontiere route le supernet vers le primaire, qui porte la meme passerelle localement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
340 lines
16 KiB
Python
340 lines
16 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,
|
|
})
|
|
# Adresse de la frontiere sur le lien de transit — le seul saut que le VRF d'un tenant
|
|
# a le droit d'emprunter. DERIVEE de l'underlay, jamais saisie ici.
|
|
_tr = underlay_mod.reseau_transit(underlay_mod.charger()) or {}
|
|
return {
|
|
"controleur": sdn.get("controleur") or "<CONTROLEUR-EVPN-A-DECLARER>",
|
|
"asn": sdn.get("asn"),
|
|
"noeuds_de_sortie": sdn.get("noeuds_de_sortie") or [],
|
|
# Le PRIMAIRE doit etre emis : `devis_opnsense` en derive le prochain saut des
|
|
# routes de la frontiere. Declare d'un cote et tu par l'autre, les deux devis se
|
|
# contrediraient — la frontiere pointerait un noeud que le SDN n'a pas designe.
|
|
"sortie_primaire": str(sdn.get("sortie_primaire") or "").strip(),
|
|
"anciennes": list(ANCIEN_NOMMAGE),
|
|
"frontiere": str(_tr.get("passerelle_sortie") or "").strip(),
|
|
"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}"
|
|
+ (f" \\\n --exitnodes-primary {devis['sortie_primaire']}"
|
|
if devis.get("sortie_primaire") else ""),
|
|
]
|
|
if sortie and not devis.get("sortie_primaire"):
|
|
out += [
|
|
"# /!\\ Aucun `proxmox_sdn.sortie_primaire` declare. La frontiere derive son",
|
|
"# prochain saut de cette valeur : sans elle, ses routes tenants restent",
|
|
"# en marqueur et aucun trafic entrant n'aboutit.",
|
|
]
|
|
elif devis.get("sortie_primaire") and devis["sortie_primaire"] not in sortie:
|
|
out += [
|
|
f"# /!\\ `sortie_primaire` = {devis['sortie_primaire']}, ABSENT des noeuds de",
|
|
"# sortie. Proxmox refuserait, et la frontiere pointerait un noeud sans",
|
|
"# chemin vers le tenant.",
|
|
]
|
|
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. La sortie du VRF — /etc/frr/frr.conf.local, SUR CHAQUE NOEUD DE SORTIE",
|
|
"#",
|
|
"# Proxmox declare les noeuds de sortie mais n'installe AUCUN defaut dans le VRF du",
|
|
"# tenant : `default-originate` ANNONCE une route aux autres noeuds, il n'en pose pas",
|
|
"# chez lui. Mesure du 2026-08-06 : `show ip route vrf vrf_tNN 0.0.0.0/0` etait vide",
|
|
"# sur les deux zones, et aucun tenant ne pouvait sortir.",
|
|
"#",
|
|
"# `nexthop-vrf default` emprunte UNE adresse a la table principale — celle de la",
|
|
"# frontiere, connectee sur le lien de transit — au lieu d'importer cette table.",
|
|
"# Consequence voulue : la route par defaut des hyperviseurs ne gouverne PAS la sortie",
|
|
"# des tenants et peut changer sans effet ici.",
|
|
"#",
|
|
"# `import vrf default` ferait l'inverse : le VRF heriterait de la table principale,",
|
|
"# donc du defaut de l'hyperviseur — sortie CONTOURNANT la frontiere — et verrait au",
|
|
"# passage le transport VXLAN, la gestion et les VLAN herites. Ne pas l'utiliser.",
|
|
"#",
|
|
"# Proxmox FUSIONNE ce fichier a chaque regeneration (EvpnPlugin.pm,",
|
|
"# read_local_frr_config) : la strophe survit a `pvesh set /cluster/sdn` et a un",
|
|
"# redemarrage de FRR. Verifie le 2026-08-06.",
|
|
]
|
|
if not devis.get("frontiere"):
|
|
out += [
|
|
"# /!\\ AUCUNE `passerelle_sortie` dans le reseau de transit de l'underlay :",
|
|
"# impossible de deriver le saut de sortie. Strophe OMISE plutot que fausse.",
|
|
]
|
|
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 += ["EOF", "systemctl reload frr"]
|
|
out += [
|
|
"",
|
|
"## 5. 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:]))
|