portee : les trois devis d'un site partagent enfin la meme regle

Le commit du 2026-08-14 nommait lui-meme ce qui restait : « meme hypothese ailleurs, non
corrigee — devis_sdn et devis_reseau partent du meme decouvrir(). A traiter quand ils
serviront sur un second site. » C'est fait AVANT, pas pendant la visite.

Les trois devis equipent le MATERIEL d'un site : la frontiere (regles, routes), le
commutateur (VLAN, SVI, routes) et le SDN de l'hyperviseur (zones, VNets). Un tenant
d'ailleurs y ajoutait des objets que le materiel accepte, qui ne correspondent jamais a
rien, et que rien ne signale.

UNE SEULE FONCTION AU LIEU D'UN FILTRE RECOPIE TROIS FOIS :
`devis_reseau.decouvrir_du_site()` = decouvrir() restreint par `underlay.tenants`, la
doctrine ecrite une fois. Le filtre inline de devis_opnsense est retire au profit d'elle.
`admin_tous_tenants()` la suit : le routeur d'un site n'a pas a savoir revenir vers le
plan de gestion d'un tenant qu'il ne porte pas.

EPROUVE dans les trois situations : underlay sans la cle -> les deux tenants, comme avant ;
underlay du second site -> OPS-Technolibre seul ; nom declare qu'aucun dossier ne fournit
-> ATTENTION et le reste est retenu ; filtre qui ne retient rien -> refus, code 1.

SANS EFFET SUR LE SITE ACTUEL : l'underlay de Chezlepro ne declare pas `tenants`, et cle
absente = toute la federation (verifie : decouvrir() et decouvrir_du_site() rendent la
meme liste ici).

ET LA CLE EST ENFIN DOCUMENTEE — c'etait le vrai trou. `underlay.tenants` existait depuis
le 14 sans figurer ni dans underlay.yml.example ni dans l'annexe du runbook
d'implantation : indecouvrable pour qui monte un second site.

prouver 37 OK, 0 echec, 0 saute ; make test inchange.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-18 16:38:30 -04:00
parent 577c78e659
commit 3445ccb836
6 changed files with 124 additions and 24 deletions

View file

@ -1,5 +1,57 @@
# CHANGELOG — Set-OPS # CHANGELOG — Set-OPS
## 2026-08-18 — Les trois devis d'un site partagent enfin la même portée
Le 14 août, la frontière a appris qu'elle ne police que les tenants de **son** site. Le
commit le disait lui-même : *« même hypothèse ailleurs, non corrigée — `devis_sdn` et
`devis_reseau` partent du même `decouvrir()`. À traiter quand ils serviront sur un second
site. »* C'est fait avant, pas pendant.
Les trois devis équipent le **matériel d'un site** :
| devis | ce qu'il pose | ce qu'un tenant d'ailleurs y ajoutait |
|---|---|---|
| `devis_opnsense` | règles et routes de la frontière | des routes vers des sous-réseaux inexistants |
| `devis_reseau` | VLAN, SVI, routes du commutateur | des VLAN qu'aucune VM ne peuplera |
| `devis_sdn` | zones et VNets EVPN de l'hyperviseur | des zones sans machine |
Aucun de ces objets ne fait de mal visible : le matériel les accepte, ils ne
correspondent jamais à rien, et rien ne les signale. C'est la définition même du chèque
vert sur un périmètre vide — sauf qu'ici, il faut le lire à l'envers : une politique qui
a l'air complète et ne protège rien.
### Une seule fonction, au lieu d'un filtre recopié trois fois
`devis_reseau.decouvrir_du_site()` — `decouvrir()` restreint par `underlay.tenants`, avec
la doctrine écrite une fois pour les trois. Le filtre inline de `devis_opnsense` est
retiré au profit d'elle. `admin_tous_tenants()` la suit : le routeur d'un site n'a aucune
raison de savoir revenir vers le plan de gestion d'un tenant qu'il ne porte pas.
Éprouvé dans les trois situations qui comptent :
```
underlay sans la clé → ['OPS-Chezlepro', 'OPS-Technolibre'] (identique à avant)
underlay du second site → ['OPS-Technolibre']
un nom qu'aucun dossier ne fournit → ATTENTION, et le reste est retenu
le filtre ne retient rien → refus, code 1 (jamais un devis vide)
```
**Sans effet sur le site actuel** : l'underlay de Chezlepro ne déclare pas `tenants`, et
clé absente = toute la fédération. `prouver` 37/37, `make test` inchangé.
### Et la clé est enfin documentée
C'était le vrai trou : `underlay.tenants` existait depuis le 14 et n'apparaissait **ni**
dans `underlay.yml.example` **ni** dans l'annexe du runbook d'implantation. Un exploitant
montant un second site ne pouvait pas la découvrir — il aurait posé la politique du
premier tenant chez le second, et le seul symptôme aurait été un silence.
> **Traiter la deuxième occurrence quand on nomme la première.** Le défaut de portée
> était écrit noir sur blanc dans le commit du 14, avec la liste des endroits où il
> restait. Quatre jours plus tard, le coût de le finir est d'une heure ; sur place, il
> aurait coûté une visite.
## 2026-08-18 — Le panneau d'intrants effaçait la mémoire écrite du dépôt ## 2026-08-18 — Le panneau d'intrants effaçait la mémoire écrite du dépôt
Un enregistrement du panneau « Intrants de base », à 13:48, a emporté **94 lignes de Un enregistrement du panneau « Intrants de base », à 13:48, a emporté **94 lignes de

