federation : c'est le SITE qui determine l'index d'un tenant

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>
This commit is contained in:
Daniel Allaire 2026-08-25 10:42:14 -04:00
parent fcde343ef0
commit a530d5f70e
3 changed files with 114 additions and 10 deletions

View file

@ -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.<index>.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

View file

@ -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. |

View file

@ -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