`index: 17` ne disait pas « voici mon adressage » : un site ne derive rien, ses machines vivent sur un reseau de fabric hors de toute derivation. Il disait UNE seule chose — quel supernet de tenant est le mien — pour autoriser un reseau du site a en occuper la bande basse (D-77). Un reste de la coincidence hebergeur<->tenant chez Chezlepro, qui est les deux a la fois ; ailleurs il aurait fallu inventer un index a un hebergeur qui n'heberge pas son propre ecosysteme. L'exception se declare desormais sur le RESEAU qui en a besoin, et elle NOMME le tenant dont elle occupe la bande basse (`bande_basse_de: OPS-Chezlepro`). Plus vrai : un seul reseau est concerne. Plus verifiable : le nom se resout dans le registre `tenants:`, donc une faute de frappe est refusee au lieu de passer. Quatre controles negatifs, tous refuses — tenant inconnu, exception absente, debordement sur la bande des zones, et le mauvais tenant nomme. 42 preuves vertes, les quatre devis du panneau OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
817 lines
39 KiB
Python
817 lines
39 KiB
Python
#!/usr/bin/env python3
|
|
"""Underlay = la fabric physique partagee par les instances federees.
|
|
|
|
Cluster-global : management des switches et du mgmt Proxmox/OOB, stockage iSCSI,
|
|
reseaux Ceph (public + cluster). Ce sont des reseaux du *sous-sol* : ils portent la
|
|
flotte, ils n'appartiennent a aucun tenant.
|
|
|
|
Convention : VLAN < 1000, franchement SOUS la plage tenant (VLAN = 1000+index*10+zone).
|
|
|
|
ADRESSAGE (D-77, 2026-08-12). Un site derive du meme `index` que son tenant, et son
|
|
underlay occupe la BANDE BASSE de ce supernet : `10.<index>.0-15.x`. La bande est
|
|
sure PAR LA REGLE et non par chance — les zones d'un tenant valent
|
|
`10.<index>.(15+categorie).0/24`, donc troisieme octet >= 16 ; les octets 0 a 15
|
|
ne sont jamais alloues. Deux sites ne peuvent alors pas se chevaucher, sans second
|
|
registre a tenir.
|
|
|
|
Trois cas, et un seul est une erreur :
|
|
|
|
- dans SON PROPRE supernet, bande basse -> conforme (c'est la regle)
|
|
- dans son propre supernet, bande HAUTE -> ERREUR : collision avec ses zones
|
|
- dans le supernet d'un AUTRE tenant -> ERREUR : collision entre sites
|
|
- hors de tout supernet (10.0.x, 192.168.x) -> conforme (heritage, et stockage)
|
|
|
|
Le site declare son index par `underlay.index`. **Sans lui**, on retombe sur la regle
|
|
stricte d'avant D-77 (aucun chevauchement) : c'est le comportement sur pour un
|
|
underlay qui n'a pas encore migre.
|
|
|
|
DESTINATION OU CHEMIN (D-78). Ce qui sort de l'espace derive n'est pas « le stockage »
|
|
mais tout reseau qui n'est jamais une DESTINATION. Le critere n'est pas la presence de
|
|
machines — le VLAN de gestion n'en a pas plus qu'un autre :
|
|
|
|
destination : on doit pouvoir l'ATTEINDRE depuis ailleurs (gestion : poste de
|
|
l'exploitant, VPN, lien inter-sites) -> 10.<index>.0-15.x, UNIQUE
|
|
chemin : seulement traverse, jamais joint depuis l'exterieur de son propre lien
|
|
(transit, transport VXLAN, stockage) -> 192.168.<vlan>.0/24, IDENTIQUE
|
|
a tous les sites — l'adresse dit son VLAN
|
|
|
|
La borne de bande basse ne concerne donc que les DESTINATIONS : un reseau en 192.168 est
|
|
hors de tout supernet tenant et passe par la branche « hors de tout supernet ».
|
|
|
|
EMPLACEMENT : l'underlay appartient a l'HEBERGEUR — ses switches, ses cables. Il vit
|
|
donc dans SON depot, et le moteur le monte par symlink :
|
|
|
|
Set-OPS-public/underlay.yml -> ../OPS-<hebergeur>/underlay.yml
|
|
|
|
Ce symlink NE SUIT PAS `make instance-utiliser` : la fabric reste celle de l'hebergeur,
|
|
quel que soit le tenant actif. Surchargeable par `SETOPS_UNDERLAY`. Absent (ou lien
|
|
brise, cas d'un clone sans depot frere) => le devis omet simplement la section.
|
|
|
|
CLI : `python3 scripts/underlay.py` (affiche + valide), `--verifier` (P23, code de sortie).
|
|
"""
|
|
from __future__ import annotations
|
|
import argparse
|
|
import ipaddress
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
RACINE = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
from inventory_rules import instance_courante, supernet_de, vlan_de # noqa: E402
|
|
|
|
# Les VLAN tenant commencent a 1000+index*10+zone (>= 1011). L'underlay reste en dessous.
|
|
SEUIL_VLAN_TENANT = 1000
|
|
|
|
# Premier troisieme-octet alloue aux ZONES d'un tenant. DERIVE de la regle
|
|
# (`base3_de` = 10.<index>.(15+categorie)) et jamais ecrit en dur : deplacer
|
|
# OCTET_ZONE deplacerait la frontiere de la bande basse avec lui.
|
|
from inventory_rules import OCTET_ZONE # noqa: E402
|
|
PREMIER_OCTET_ZONE = OCTET_ZONE + 1
|
|
|
|
|
|
def chemin() -> Path | None:
|
|
"""Emplacement du underlay.yml : SETOPS_UNDERLAY, sinon le symlink a la racine.
|
|
|
|
`exists()` suit le lien : un symlink brise (clone sans le depot de l'hebergeur)
|
|
rend None, et tout degrade proprement au lieu d'echouer.
|
|
"""
|
|
env = os.environ.get("SETOPS_UNDERLAY")
|
|
if env:
|
|
p = Path(env).expanduser()
|
|
return p if p.exists() else None
|
|
p = RACINE / "underlay.yml"
|
|
return p if p.exists() else None
|
|
|
|
|
|
def charger() -> dict | None:
|
|
"""Le bloc `underlay` du fichier, ou None si absent/vide."""
|
|
c = chemin()
|
|
if not c:
|
|
return None
|
|
data = yaml.safe_load(c.read_text(encoding="utf-8")) or {}
|
|
return data.get("underlay") or None
|
|
|
|
|
|
def reseaux(underlay: dict | None) -> list[dict]:
|
|
return (underlay or {}).get("reseaux", []) or []
|
|
|
|
|
|
def hotes(underlay: dict | None) -> list[dict]:
|
|
return (underlay or {}).get("hotes", []) or []
|
|
|
|
|
|
def machines(underlay: dict | None) -> list[dict]:
|
|
"""LES MACHINES DE L'HEBERGEUR — le site, et rien d'autre.
|
|
|
|
UN SITE N'EST PAS UN PLAN (2026-08-24). Un tenant se DERIVE : de son seul `index`
|
|
descendent son supernet, ses VLAN, ses VMID, ses VNet. Un site ne derive de rien —
|
|
il n'a pas d'index, il EST le terrain sur lequel les tenants derivent.
|
|
|
|
Les faire passer par le generateur de tenants imposait une branche « si l'adresse est
|
|
declaree, elle gagne » : deux mondes dans une meme moulinette, un `nomenclature.yml`
|
|
de site vide de sens, et un site exclu des devis par ABSENCE d'index plutot que par
|
|
nature. Une exclusion fondee sur un manque casse au premier ajout innocent.
|
|
|
|
Ces machines sont donc du MOBILIER DE FABRIC, declare a cote des switches et des
|
|
hyperviseurs qui les portent. Meme fichier, meme carte, meme secrets. Ce qui se
|
|
partage avec les tenants, ce sont les ROLES — pas la forme du plan.
|
|
"""
|
|
return (underlay or {}).get("machines", []) or []
|
|
|
|
|
|
FABRIC_DEFAUT = "principal"
|
|
|
|
# Priorites de pont : le routeur est la racine (basse), les switches d'acces ne
|
|
# doivent jamais l'etre. 4096 laisse 0 disponible pour une reprise d'urgence.
|
|
STP_PRIORITE_RACINE = 4096
|
|
STP_PRIORITE_ACCES = 32768
|
|
# Dialecte de CLI du commutateur : c'est une propriete du MATERIEL, donc de la fabric.
|
|
# Source unique (devis_reseau y lit ses choix d'argparse).
|
|
DIALECTES = ("cisco", "binardat")
|
|
STP_MODES = ("rstp", "mstp", "pvst")
|
|
STP_TOPOLOGIES = ("etoile", "anneau", "maille")
|
|
|
|
|
|
ROUTAGE_TENANTS = ("switch", "sdn")
|
|
# VXLAN coute 50 octets d'encapsulation : le TRANSPORT doit valoir au moins l'overlay
|
|
# plus cette surcharge. Sous ce seuil, il casse PARTIELLEMENT — le ping passe, les
|
|
# transferts echouent — la panne la plus couteuse a diagnostiquer.
|
|
SURCOUT_VXLAN = 50
|
|
MTU_OVERLAY_DEFAUT = 1450 # ce que voit une VM ; regle par zone dans Proxmox SDN
|
|
|
|
|
|
def mtu_overlay(underlay: dict | None) -> int:
|
|
"""MTU annonce aux VM par les VNet. Declare, parce qu'il commande le minimum du
|
|
transport : un overlay a 1450 tient dans un transport a 1500, pas un overlay a 1500."""
|
|
v = (underlay or {}).get("mtu_overlay")
|
|
return int(v) if isinstance(v, int) else MTU_OVERLAY_DEFAUT
|
|
|
|
|
|
def tenants_du_site(underlay: dict | None) -> list[str] | None:
|
|
"""Noms de dossier des tenants HEBERGES sur ce site. None = non declare.
|
|
|
|
POURQUOI CETTE CLE EXISTE (mesure du 2026-08-13, sur le second site).
|
|
Les devis partaient de `devis_reseau.decouvrir()`, qui liste TOUTE la federation :
|
|
tout dossier frere portant un `plan/nomenclature.yml` avec un index. C'etait juste
|
|
tant qu'il n'y avait qu'un site — l'hebergeur unique portait bien tous les tenants.
|
|
|
|
Des le second site, c'est faux et dangereux. Le devis de la frontiere du site B
|
|
voulait y poser les regles ET les routes du tenant du site A : trente regles et six
|
|
routes vers des sous-reseaux qui n'existent pas la-bas. Elles auraient ete acceptees
|
|
par le boitier, elles n'auraient jamais correspondu a un paquet, et rien ne l'aurait
|
|
signale — une politique qui a l'air complete et ne protege rien.
|
|
|
|
Absente, on garde l'ancien comportement (toute la federation) : un site unique n'a
|
|
rien a declarer. C'est le second site qui doit se nommer.
|
|
"""
|
|
v = (underlay or {}).get("underlay", underlay or {}).get("tenants")
|
|
if v is None:
|
|
return None
|
|
if isinstance(v, dict):
|
|
return [str(k) for k in v]
|
|
return [str(x) for x in v] if isinstance(v, list) else [str(v)]
|
|
|
|
|
|
def allocations(underlay: dict | None) -> dict[str, int]:
|
|
"""Index ATTRIBUE par ce site a chacun de ses tenants. Vide si aucune allocation.
|
|
|
|
C'EST LE SITE QUI DETERMINE L'INDEX D'UN TENANT (2026-08-25).
|
|
|
|
SITE et OPS sont deux CLASSES distinctes : le site decide de la fabric et du reseau et
|
|
produit les intrants ; le tenant consomme ces intrants et n'a d'intelligence que sur
|
|
ses applications. Or l'index est l'intrant reseau par excellence — de lui descendent le
|
|
supernet, les VLAN, les VMID, les noms de VNet SDN. Un tenant qui le DECIDE decide donc
|
|
du plan d'adressage de la fabric, ce qui n'est pas a lui.
|
|
|
|
Il etait declare DEUX FOIS jusqu'ici : dans `plan/nomenclature.yml` du tenant et dans
|
|
l'`underlay` du site. Deux sources de verite pour le meme nombre, dans deux classes
|
|
differentes — et une preuve (P21) dont le seul role etait de courir apres les doublons
|
|
que cette double declaration rendait possibles.
|
|
|
|
Le sens de la fleche change, pas les valeurs : le site ALLOUE, le tenant RECOIT, et la
|
|
nomenclature du tenant devient la copie verifiable d'une decision prise ailleurs. Un
|
|
tenant qui s'emancipe vers un autre site en recoit un neuf de son nouvel hote.
|
|
|
|
Forme heritee (une simple liste de noms) toujours acceptee : elle dit « ces tenants
|
|
sont ici » sans rien allouer, et ne vaut que pour un site qui n'a pas encore migre.
|
|
"""
|
|
v = (underlay or {}).get("underlay", underlay or {}).get("tenants")
|
|
if not isinstance(v, dict):
|
|
return {}
|
|
out: dict[str, int] = {}
|
|
for nom, idx in v.items():
|
|
if isinstance(idx, bool) or not isinstance(idx, int):
|
|
continue # signale par `valider`, pas ignore en silence
|
|
out[str(nom)] = idx
|
|
return out
|
|
|
|
|
|
def routage_tenants(underlay: dict | None) -> str:
|
|
"""Qui route entre les zones d'un tenant : `switch` (SVI) ou `sdn` (EVPN).
|
|
|
|
En `sdn`, une zone EVPN par tenant porte le VRF : le routage ET le filtrage
|
|
inter-zone d'un meme tenant vivent sur les hyperviseurs. Aucun VLAN de tenant ne
|
|
circule alors sur le fil — seulement du VXLAN encapsule dans de l'IP — et le devis
|
|
switch cesse d'emettre VLAN tenants, SVI et ACL.
|
|
|
|
L'inter-tenant, lui, sort du VRF et passe par la frontiere, qui le police.
|
|
"""
|
|
v = str((underlay or {}).get("routage_tenants") or "switch")
|
|
return v if v in ROUTAGE_TENANTS else "switch"
|
|
|
|
|
|
def acl_inter_tenant(underlay: dict | None) -> bool:
|
|
"""La fabric porte-t-elle l'isolation inter-tenant par ACL ? Vrai par defaut.
|
|
|
|
A mettre a `false` quand le materiel ne sait pas lier une ACL a une interface de
|
|
routage — cas du Binardat, dont les interfaces VLAN n'offrent aucun `access-group`.
|
|
Le devis cesse alors d'emettre des ACL qui ne seraient jamais liees : mieux vaut
|
|
aucune regle qu'une regle inoperante qu'on croit active.
|
|
|
|
Conséquence assumee, et ecrite dans le devis : l'isolation repose entierement sur
|
|
les nftables d'hote, et le plan de gestion de la fabric n'a plus de protection
|
|
RESEAU contre les tenants.
|
|
"""
|
|
v = (underlay or {}).get("acl_inter_tenant")
|
|
return True if v is None else bool(v)
|
|
|
|
|
|
def dialecte(underlay: dict | None) -> str:
|
|
"""Dialecte de CLI declare pour la fabric. '' si absent."""
|
|
return str((underlay or {}).get("dialecte") or "")
|
|
|
|
|
|
def stp(underlay: dict | None) -> dict:
|
|
"""Parametres de spanning-tree de la fabric principale. {} si non declares."""
|
|
return (underlay or {}).get("stp") or {}
|
|
|
|
|
|
def fabric_de(reseau: dict) -> str:
|
|
"""Fabric physique qui porte ce reseau. `principal` par defaut."""
|
|
return str(reseau.get("fabric") or FABRIC_DEFAUT)
|
|
|
|
|
|
def fabriques(underlay: dict | None) -> list[str]:
|
|
"""Les fabrics declarees, `principal` en tete."""
|
|
vues = {fabric_de(r) for r in reseaux(underlay)}
|
|
return [FABRIC_DEFAUT] * (FABRIC_DEFAUT in vues) + sorted(vues - {FABRIC_DEFAUT})
|
|
|
|
|
|
def reseaux_de_fabric(underlay: dict | None, fabric: str) -> list[dict]:
|
|
return [r for r in reseaux(underlay) if fabric_de(r) == fabric]
|
|
|
|
|
|
def fabric_du_routeur(underlay: dict | None) -> str:
|
|
"""Fabric du switch routeur : celle du reseau ou il est declare comme hote."""
|
|
r_nom = routeur(underlay)
|
|
par_nom = {r.get("nom"): r for r in reseaux(underlay)}
|
|
for h in hotes(underlay):
|
|
if h.get("nom") == r_nom and h.get("reseau") in par_nom:
|
|
return fabric_de(par_nom[h["reseau"]])
|
|
return FABRIC_DEFAUT
|
|
|
|
|
|
def routeur(underlay: dict | None) -> str | None:
|
|
"""Nom du switch qui porte les SVI, ou None si aucun n'est designe.
|
|
|
|
Sans MLAG, UN SEUL switch route : les autres restent en L2 pur. Appliquer le
|
|
meme devis sur les trois creerait autant de conflits d'adresses que de SVI.
|
|
"""
|
|
return (underlay or {}).get("routeur") or None
|
|
|
|
|
|
def ports_de(hote: dict) -> dict:
|
|
"""Ports physiques declares d'un equipement. {} si aucun.
|
|
|
|
Trois clefs, qui correspondent aux trois natures de lien du devis :
|
|
`hyperviseurs` (liste) ports terminaux vers Proxmox ;
|
|
`frontiere` (liste) port(s) vers le pare-feu de bordure ;
|
|
`rayons` (table) {nom du switch d'acces: port}, cote routeur ;
|
|
`montante` (str) port vers le routeur, cote switch d'acces.
|
|
Non declares, le devis retombe sur ses marqueurs : il reste lisible, il n'est
|
|
simplement pas applicable tel quel.
|
|
"""
|
|
return (hote or {}).get("ports") or {}
|
|
|
|
|
|
def _ports_a_plat(hote: dict) -> list[str]:
|
|
"""Tous les ports d'un equipement, a plat — pour detecter un doublon."""
|
|
p = ports_de(hote)
|
|
plat = [str(x) for x in (p.get("hyperviseurs") or [])]
|
|
plat += [str(x) for x in (p.get("frontiere") or [])]
|
|
plat += [str(v) for v in (p.get("rayons") or {}).values()]
|
|
if p.get("montante"):
|
|
plat.append(str(p["montante"]))
|
|
return plat
|
|
|
|
|
|
# `passerelle_amont` : un routeur que nous N'ADMINISTRONS PAS. Il existe, il porte une
|
|
# adresse, des choses sortent par lui — mais aucun devis ne le configure et il ne recoit
|
|
# aucune regle. Le declarer n'est pas l'adopter : c'est empecher le validateur de crier
|
|
# a la passerelle fantome pour une passerelle qui, elle, est bien reelle.
|
|
ROLES_HOTE = ("switch", "hyperviseur", "frontiere", "passerelle_amont")
|
|
|
|
|
|
def role_hote(hote: dict) -> str:
|
|
"""Nature d'un equipement declare. `switch` par defaut (compatibilite).
|
|
|
|
Le reseau ne suffit pas a la deduire : un hyperviseur partage le reseau de
|
|
management avec les commutateurs, et recevrait sinon leur configuration.
|
|
"""
|
|
return str((hote or {}).get("role") or "switch")
|
|
|
|
|
|
def switches_acces(underlay: dict | None) -> list[dict]:
|
|
"""Les switches d'acces : hotes du reseau de management, hors routeur.
|
|
|
|
Source unique du « qui est un switch d'acces » — utilisee pour leur devis (partie B)
|
|
ET pour les rayons de l'etoile emis cote routeur. Les deux ne peuvent pas diverger.
|
|
|
|
Filtre sur le ROLE autant que sur le reseau : un hyperviseur declare sur le meme
|
|
reseau ne doit recevoir aucune ligne de configuration de commutateur.
|
|
"""
|
|
r_nom = routeur(underlay)
|
|
if not r_nom:
|
|
return []
|
|
fabric = fabric_du_routeur(underlay)
|
|
mgmt = next((r for r in reseaux_de_fabric(underlay, fabric) if r.get("passerelle")), None)
|
|
if not mgmt:
|
|
return []
|
|
return [h for h in hotes(underlay)
|
|
if h.get("nom") != r_nom and h.get("reseau") == mgmt["nom"]
|
|
and role_hote(h) == "switch"]
|
|
|
|
|
|
def reseau_transit(underlay: dict | None) -> dict | None:
|
|
"""Le reseau de TRANSIT vers la frontiere nord/sud, s'il est declare.
|
|
|
|
Convention : c'est le reseau underlay qui porte `passerelle_sortie` — l'adresse
|
|
du pare-feu de bordure sur ce lien. Il vit dans l'underlay et non dans un tenant
|
|
parce qu'il est PARTAGE : la frontiere route vers tous les supernets tenants par
|
|
ce meme saut, il ne peut donc deriver d'aucun `index`.
|
|
"""
|
|
for r in reseaux(underlay):
|
|
if r.get("passerelle_sortie"):
|
|
return r
|
|
return None
|
|
|
|
|
|
def charger_depuis(chemin_fichier: Path) -> dict | None:
|
|
"""Le bloc `underlay` d'un fichier DESIGNE — pour valider un modele, pas le site."""
|
|
if not chemin_fichier.is_file():
|
|
return None
|
|
data = yaml.safe_load(chemin_fichier.read_text(encoding="utf-8")) or {}
|
|
return data.get("underlay") or None
|
|
|
|
|
|
def octet_passerelle(plan_nomenclature: Path | None = None) -> int | None:
|
|
"""Dernier octet reserve aux points de routage, lu dans la nomenclature active.
|
|
|
|
Convention d'exploitation : un routeur porte LE MEME dernier octet sur tous les
|
|
sous-reseaux ou il participe — on retient une adresse, pas treize. Declare une
|
|
seule fois (`reservations.passerelle`), jamais code en dur ici.
|
|
"""
|
|
p = plan_nomenclature or (instance_courante() / "plan" / "nomenclature.yml")
|
|
if not p.is_file():
|
|
return None
|
|
n = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
|
v = (n.get("reservations") or {}).get("passerelle")
|
|
return int(v) if isinstance(v, int) else None
|
|
|
|
|
|
def index_site(underlay: dict | None) -> int | None:
|
|
"""`index` du site — le seed dont derive tout son adressage (D-77). None si absent."""
|
|
v = (underlay or {}).get("underlay", underlay or {}).get("index")
|
|
return int(v) if isinstance(v, int) else None
|
|
|
|
|
|
def _tenants_reperes() -> tuple[set[int], list[str]]:
|
|
"""VLAN et supernets des tenants federes (import paresseux : evite le cycle avec devis)."""
|
|
import devis_reseau
|
|
tenants = devis_reseau.decouvrir()
|
|
vlans = {vlan_de(n["index"], z) for _, _, n in tenants for z in n["categories"]}
|
|
supernets = [supernet_de(n["index"]) for _, _, n in tenants]
|
|
return vlans, supernets
|
|
|
|
|
|
def _erreurs_tenants_declares(underlay: dict | None) -> list[str]:
|
|
"""`tenants` ne nomme que des tenants que la decouverte trouve REELLEMENT.
|
|
|
|
POURQUOI STATIQUEMENT (D-75). Un nom mal orthographie dans `tenants` ne se voit pas :
|
|
le tenant disparait simplement des trois devis du site, qui restent « conformes » sur
|
|
ce qu'il en reste. Sur un site a un seul tenant, la faute de frappe rend un devis VIDE
|
|
— une frontiere sans regle, un commutateur sans VLAN — et rien dans le mot
|
|
« conforme » ne dit qu'on vient de dessiner le vide.
|
|
|
|
L'ecart est entierement lisible sans toucher au materiel : d'un cote une liste de
|
|
noms, de l'autre les dossiers freres. On le dit donc a `make underlay`, pas au
|
|
moment ou l'on pousse dans un boitier.
|
|
"""
|
|
portes = tenants_du_site(underlay)
|
|
if portes is None:
|
|
return [] # clef absente = toute la federation : rien a verifier
|
|
import devis_reseau # import paresseux : meme cycle que _tenants_reperes
|
|
connus = {nom for nom, _pfx, _n in devis_reseau.decouvrir()}
|
|
erreurs: list[str] = []
|
|
for nom in portes:
|
|
if nom in connus:
|
|
continue
|
|
dossier = RACINE.parent / nom
|
|
if not dossier.is_dir():
|
|
erreurs.append(f"tenants: « {nom} » — aucun dossier frere de ce nom "
|
|
f"(attendu : {dossier})")
|
|
elif not (dossier / "plan" / "nomenclature.yml").is_file():
|
|
erreurs.append(f"tenants: « {nom} » — dossier present, mais sans "
|
|
f"plan/nomenclature.yml : ce n'est pas une instance")
|
|
else:
|
|
erreurs.append(f"tenants: « {nom} » — nomenclature presente mais non federee "
|
|
f"(`index` absent, `categories` vide ou `federe: false`)")
|
|
if not (set(portes) & connus):
|
|
erreurs.append("tenants: aucun nom declare ne correspond a un tenant federe — "
|
|
"les devis de ce site n'auraient rien a poser")
|
|
|
|
# --- L'ALLOCATION, ET SA COHERENCE AVEC CE QUE LE TENANT PORTE ---------
|
|
#
|
|
# Le site ALLOUE, le tenant RECOIT. La nomenclature du tenant reste donc le lieu ou
|
|
# l'index se LIT — tout le moteur le lit la — mais elle n'est plus le lieu ou il se
|
|
# DECIDE. Ce qui suit compare les deux et refuse l'ecart : sans cette comparaison,
|
|
# « allouer » ne serait qu'un commentaire.
|
|
brut = (underlay or {}).get("underlay", underlay or {}).get("tenants")
|
|
if isinstance(brut, dict):
|
|
vus: dict[int, str] = {}
|
|
indices = {nom: n["index"] for nom, _pfx, n in devis_reseau.decouvrir()
|
|
if n.get("index") is not None}
|
|
for nom, idx in brut.items():
|
|
if isinstance(idx, bool) or not isinstance(idx, int):
|
|
erreurs.append(f"tenants: « {nom} » — index alloue « {idx!r} » n'est pas "
|
|
f"un entier ; une allocation qui n'en est pas une ne "
|
|
f"protege de rien")
|
|
continue
|
|
if idx in vus:
|
|
erreurs.append(f"tenants: index {idx} alloue a la fois a « {vus[idx]} » "
|
|
f"et a « {nom} » — c'est exactement la collision que "
|
|
f"l'allocation par le site doit rendre impossible")
|
|
vus[idx] = str(nom)
|
|
porte = indices.get(str(nom))
|
|
if porte is not None and porte != idx:
|
|
erreurs.append(
|
|
f"tenants: « {nom} » — le site lui alloue l'index {idx}, mais son "
|
|
f"plan/nomenclature.yml en porte {porte}. Le site decide ; corriger "
|
|
f"le plan du tenant, pas l'allocation.")
|
|
return erreurs
|
|
|
|
|
|
def valider(underlay: dict | None,
|
|
tenants_vlans: set[int] | None = None,
|
|
tenants_supernets: list[str] | None = None,
|
|
plan_nomenclature: Path | None = None) -> list[str]:
|
|
"""Liste des erreurs (vide = conforme). Verifie champs requis, unicite, et
|
|
surtout l'absence de collision avec la plage tenant (VLAN et sous-reseaux)."""
|
|
if underlay is None:
|
|
return [] # absent = rien a valider (le devis omettra la section)
|
|
# Les tenants reperes fournis par l'appelant = validation d'un GABARIT (modeles.py) :
|
|
# un modele decrit du materiel, pas un site deploye, et ne peut pas nommer de tenants
|
|
# reels. Seule la validation d'un SITE confronte `tenants` aux dossiers freres.
|
|
mode_site = tenants_vlans is None or tenants_supernets is None
|
|
if tenants_vlans is None or tenants_supernets is None:
|
|
tenants_vlans, tenants_supernets = _tenants_reperes()
|
|
t_supernets = [ipaddress.ip_network(s, strict=False) for s in tenants_supernets]
|
|
# UN SITE N'A PAS D'INDEX (2026-08-25). Il en portait un, et c'etait un vestige.
|
|
#
|
|
# Cette valeur ne disait pas « voici mon adressage » — le site ne derive rien, ses
|
|
# machines vivent sur un reseau de fabric. Elle disait une seule chose : « QUEL
|
|
# SUPERNET DE TENANT EST LE MIEN », pour autoriser un reseau du site a occuper sa
|
|
# bande basse. C'etait un reste de la coincidence hebergeur↔tenant chez Chezlepro,
|
|
# qui est a la fois les deux — et qui aurait induit en erreur partout ailleurs.
|
|
#
|
|
# L'exception se declare desormais SUR LE RESEAU qui en a besoin, et elle NOMME le
|
|
# tenant dont elle occupe la bande basse. C'est plus vrai (un seul reseau est
|
|
# concerne, pas le site entier) et plus verifiable : le nom se resout dans le registre
|
|
# d'allocation, donc une faute de frappe est refusee au lieu de passer.
|
|
alloc = allocations(underlay)
|
|
|
|
def _supernet_hote(r: dict) -> tuple[ipaddress.IPv4Network | None, str | None]:
|
|
"""Supernet dont ce reseau occupe la bande basse, et l'erreur s'il n'existe pas."""
|
|
nom_t = str(r.get("bande_basse_de") or "").strip()
|
|
if not nom_t:
|
|
return None, None
|
|
if nom_t not in alloc:
|
|
return None, (f"reseau '{r.get('nom', '?')}': `bande_basse_de: {nom_t}` — ce "
|
|
f"nom n'est pas dans le registre `tenants:` du site. Sans "
|
|
f"allocation connue, on ne peut pas savoir quel supernet il "
|
|
f"occupe, ni verifier qu'il reste dans la bande basse.")
|
|
return ipaddress.ip_network(supernet_de(alloc[nom_t]), strict=False), None
|
|
|
|
erreurs: list[str] = []
|
|
if mode_site:
|
|
erreurs += _erreurs_tenants_declares(underlay)
|
|
vus_vlan: dict[int, str] = {}
|
|
vus_sous: list[tuple[ipaddress.IPv4Network, str]] = []
|
|
noms_reseaux: set[str] = set()
|
|
|
|
for r in reseaux(underlay):
|
|
nom = r.get("nom", "?")
|
|
noms_reseaux.add(nom)
|
|
vlan, sous = r.get("vlan"), r.get("sous_reseau")
|
|
# UN SEGMENT PHYSIQUE SEPARE N'A PAS D'ETIQUETTE (mesure du 2026-08-24).
|
|
# Le plan d'administration de Chezlepro arrive sur `igb0` de la frontiere — un
|
|
# PORT A LUI, non etiquete. Lui inventer un `vlan: 10` faisait croire a un VLAN
|
|
# trunke qui n'existe nulle part : trois sondes depuis asgard (bond0 tag 10,
|
|
# bond3 tag 10, vmbr1 natif) sont restees muettes, temoin positif a l'appui.
|
|
# `segment_physique: true` dit cette nature, et dispense d'etiquette.
|
|
if r.get("segment_physique") and vlan is None:
|
|
vlan = None
|
|
elif vlan is None or sous is None:
|
|
erreurs.append(f"reseau '{nom}': 'vlan' et 'sous_reseau' sont requis "
|
|
f"(ou `segment_physique: true` si le reseau n'est pas etiquete)")
|
|
continue
|
|
if sous is None:
|
|
erreurs.append(f"reseau '{nom}': 'sous_reseau' est requis")
|
|
continue
|
|
|
|
elif vlan is not None and (not isinstance(vlan, int) or vlan >= SEUIL_VLAN_TENANT):
|
|
erreurs.append(f"reseau '{nom}': VLAN {vlan} doit etre un entier < {SEUIL_VLAN_TENANT} "
|
|
f"(sinon empiete sur la plage tenant)")
|
|
if vlan is not None and vlan in tenants_vlans:
|
|
erreurs.append(f"reseau '{nom}': VLAN {vlan} deja porte par un tenant federe")
|
|
if vlan is not None and vlan in vus_vlan:
|
|
erreurs.append(f"reseau '{nom}': VLAN {vlan} en double avec '{vus_vlan[vlan]}'")
|
|
if vlan is not None:
|
|
vus_vlan[vlan] = nom
|
|
try:
|
|
net = ipaddress.ip_network(sous, strict=False)
|
|
except ValueError:
|
|
erreurs.append(f"reseau '{nom}': sous_reseau '{sous}' invalide")
|
|
continue
|
|
# D-77 : l'underlay d'un site vit dans la BANDE BASSE de SON propre supernet.
|
|
# Chevaucher celui d'un AUTRE tenant reste une erreur — c'est ce qui empeche deux
|
|
# sites de se joindre. Et deborder sur la bande haute de son propre supernet en
|
|
# est une aussi : on entrerait en collision avec ses propres zones.
|
|
mien, err_bande = _supernet_hote(r)
|
|
if err_bande:
|
|
erreurs.append(err_bande)
|
|
for ts in t_supernets:
|
|
if not net.overlaps(ts):
|
|
continue
|
|
if mien is not None and ts == mien:
|
|
# Le sien : seule la bande basse est permise.
|
|
hauts = [o for o in range(net.network_address.packed[2],
|
|
net.broadcast_address.packed[2] + 1)
|
|
if o >= PREMIER_OCTET_ZONE]
|
|
if hauts:
|
|
erreurs.append(
|
|
f"reseau '{nom}': {sous} deborde sur la bande des ZONES de son "
|
|
f"propre tenant (3e octet {hauts[0]} >= {PREMIER_OCTET_ZONE}). "
|
|
f"L'underlay tient dans 10.x.0-{PREMIER_OCTET_ZONE - 1}.x (D-77).")
|
|
else:
|
|
erreurs.append(f"reseau '{nom}': {sous} chevauche le supernet tenant {ts} "
|
|
f"— c'est celui d'un AUTRE site, les deux ne pourront "
|
|
f"jamais etre relies")
|
|
for autre, autre_nom in vus_sous:
|
|
if net.overlaps(autre):
|
|
erreurs.append(f"reseau '{nom}': {sous} chevauche le reseau underlay '{autre_nom}'")
|
|
if r.get("passerelle"):
|
|
try:
|
|
if ipaddress.ip_address(r["passerelle"]) not in net:
|
|
erreurs.append(f"reseau '{nom}': passerelle {r['passerelle']} hors de {sous}")
|
|
except ValueError:
|
|
erreurs.append(f"reseau '{nom}': passerelle '{r['passerelle']}' invalide")
|
|
# Transit vers la frontiere : la sortie doit etre SUR le lien, et distincte du SVI.
|
|
if r.get("passerelle_sortie"):
|
|
try:
|
|
sortie = ipaddress.ip_address(r["passerelle_sortie"])
|
|
if sortie not in net:
|
|
erreurs.append(f"reseau '{nom}': passerelle_sortie {sortie} hors de {sous}")
|
|
elif r.get("passerelle") and str(sortie) == str(r["passerelle"]):
|
|
erreurs.append(f"reseau '{nom}': passerelle_sortie {sortie} identique au SVI "
|
|
f"(le switch ne peut pas etre sa propre sortie)")
|
|
elif False:
|
|
erreurs.append(f"reseau '{nom}': (inatteignable) "
|
|
f"(le SVI du switch sur le lien de transit)")
|
|
except ValueError:
|
|
erreurs.append(f"reseau '{nom}': passerelle_sortie '{r['passerelle_sortie']}' invalide")
|
|
vus_sous.append((net, nom))
|
|
|
|
for h in hotes(underlay):
|
|
r = (h or {}).get("role")
|
|
if r is not None and str(r) not in ROLES_HOTE:
|
|
erreurs.append(f"hote '{h.get('nom','?')}': role '{r}' inconnu "
|
|
f"(attendu : {', '.join(ROLES_HOTE)})")
|
|
|
|
# Ports physiques : un meme port ne peut pas servir deux fois sur un equipement,
|
|
# et un rayon ne peut viser qu'un switch d'acces reellement declare.
|
|
noms_acces = {h.get("nom") for h in switches_acces(underlay)}
|
|
r_courant = routeur(underlay)
|
|
for h in hotes(underlay):
|
|
hn = h.get("nom", "?")
|
|
plat = _ports_a_plat(h)
|
|
doublons = sorted({p for p in plat if plat.count(p) > 1})
|
|
if doublons:
|
|
erreurs.append(f"hote '{hn}': port(s) declare(s) deux fois : {', '.join(doublons)}")
|
|
p = ports_de(h)
|
|
for cible in (p.get("rayons") or {}):
|
|
if cible not in noms_acces:
|
|
erreurs.append(f"hote '{hn}': rayon vers '{cible}', qui n'est pas un switch d'acces")
|
|
if p.get("rayons") and hn != r_courant:
|
|
erreurs.append(f"hote '{hn}': des `rayons` mais ce n'est pas le routeur")
|
|
if p.get("montante") and hn == r_courant:
|
|
erreurs.append(f"hote '{hn}': une `montante` alors qu'il EST le routeur")
|
|
|
|
# L'INVARIANT DU DERNIER OCTET NE S'APPLIQUE PAS ICI (decide le 2026-08-04).
|
|
#
|
|
# Il vaut pour l'adressage DERIVE des tenants, ou `passerelle_de(index, zone)`
|
|
# produit le meme `.1` dans les treize sous-reseaux d'un tenant : on retient une
|
|
# adresse, pas treize. C'est une propriete de la derivation, pas une regle
|
|
# universelle.
|
|
#
|
|
# Dans l'underlay, l'adressage est dicte par les PARTICIPANTS d'un lien physique.
|
|
# La regle y produisait deux effets pervers : un seuil arbitraire (les sous-reseaux
|
|
# plus etroits qu'un /24 etaient exemptes, donc elargir un /29 changeait la
|
|
# validite du fichier), et une couture entre proprietaires — l'octet attendu venait
|
|
# de la nomenclature d'un TENANT, appliquee a la fabric de l'HEBERGEUR. La validite
|
|
# de l'underlay aurait dependu du tenant actif si l'un d'eux reservait autre chose.
|
|
#
|
|
# Ce qui reste, et qui est plus fort : une passerelle doit etre l'adresse d'un hote
|
|
# DECLARE sur ce reseau (verifie plus bas). Elle attrape les passerelles fantomes,
|
|
# ce que le comptage d'octets ne faisait pas.
|
|
|
|
v = (underlay or {}).get("acl_inter_tenant")
|
|
if v is not None and not isinstance(v, bool):
|
|
erreurs.append(f"acl_inter_tenant doit etre un booleen (recu : {v!r})")
|
|
|
|
rt = (underlay or {}).get("routage_tenants")
|
|
if rt is not None and str(rt) not in ROUTAGE_TENANTS:
|
|
erreurs.append(f"routage_tenants '{rt}' inconnu (attendu : {', '.join(ROUTAGE_TENANTS)})")
|
|
if routage_tenants(underlay) == "sdn":
|
|
# Le transport doit valoir l'overlay DECLARE plus la surcharge d'encapsulation.
|
|
minimal = mtu_overlay(underlay) + SURCOUT_VXLAN
|
|
fab = fabric_du_routeur(underlay)
|
|
for r in reseaux_de_fabric(underlay, fab):
|
|
mtu = int(r.get("mtu") or 1500)
|
|
if mtu < minimal:
|
|
erreurs.append(
|
|
f"reseau '{r.get('nom','?')}': MTU {mtu} < {minimal} "
|
|
f"(overlay {mtu_overlay(underlay)} + {SURCOUT_VXLAN} de VXLAN) — sous ce "
|
|
f"seuil le ping passe et les transferts echouent")
|
|
|
|
d = dialecte(underlay)
|
|
if d and d not in DIALECTES:
|
|
erreurs.append(f"dialecte '{d}' inconnu (attendu : {', '.join(DIALECTES)})")
|
|
|
|
s = stp(underlay)
|
|
if s:
|
|
if str(s.get("mode", "")) not in STP_MODES:
|
|
erreurs.append(f"stp.mode '{s.get('mode')}' inconnu (attendu : {', '.join(STP_MODES)})")
|
|
if str(s.get("topologie", "")) not in STP_TOPOLOGIES:
|
|
erreurs.append(f"stp.topologie '{s.get('topologie')}' inconnue "
|
|
f"(attendu : {', '.join(STP_TOPOLOGIES)})")
|
|
if not routeur(underlay):
|
|
erreurs.append("stp declare sans `routeur` : impossible de designer le pont racine")
|
|
|
|
# Une seule sortie possible : deux transits donneraient deux routes par defaut.
|
|
transits = [r.get("nom", "?") for r in reseaux(underlay) if r.get("passerelle_sortie")]
|
|
if len(transits) > 1:
|
|
erreurs.append(f"un seul reseau de transit autorise (passerelle_sortie), "
|
|
f"trouves : {', '.join(transits)}")
|
|
|
|
# Le routeur designe doit etre un hote declare : sinon le devis ne saurait pas
|
|
# quelle adresse de gestion donner aux switches d'acces.
|
|
r_nom = routeur(underlay)
|
|
if r_nom and r_nom not in {h.get("nom") for h in hotes(underlay)}:
|
|
erreurs.append(f"routeur '{r_nom}': aucun hote de ce nom n'est declare")
|
|
# La passerelle d'un reseau doit APPARTENIR A UN HOTE DECLARE de ce reseau.
|
|
#
|
|
# Cette regle en remplace une plus etroite, qui exigeait que la passerelle soit
|
|
# l'adresse du switch routeur. Elle etait vraie tant qu'un switch routait ; depuis
|
|
# que la frontiere est le seul equipement L3 (2026-08-04), elle refusait la seule
|
|
# configuration correcte. La nouvelle est PLUS forte : elle attrape aussi une
|
|
# passerelle qui ne correspond a rien — une adresse inventee, un octet de trop.
|
|
par_reseau: dict[str, list[dict]] = {}
|
|
for h in hotes(underlay):
|
|
if h.get("reseau"):
|
|
par_reseau.setdefault(h["reseau"], []).append(h)
|
|
for r in reseaux(underlay):
|
|
for cle, sens in (("passerelle", "passerelle"), ("passerelle_sortie", "sortie")):
|
|
adr = r.get(cle)
|
|
if not adr:
|
|
continue
|
|
porteurs = [h for h in par_reseau.get(r.get("nom"), []) if str(h.get("ip")) == str(adr)]
|
|
if not porteurs:
|
|
erreurs.append(
|
|
f"reseau '{r.get('nom','?')}': {sens} {adr} n'est l'adresse d'aucun hote "
|
|
f"declare sur ce reseau — passerelle fantome")
|
|
|
|
par_nom = {r.get("nom"): r for r in reseaux(underlay)}
|
|
for h in hotes(underlay):
|
|
hn = h.get("nom", "?")
|
|
r = par_nom.get(h.get("reseau"))
|
|
if not r:
|
|
erreurs.append(f"hote '{hn}': reseau '{h.get('reseau')}' inconnu")
|
|
continue
|
|
try:
|
|
if ipaddress.ip_address(h["ip"]) not in ipaddress.ip_network(r["sous_reseau"], strict=False):
|
|
erreurs.append(f"hote '{hn}': IP {h.get('ip')} hors de {r['sous_reseau']}")
|
|
except (ValueError, KeyError):
|
|
erreurs.append(f"hote '{hn}': IP absente ou invalide")
|
|
|
|
# --- LES MACHINES DE L'HEBERGEUR ----------------------------------------
|
|
#
|
|
# Rien ici ne se derive : tout est declare, et donc tout se verifie. La garde est
|
|
# plus stricte que celle des tenants pour cette raison meme — un tenant qui se trompe
|
|
# d'adresse ne peut pas, sa nomenclature le lui interdit ; un site, si.
|
|
occupees = {h.get("ip"): h.get("nom", "?") for h in hotes(underlay)}
|
|
vus_vmid: dict[int, str] = {}
|
|
noms_hyperviseurs = {h.get("nom") for h in hotes(underlay)
|
|
if h.get("role") == "hyperviseur"}
|
|
for m in machines(underlay):
|
|
mn = m.get("nom", "?")
|
|
r = par_nom.get(m.get("reseau"))
|
|
if not r:
|
|
erreurs.append(f"machine '{mn}': reseau '{m.get('reseau')}' inconnu")
|
|
continue
|
|
# LE PONT EST LA QUESTION QU'ON NE PEUT PAS ESQUIVER. Une machine se rattache a un
|
|
# pont d'hyperviseur ; un reseau que AUCUN pont ne porte ne peut voir naitre
|
|
# aucune VM. C'est le cas mesure du plan d'administration de Chezlepro : la
|
|
# frontiere l'a sur un port a elle, les hyperviseurs n'y touchent pas. Refuser ici
|
|
# est la seule facon de ne pas creer une VM sourde.
|
|
if not str(r.get("pont", "") or "").strip():
|
|
erreurs.append(
|
|
f"machine '{mn}': le reseau '{r.get('nom')}' ne declare aucun `pont:` — "
|
|
f"aucun pont d'hyperviseur ne le porte, une VM y naitrait sourde")
|
|
if m.get("noeud") not in noms_hyperviseurs:
|
|
erreurs.append(f"machine '{mn}': noeud '{m.get('noeud')}' n'est pas un "
|
|
f"hyperviseur declare dans `hotes`")
|
|
try:
|
|
ip = ipaddress.ip_address(m["ip"])
|
|
if ip not in ipaddress.ip_network(r["sous_reseau"], strict=False):
|
|
erreurs.append(f"machine '{mn}': IP {m.get('ip')} hors de {r['sous_reseau']}")
|
|
except (ValueError, KeyError):
|
|
erreurs.append(f"machine '{mn}': IP absente ou invalide")
|
|
else:
|
|
if str(ip) in occupees:
|
|
erreurs.append(f"machine '{mn}': IP {ip} deja prise par "
|
|
f"'{occupees[str(ip)]}'")
|
|
occupees[str(ip)] = mn
|
|
vmid = m.get("vmid")
|
|
if not isinstance(vmid, int):
|
|
erreurs.append(f"machine '{mn}': `vmid` absent ou non entier")
|
|
elif vmid in vus_vmid:
|
|
erreurs.append(f"machine '{mn}': vmid {vmid} en double avec '{vus_vmid[vmid]}'")
|
|
else:
|
|
vus_vmid[vmid] = mn
|
|
if not (m.get("services") or []):
|
|
erreurs.append(f"machine '{mn}': aucun `services:` — une machine du site "
|
|
f"existe POUR un role, sinon elle n'a pas lieu d'etre")
|
|
return erreurs
|
|
|
|
|
|
def verifier() -> int:
|
|
underlay = charger()
|
|
if underlay is None:
|
|
print("Underlay : aucun fichier (underlay.yml absent) — section omise, rien a prouver.")
|
|
return 0
|
|
erreurs = valider(underlay)
|
|
if erreurs:
|
|
print("Underlay NON conforme :")
|
|
for e in erreurs:
|
|
print(f" - {e}")
|
|
return 1
|
|
n = len(reseaux(underlay))
|
|
print(f"Underlay conforme : {n} reseau(x), aucune collision avec la plage tenant.")
|
|
return 0
|
|
|
|
|
|
def afficher() -> None:
|
|
underlay = charger()
|
|
if underlay is None:
|
|
print("Aucun underlay defini (underlay.yml absent). Copier underlay.yml.example pour commencer.")
|
|
return
|
|
print(f"Underlay (fabric physique, cluster-global) — source : {chemin()}")
|
|
print(f"{'RESEAU':<16}{'VLAN':>6} {'SOUS-RESEAU':<18}{'PASSERELLE':<15}{'MTU':>6} DESCRIPTION")
|
|
for r in reseaux(underlay):
|
|
print(f"{r.get('nom','?'):<16}{str(r.get('vlan','?')):>6} "
|
|
f"{r.get('sous_reseau','?'):<18}{str(r.get('passerelle') or '-'):<15}"
|
|
f"{str(r.get('mtu') or '-'):>6} {r.get('description','')}")
|
|
if hotes(underlay):
|
|
print("\nHotes fixes documentes :")
|
|
for h in hotes(underlay):
|
|
print(f" {h.get('nom','?'):<16} {h.get('ip','?'):<15} ({h.get('reseau','?')})")
|
|
erreurs = valider(underlay)
|
|
print("\n" + ("Conforme (aucune collision tenant)." if not erreurs
|
|
else "ERREURS :\n - " + "\n - ".join(erreurs)))
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("--verifier", action="store_true", help="valide l'underlay (P23), code de sortie")
|
|
args = ap.parse_args()
|
|
if args.verifier:
|
|
sys.exit(verifier())
|
|
afficher()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|