View file

@ -208,6 +208,12 @@ underlay:
# la bande basse lui appartient. Sans elle, `make underlay` refuse le réseau de # la bande basse lui appartient. Sans elle, `make underlay` refuse le réseau de
# gestion en le prenant pour celui d'un AUTRE site — message déroutant, cause triviale. # gestion en le prenant pour celui d'un AUTRE site — message déroutant, cause triviale.
index: <index> index: <index>
# LES TENANTS QUE CE SITE PORTE — noms de dossier, pas de fantaisie. Les trois devis
# d'équipement (frontière, commutateur, SDN) découvrent TOUTE la fédération : sans
# cette clé, le second site se voit proposer les règles, les VLAN et les zones du
# premier. Le matériel les accepte, aucune ne correspond jamais à un paquet, et rien
# ne le signale. Un site UNIQUE n'a rien à déclarer ; c'est le second qui se nomme.
tenants: [OPS-<tenant>]
routeur: <nom-du-commutateur> # racine du spanning-tree, pas un routeur routeur: <nom-du-commutateur> # racine du spanning-tree, pas un routeur
# `stp` exige `routeur` : ne pas le déclarer tant qu'aucun commutateur ne l'est. # `stp` exige `routeur` : ne pas le déclarer tant qu'aucun commutateur ne l'est.
routage_tenants: sdn # le routage inter-zone vit sur l'hyperviseur routage_tenants: sdn # le routage inter-zone vit sur l'hyperviseur

View file

