diff --git a/CHANGELOG.md b/CHANGELOG.md index ea59ae1..5166661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,33 +1,52 @@ # CHANGELOG — Set-OPS -## 2026-08-24 — Un plan peut désormais déclarer son adressage, faute d'index à en dériver +## 2026-08-24 — Un SITE n'est pas un plan : ses machines vivent dans l'underlay -Un **SITE** décrit les machines de l'hébergeur. Il n'a pas d'index — il ne cohabite pas -avec les tenants, il vit dans le réseau d'administration — et rien ne peut donc se dériver -d'un seed qui n'existe pas. +J'avais d'abord fait entrer le site dans le générateur des tenants, avec une branche +« si l'adresse est déclarée, elle gagne ». **Cette branche est retirée.** -`instancier` accepte maintenant un adressage **déclaré** (`ip`, `vmid`, `vlan`, `cidr`, -`passerelle` au plan), et l'explicite gagne sur le dérivé. Les deux chemins se rejoignent -sur un seul jeu de `hostvars` : un tenant ne déclare rien et tout descend de son index ; -un site déclare, parce qu'il n'y a rien d'où descendre. +Le symptôme était visible tout de suite. Un tenant se **dérive** : de son seul `index` +descendent son supernet, ses VLAN, ses VMID, ses VNet SDN. Un site ne dérive de rien — +il n'a pas d'index, il **est** le terrain sur lequel les tenants dérivent. Les faire +passer par la même moulinette donnait un `nomenclature.yml` de site réduit à une coquille +vide, et un site exclu des devis par **absence** d'index plutôt que par nature. Une +exclusion fondée sur un manque casse au premier ajout innocent. -**La garde reste entière** : une machine sans adresse — ni déclarée ni dérivable — est -toujours refusée. C'est l'absence d'adresse qui est fautive, pas l'absence de dérivation. +Les machines de l'hébergeur se déclarent donc dans `underlay.yml`, à côté des switches et +des hyperviseurs qui les portent : même carte, même fichier, mêmes secrets. Ce qui se +partage entre un site et un tenant, ce sont les **rôles**, pas la forme du plan. -### Ce que la préparation a révélé +Six gardes neuves, chacune éprouvée par un contrôle négatif : réseau inconnu, réseau sans +pont, nœud qui n'est pas un hyperviseur déclaré, IP hors sous-réseau, IP déjà prise, +vmid absent ou en double, machine sans service. -**Le réseau d'administration `10.17.0.0/24` est à l'intérieur du supernet que l'index 17 -dérive pour Chezlepro** (`10.17.0.0/16`). Pas de collision réelle — les zones d'un tenant -commencent à `10.17.16.0` — mais le nom trompe, et un jour ça mordra. +### Ce que la mesure a corrigé -**L'underlay ne dit pas quel pont Proxmox porte quel réseau.** Il déclare les VLAN, les -sous-réseaux, les passerelles ; il documente `vmbr0` pour la grappe et `vmbr3` pour le -transport VXLAN — en commentaire. Rien ne dit par où une VM rejoint le plan -d'administration. Les tenants ne s'en apercevaient pas : leur pont est un VNet SDN, dérivé -de leur index. Un SITE, lui, n'en a pas. +**Le réseau d'administration `10.17.0.0/24` n'a pas d'étiquette VLAN**, et n'en a jamais +eu. `vlan: 10` était une supposition héritée, et elle était fausse : la frontière le porte +sur `igb0`, un port physique à elle. Il existe bien un VLAN 10 sur `vmbr1`, mais c'est +l'**ancien** plan `10.0.0.0/24`, aujourd'hui vide — deux choses différentes que le même +chiffre confondait. D'où `segment_physique: true`. -C'est un intrant manquant, pas un détail : sans lui, un plan de site ne peut pas dire à -quoi rattacher ses machines. +**Aucun pont d'hyperviseur ne touche ce segment** : trois sondes non persistantes depuis +asgard (bond0 étiqueté 10, bond3 étiqueté 10, vmbr1 non étiqueté) sont restées muettes, +témoin positif réussi dans le même script. Une VM y naîtrait sourde. Le validateur refuse +désormais toute machine déclarée sur un réseau sans `pont:`. + +*Le premier jeu de sondes avait pour cible la frontière, et concluait faux : elle ne +répond pas à une source qu'elle ne connaît pas — son default-deny travaillait. C'est le +témoin qui l'a révélé, pas la sonde.* + +**Les machines du site vivent donc sur `grappe-controle`** (`vmbr0`, `192.168.11.0/24`) : +le seul plan de l'hébergeur porté par un pont réel sur les trois nœuds, pourvu d'une +passerelle et d'une sortie, et joignable depuis le poste. Compromis assumé et écrit : sa +sortie ne passe pas par la frontière — le statut qu'ont déjà les hyperviseurs. La cible +propre reste un VLAN de site sur `vmbr1` avec une patte sur la frontière ; elle demande un +trunk côté commutateur. + +**`passerelle_amont`**, rôle d'hôte neuf : un routeur qui existe mais que nous +n'administrons pas. Le déclarer n'est pas l'adopter — c'est distinguer une passerelle +étrangère d'une passerelle fantôme. ## 2026-08-24 — Quinze invites pour un seul mot de passe diff --git a/scripts/instancier.py b/scripts/instancier.py index 066df4a..84ed105 100644 --- a/scripts/instancier.py +++ b/scripts/instancier.py @@ -186,27 +186,30 @@ def generer() -> dict: # # On refuse en BLOC plutot qu'a la premiere fautive : l'exploitant corrige son plan # une fois, pas une machine a la fois. - # UN SITE N'A PAS D'INDEX, DONC RIEN NE SE DERIVE (2026-08-24). + # CE GENERATEUR EST CELUI DES TENANTS, ET DE PERSONNE D'AUTRE (2026-08-24). # - # Un SITE decrit les machines de l'HEBERGEUR : elles vivent dans le reseau - # d'administration, pas dans un supernet de tenant, et il n'y en a qu'un jeu par site. - # Leur adressage est donc DECLARE, pas derive — il n'y a pas de seed dont le faire - # descendre, et inventer un index pour un site le ferait cohabiter avec les tenants. + # J'y avais ajoute une branche « adressage declare » pour y faire entrer les machines + # d'un SITE. C'etait le debut d'une confusion : un site et un tenant ne sont pas deux + # variantes d'une meme chose. Un site est le TERRAIN, un tenant ce qui y pousse. # - # La garde reste entiere pour les deux mondes : une machine sans adresse — ni derivee - # ni declaree — est refusee. C'est l'absence d'adresse qui est fautive, pas l'absence - # de derivation. + # Le symptome etait visible tout de suite — une branche `si l'adresse est declaree`, + # un `nomenclature.yml` de site reduit a une coquille vide, et un site exclu des devis + # par ABSENCE d'index plutot que par nature. Une exclusion fondee sur un manque est + # une exclusion qu'un ajout innocent casserait. + # + # Les machines de l'hebergeur se declarent desormais dans `underlay.yml`, a cote des + # switches et des hyperviseurs — du mobilier de fabric. Ce qui se partage entre les + # deux mondes, ce sont les ROLES, pas la forme du plan. sans_fonction = [ nom for nom, srv in serveurs.items() - if not str(srv.get("ip", "")).strip() - and not (deriver_nomenclature(str(srv.get("fonction", "")), - fonction_seq(nom)[1], nomenclature) or {}).get("adresse_ip") + if not (deriver_nomenclature(str(srv.get("fonction", "")), + fonction_seq(nom)[1], nomenclature) or {}).get("adresse_ip") ] if sans_fonction: connues = ", ".join(sorted((nomenclature.get("fonctions") or {}))) or "(aucune)" raise SystemExit( - "Machines sans adresse : ni declaree (`ip:` au plan, cas d'un SITE), ni " - "derivable — leur `fonction` n'est pas dans `nomenclature.fonctions` :\n" + "Machines sans adresse derivable — leur `fonction` n'est pas declaree dans " + "`nomenclature.fonctions` :\n" + "".join(f" - {n} : fonction « {serveurs[n].get('fonction', '')} »\n" for n in sans_fonction) + f"Fonctions connues de ce plan : {connues}") @@ -218,17 +221,6 @@ def generer() -> dict: for nom, srv in serveurs.items(): _, seq = fonction_seq(nom) d = deriver_nomenclature(str(srv.get("fonction", "")), seq, nomenclature) or {} - # L'EXPLICITE GAGNE SUR LE DERIVE. Un SITE declare `ip`, `vmid` et `vlan` ; un - # tenant ne declare rien et tout descend de son index. Les deux chemins se - # rejoignent ici, et un seul jeu de hostvars en sort. - if str(srv.get("ip", "")).strip(): - d = dict(d) - d["adresse_ip"] = srv["ip"] - d["cidr"] = srv.get("cidr", 24) - d["passerelle"] = srv.get("passerelle") or d.get("passerelle") - d["vlan"] = srv.get("vlan", d.get("vlan")) - d["vmid"] = srv.get("vmid", d.get("vmid")) - d["zone"] = None # pas de zone : pas de VNet SDN a deriver hostvars = { # Supernet du tenant, DERIVE du seed comme tout le reste. Il repond a la # question « quels clients mon service doit-il accepter ? », posee par diff --git a/scripts/underlay.py b/scripts/underlay.py index 132d763..6d8f92c 100644 --- a/scripts/underlay.py +++ b/scripts/underlay.py @@ -103,6 +103,25 @@ 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 @@ -253,7 +272,11 @@ def _ports_a_plat(hote: dict) -> list[str]: return plat -ROLES_HOTE = ("switch", "hyperviseur", "frontiere") +# `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: @@ -406,17 +429,31 @@ def valider(underlay: dict | None, nom = r.get("nom", "?") noms_reseaux.add(nom) vlan, sous = r.get("vlan"), r.get("sous_reseau") - if vlan is None or sous is None: - erreurs.append(f"reseau '{nom}': 'vlan' et 'sous_reseau' sont requis") + # 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 not isinstance(vlan, int) or vlan >= SEUIL_VLAN_TENANT: + 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 in tenants_vlans: + 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 in vus_vlan: + if vlan is not None and vlan in vus_vlan: erreurs.append(f"reseau '{nom}': VLAN {vlan} en double avec '{vus_vlan[vlan]}'") - vus_vlan[vlan] = nom + if vlan is not None: + vus_vlan[vlan] = nom try: net = ipaddress.ip_network(sous, strict=False) except ValueError: @@ -591,6 +628,55 @@ def valider(underlay: dict | None, 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