SITE et OPS sont deux classes distinctes. Le site decide de la fabric et du reseau et PRODUIT les intrants ; le tenant les consomme et n'a d'intelligence que sur ses applications, leur configuration et leurs integrations. Une valeur reseau qu'un OPS decide est une valeur mal placee. L'index est l'intrant reseau par excellence — supernet, sous-reseaux de zone, VLAN, VMID, noms de VNet en descendent. Il etait declare DEUX FOIS : dans la nomenclature du tenant et dans l'underlay du site. Et le site ne declarait meme pas qui il heberge : la cle `tenants:` existait dans le code, jamais dans la carte — la decouverte se faisait par balayage des dossiers freres. `tenants:` devient un registre d'allocation (nom -> index). Le site alloue, le tenant recoit, la nomenclature n'est plus que la copie verifiable d'une decision prise ailleurs. Quatre gardes neuves, chacune eprouvee par un controle negatif, sous P23. Un tenant qui s'emancipe recoit un index NEUF de son nouveau site : l'index n'est pas une propriete du tenant, c'est une place sur une fabric. 42 preuves vertes, quatre devis du panneau OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
794 lines
38 KiB
Python
794 lines
38 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]
|
|
# Sans `index` declare, on ignore quel supernet est le SIEN : on retombe alors sur la
|
|
# regle stricte d'avant D-77 (aucun chevauchement), qui reste sure.
|
|
idx_site = index_site(underlay)
|
|
|
|
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 = ipaddress.ip_network(supernet_de(idx_site), strict=False) \
|
|
if idx_site is not None else None
|
|
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()
|