@ -51,7 +51,7 @@ from resoudre_flux import ( # noqa: E402
# `inventaire_de` / `admin_de` viennent de devis_reseau : les routes de retour du # `inventaire_de` / `admin_de` viennent de devis_reseau : les routes de retour du
# switch et les regles de la frontiere doivent lire les MEMES tenants, sinon l'un # switch et les regles de la frontiere doivent lire les MEMES tenants, sinon l'un
# certifie des routes que l'autre n'emet pas. # certifie des routes que l'autre n'emet pas.
from devis_reseau import admin_de, decouvrir, inventaire_de, prefixe # noqa: E402 from devis_reseau import admin_de, decouvrir, decouvrir_du_site, inventaire_de, prefixe # noqa: E402
# Le lien de transit (et donc le prochain saut) vit dans l'underlay : il est PARTAGE # Le lien de transit (et donc le prochain saut) vit dans l'underlay : il est PARTAGE
# par tous les tenants, il ne peut deriver d'aucun index. Source unique, pas un intrant. # par tous les tenants, il ne peut deriver d'aucun index. Source unique, pas un intrant.
import underlay as underlay_mod # noqa: E402 import underlay as underlay_mod # noqa: E402
@ -792,28 +792,14 @@ def main(argv: list[str]) -> int:
ap.add_argument("--verifier", action="store_true", help="Valide le devis sans l'afficher.") ap.add_argument("--verifier", action="store_true", help="Valide le devis sans l'afficher.")
args = ap.parse_args(argv) args = ap.parse_args(argv)
tenants = decouvrir() # UNE FRONTIERE NE POLICE QUE LES TENANTS DE SON SITE (2026-08-14). Le filtre est
# devenu la source unique des trois devis qui equipent un site — commutateur, SDN et
# frontiere : cf. `devis_reseau.decouvrir_du_site`.
tenants = decouvrir_du_site()
if not tenants: if not tenants:
print("Aucune instance federee trouvee (../*/plan/nomenclature.yml).", file=sys.stderr) print("Aucune instance federee trouvee (../*/plan/nomenclature.yml).", file=sys.stderr)
return 1 return 1
# UNE FRONTIERE NE POLICE QUE LES TENANTS DE SON SITE. `decouvrir()` rend toute la
# federation ; l'hebergeur declare dans SON underlay ceux qu'il porte reellement.
# Sans ce filtre, la frontiere du second site recevait les regles et les routes du
# premier — acceptees par le boitier, jamais correspondantes, jamais signalees.
portes = underlay_mod.tenants_du_site(underlay_mod.charger())
if portes is not None:
connus = {nom for nom, _, _ in tenants}
for nom in portes:
if nom not in connus:
print(f"ATTENTION : `tenants` de l'underlay nomme « {nom} », "
f"qu'aucun dossier frere ne fournit.", file=sys.stderr)
tenants = [t for t in tenants if t[0] in set(portes)]
if not tenants:
print("Aucun tenant heberge sur ce site : `underlay.tenants` n'en retient "
"aucun de connu.", file=sys.stderr)
return 1
devis = construire(tenants) devis = construire(tenants)
if args.verifier: if args.verifier:

View file

@ -451,14 +451,17 @@ def admin_de(nom_instance: str) -> list[str]:
def admin_tous_tenants() -> list[str]: def admin_tous_tenants() -> list[str]:
"""Union des reseaux d'administration de TOUS les tenants federes. """Union des reseaux d'administration des tenants heberges sur CE SITE.
Le switch routeur est partage : il doit savoir revenir vers chaque plan de gestion, Le switch routeur est partage : il doit savoir revenir vers chaque plan de gestion,
pas seulement celui de l'instance active. Router n'est pas autoriser — le pas seulement celui de l'instance active. Router n'est pas autoriser — le
cloisonnement se fait a la frontiere, par un alias distinct par tenant. cloisonnement se fait a la frontiere, par un alias distinct par tenant.
SOURCE UNIQUE : `devis_opnsense` importe cette fonction plutot que d'en refaire une. SOURCE UNIQUE : `devis_opnsense` importe cette fonction plutot que d'en refaire une.
Bornee au site depuis le 2026-08-18 : le routeur d'un site n'a aucune raison de
savoir revenir vers le plan de gestion d'un tenant qu'il ne porte pas.
""" """
return sorted({c for nom, _pfx, _n in decouvrir() for c in admin_de(nom)}) return sorted({c for nom, _pfx, _n in decouvrir_du_site() for c in admin_de(nom)})
def route_statique(reseau: str, saut: str, dialecte: str) -> str: def route_statique(reseau: str, saut: str, dialecte: str) -> str:
@ -569,6 +572,44 @@ def decouvrir() -> list[tuple[str, str, dict]]:
return tenants return tenants
def decouvrir_du_site() -> list[tuple[str, str, dict]]:
"""Les tenants que CE SITE heberge — `decouvrir()` restreint par `underlay.tenants`.
POURQUOI CE FILTRE (mesure du 2026-08-14, sur le second site). `decouvrir()` rend
TOUTE la federation : tout dossier frere portant une nomenclature 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, c'est faux : `make frontiere-plan` voulait poser sur la
frontiere de Technolibre les regles ET les routes de Chezlepro, dont six routes vers
des sous-reseaux qui n'existent pas la-bas. Le boitier les aurait acceptees, aucune
n'aurait jamais correspondu a un paquet, et rien ne l'aurait signale.
Le meme raisonnement vaut pour les DEUX autres devis qui equipent un site : le
commutateur ne porte que les VLAN et les SVI des tenants poses dessus, et le SDN de
l'hyperviseur ne cree que leurs zones. Un objet provisionne pour un tenant absent est
au mieux inutile, au pire un chemin ouvert que personne ne relit.
CLE ABSENTE = toute la federation : un site unique n'a rien a declarer, c'est le
second qui doit se nommer. Un nom declare qu'aucun dossier frere ne fournit est
signale, pas ignore. Un filtre qui ne retient RIEN est un refus, pas un devis vide.
"""
tenants = decouvrir()
if not tenants:
return tenants # « aucune instance federee » : au caller de le dire
portes = underlay_mod.tenants_du_site(underlay_mod.charger())
if portes is None:
return tenants
connus = {nom for nom, _pfx, _n in tenants}
for nom in portes:
if nom not in connus:
print(f"ATTENTION : `tenants` de l'underlay nomme « {nom} », "
f"qu'aucun dossier frere ne fournit.", file=sys.stderr)
retenus = [t for t in tenants if t[0] in set(portes)]
if not retenus:
raise SystemExit("Aucun tenant heberge sur ce site : `underlay.tenants` n'en "
"retient aucun de connu.")
return retenus
def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -> str: def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -> str:
dialecte = dialecte_effectif(dialecte) dialecte = dialecte_effectif(dialecte)
underlay = underlay_mod.charger() underlay = underlay_mod.charger()
@ -756,7 +797,7 @@ def main() -> None:
ap.add_argument("--dialecte", choices=DIALECTES, default=None, ap.add_argument("--dialecte", choices=DIALECTES, default=None,
help="CLI du commutateur (defaut : SETOPS_DIALECTE, sinon underlay.dialecte)") help="CLI du commutateur (defaut : SETOPS_DIALECTE, sinon underlay.dialecte)")
args = ap.parse_args() args = ap.parse_args()
tenants = decouvrir() tenants = decouvrir_du_site() # le commutateur ne porte que les tenants de son site
if not tenants: if not tenants:
print("Aucune instance 'ip-miroir' federee trouvee (../*/plan/nomenclature.yml).", print("Aucune instance 'ip-miroir' federee trouvee (../*/plan/nomenclature.yml).",
file=sys.stderr) file=sys.stderr)

View file

@ -64,7 +64,7 @@ RACINE = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(RACINE / "scripts")) sys.path.insert(0, str(RACINE / "scripts"))
from inventory_rules import passerelle_de, sous_reseau_de, supernet_de, vlan_de # noqa: E402 from inventory_rules import passerelle_de, sous_reseau_de, supernet_de, vlan_de # noqa: E402
from devis_reseau import decouvrir # noqa: E402 from devis_reseau import decouvrir_du_site # noqa: E402
import underlay as underlay_mod # noqa: E402 import underlay as underlay_mod # noqa: E402
@ -334,7 +334,8 @@ def main(argv: list[str]) -> int:
ap.add_argument("--verifier", action="store_true") ap.add_argument("--verifier", action="store_true")
args = ap.parse_args(argv) args = ap.parse_args(argv)
devis = construire(decouvrir()) # Le SDN d'un hyperviseur ne cree que les zones des tenants qu'il porte.
devis = construire(decouvrir_du_site())
if args.verifier: if args.verifier:
zones = devis["zones"] zones = devis["zones"]
if not zones: if not zones:

