diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cce2b5..b32c8e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # CHANGELOG — Set-OPS +## 2026-08-25 — C'est le SITE qui détermine l'index d'un tenant + +**SITE et OPS sont deux classes distinctes.** Le site décide de la *fabric* et du *réseau* +et **produit les intrants** ; le tenant les consomme et n'a d'intelligence que sur ses +*applications*, leur configuration et leurs intégrations. Une valeur réseau qu'un OPS +**décide** est une valeur mal placée. + +Or l'index est l'intrant réseau par excellence : de lui descendent le supernet +`10..0.0/16`, les sous-réseaux de zone, les VLAN, les VMID, les noms de VNet SDN. +Un tenant qui le décide décide du plan d'adressage de la fabric. + +### Ce qui n'allait pas + +Il était déclaré **deux fois** — dans `plan/nomenclature.yml` du tenant *et* dans +l'underlay du site. Deux sources de vérité pour le même nombre, dans deux classes +différentes. Et le site ne déclarait même pas **qui il héberge** : la clé `tenants:` +existait dans le code, avec une docstring qui décrivait précisément le danger, mais +n'était écrite nulle part — la découverte se faisait par balayage des dossiers frères, +c'est-à-dire par accident du système de fichiers. + +### Le sens de la flèche change, pas les valeurs + +`tenants:` devient un **registre d'allocation** : `OPS-Chezlepro: 17`, +`OPS-Technolibre: 23`, `OPS-Patient0: 29`. Le site alloue, le tenant reçoit, et la +nomenclature du tenant n'est plus que la copie vérifiable d'une décision prise ailleurs. + +Quatre gardes neuves, chacune éprouvée par un contrôle négatif : une allocation qui +contredit le plan du tenant, deux tenants sur le même index, une allocation qui n'est pas +un entier, un tenant nommé qui n'existe pas. Elles vivent dans `underlay.valider()`, donc +sous **P23** — aucune preuve à ajouter. + +La forme héritée (simple liste de noms) reste acceptée : elle dit « ces tenants sont ici » +sans rien allouer, pour un site qui n'a pas encore migré. + +### Et l'émancipation + +Un tenant qui part chez un autre hébergeur **reçoit un index neuf de son nouveau site**. +L'index n'est pas une propriété du tenant : c'est une **place sur une fabric**. + ## 2026-08-25 — La vue Réseau du panneau était vide : un `KeyError` deux couches plus bas `segment_physique: true` — introduit le matin même pour dire qu'un réseau n'a pas diff --git a/docs/audit/preuve-2026-08-25.md b/docs/audit/preuve-2026-08-25.md index ea68115..ffec883 100644 --- a/docs/audit/preuve-2026-08-25.md +++ b/docs/audit/preuve-2026-08-25.md @@ -6,7 +6,7 @@ > [`docs/audit/README.md`](README.md), et le registre trace : > [`docs/audit/affirmations.md`](affirmations.md). -- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml` +- **Instance** : `instance` — inventaire `instance/inventories/production/hosts.yml` - **Verdict** : ✅ CONFORME (42 OK · 0 echec · 0 saute) ## Preuves @@ -23,32 +23,32 @@ | P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 36 groupes classes, aucun cycle, aucune arete en arriere. | | P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 34 rôles, 91 flux, schéma + matrice OK. | | P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). | -| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | playbook: playbooks/proxmox/cloner_vm_debian.yml | +| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_oauth2_proxy | | P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. | | P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. | | P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. | | P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. | -| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 15 hotes, 35 groupes (inventaire dechiffre et parse). | +| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 5 hotes, 14 groupes (inventaire dechiffre et parse). | | P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. | -| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 23 secret(s) exige(s), tous presents. Voute reelle : 25 cle(s), aucun manque. | -| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. | +| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 18 secret(s) exige(s), tous presents. Voute reelle : 21 cle(s), aucun manque. | +| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 14 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. | | P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. | | P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 3 instance(s) federee(s), aucun index en collision. | | P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). | | P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 8 reseau(x), aucune collision avec la plage tenant. | | P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 81 regles, 15 routes, admin=10.0.0.0/24,10.17.0.0/24,10.29.19.41/32,192.168.254.2/32,192.168.255.2/32. | | P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 50 groupe(s), 79 regle(s). | -| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 15 hote(s) x 5 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). | +| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 5 hote(s) x 5 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). | | P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. | | P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. | | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 28 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 17, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv | | P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. | | P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 49 scripts expliques et atteignables, 102 cibles make documentees, 60 roles avec README. | -| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (125 cle(s) declaree(s) par l'instance). | -| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (37 groupes). | +| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 13 exigence(s) de role, toutes satisfaites (39 cle(s) declaree(s) par l'instance). | +| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (16 groupes). | | P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (23 genere(s) exempte(s)). | -| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 5 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). | -| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). | +| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 0 application(s) exigeant une base l'ont toutes (0 entree(s) au registre). | +| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 2 hote(s) detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). | | P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. | | P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 35 role(s) serveur/client tous nommes, 36 groupe(s) cite(s) en table existent tous. | | P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. | diff --git a/scripts/underlay.py b/scripts/underlay.py index 6d8f92c..8f61132 100644 --- a/scripts/underlay.py +++ b/scripts/underlay.py @@ -170,9 +170,45 @@ def tenants_du_site(underlay: dict | None) -> list[str] | None: 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). @@ -396,6 +432,35 @@ def _erreurs_tenants_declares(underlay: dict | None) -> list[str]: 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