diff --git a/CHANGELOG.md b/CHANGELOG.md index fc7b3c5..2db18cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,57 @@ # 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 Un enregistrement du panneau « Intrants de base », à 13:48, a emporté **94 lignes de diff --git a/docs/implanter-un-tenant-sur-un-site.md b/docs/implanter-un-tenant-sur-un-site.md index 1966baa..119097d 100644 --- a/docs/implanter-un-tenant-sur-un-site.md +++ b/docs/implanter-un-tenant-sur-un-site.md @@ -208,6 +208,12 @@ underlay: # 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. 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-] routeur: # racine du spanning-tree, pas un routeur # `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 diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index 686df8f..697c29d 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -51,7 +51,7 @@ from resoudre_flux import ( # noqa: E402 # `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 # 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 # par tous les tenants, il ne peut deriver d'aucun index. Source unique, pas un intrant. 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.") 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: print("Aucune instance federee trouvee (../*/plan/nomenclature.yml).", file=sys.stderr) 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) if args.verifier: diff --git a/scripts/devis_reseau.py b/scripts/devis_reseau.py index de7772a..fd36583 100644 --- a/scripts/devis_reseau.py +++ b/scripts/devis_reseau.py @@ -451,14 +451,17 @@ def admin_de(nom_instance: str) -> 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, 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. 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: @@ -569,6 +572,44 @@ def decouvrir() -> list[tuple[str, str, dict]]: 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: dialecte = dialecte_effectif(dialecte) underlay = underlay_mod.charger() @@ -756,7 +797,7 @@ def main() -> None: ap.add_argument("--dialecte", choices=DIALECTES, default=None, help="CLI du commutateur (defaut : SETOPS_DIALECTE, sinon underlay.dialecte)") args = ap.parse_args() - tenants = decouvrir() + tenants = decouvrir_du_site() # le commutateur ne porte que les tenants de son site if not tenants: print("Aucune instance 'ip-miroir' federee trouvee (../*/plan/nomenclature.yml).", file=sys.stderr) diff --git a/scripts/devis_sdn.py b/scripts/devis_sdn.py index cc65e7e..1c85f04 100644 --- a/scripts/devis_sdn.py +++ b/scripts/devis_sdn.py @@ -64,7 +64,7 @@ RACINE = Path(__file__).resolve().parents[1] sys.path.insert(0, str(RACINE / "scripts")) 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 @@ -334,7 +334,8 @@ def main(argv: list[str]) -> int: ap.add_argument("--verifier", action="store_true") 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: zones = devis["zones"] if not zones: diff --git a/underlay.yml.example b/underlay.yml.example index 84d3060..7fc0399 100644 --- a/underlay.yml.example +++ b/underlay.yml.example @@ -17,6 +17,20 @@ # Storage/Ceph restent en general L2 pur (pas de passerelle). --- underlay: + # LES TENANTS QUE CE SITE PORTE — noms de DOSSIER des instances (../OPS-). + # + # 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). # 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.