View file

@ -17,6 +17,20 @@
# Storage/Ceph restent en general L2 pur (pas de passerelle). # Storage/Ceph restent en general L2 pur (pas de passerelle).
--- ---
underlay: underlay:
# LES TENANTS QUE CE SITE PORTE — noms de DOSSIER des instances (../OPS-<nom>).
#
# Les trois devis qui equipent un site (frontiere, commutateur, SDN) decouvrent TOUTE
# la federation : tout dossier frere portant une nomenclature avec un index. C'est
# juste tant qu'il n'y a qu'un site ; des le second, le devis du site B veut y poser
# les regles, les VLAN et les zones du tenant du site A. Le materiel les accepte,
# aucune ne correspond jamais a un paquet, et rien ne le signale (mesure du
# 2026-08-14 : trente objets de trop, dont six routes vers des reseaux inexistants).
#
# ABSENTE = toute la federation : un site UNIQUE n'a rien a declarer, c'est le second
# qui doit se nommer. Un nom qu'aucun dossier frere ne fournit est signale ; un filtre
# qui ne retient aucun tenant connu est un refus, pas un devis vide.
#tenants: [OPS-monorganisation]
# FABRICS PHYSIQUES. Chaque reseau appartient a une fabric (`principal` par defaut). # FABRICS PHYSIQUES. Chaque reseau appartient a une fabric (`principal` par defaut).
# Deux fabrics ne partagent aucun cable : le devis d'une fabric ne declare ni ne # Deux fabrics ne partagent aucun cable : le devis d'une fabric ne declare ni ne
# transporte les VLAN d'une autre, et leurs spanning-tree sont independants. # transporte les VLAN d'une autre, et leurs spanning-tree sont independants.