site : un SITE n'est pas un plan — ses machines vivent dans l'underlay
La branche « adressage declare » du generateur de tenants est retiree. Un tenant se
derive de son index ; un site n'a pas d'index et ne derive de rien. Les faire passer par
la meme moulinette donnait une nomenclature de site vide de sens, et un site exclu des
devis par ABSENCE d'index plutot que par nature.
Les machines de l'hebergeur se declarent desormais dans underlay.yml, a cote des switches
et des hyperviseurs qui les portent. Six gardes neuves, chacune eprouvee par un controle
negatif.
Mesures qui ont corrige la carte :
- 10.17.0.0/24 n'a pas d'etiquette VLAN (segment physique sur igb0 de la frontiere) ;
le VLAN 10 de vmbr1 est l'ancien plan 10.0.0.0/24, vide.
- aucun pont d'hyperviseur ne porte ce segment : trois sondes muettes, temoin positif
reussi. Une VM y naitrait sourde — le validateur le refuse.
- les machines du site vont donc sur grappe-controle (vmbr0), seul plan de l'hebergeur
porte par un pont reel, avec passerelle et sortie.
Role d'hote neuf : passerelle_amont — un routeur reel que nous n'administrons pas.
42 preuves vertes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
9960cfd68e
commit
93dde5b4f2
3 changed files with 149 additions and 52 deletions
61
CHANGELOG.md
61
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue