placement : le devis validait un pont que la flotte n'utilise pas
Some checks are pending
verifier / verifier (push) Waiting to run

Remarque de l'exploitant en preparant patient 0 : « le pont ne me semble pas approprie du
tout, depuis qu'on cree des VNets pour des tenants ». Juste, et plus grave que cosmetique.

`make placement-plan` confrontait `proxmox_clone_pont` (vmbr1) au cluster. Ce n'est PAS la
que les VM de la flotte atterrissent : `instancier` pose dans chaque hote le pont DERIVE
de sa zone (le VNet du tenant), et `make creer-vm` le passe au clone en ecrasant ce
defaut. vmbr1 n'est que le repli des clones MANUELS, hors plan. Le devis mesurait donc un
objet qui ne sert pas, et ignorait celui qui sert.

SUR PATIENT 0 : avant, « pont vmbr1 existe -> CONFORME ». Apres, « reseaux VM : t29appl,
t29donn, t29fron, t29serv INTROUVABLE — passer `make sdn-appliquer` AVANT de creer les
VM ». Aucun de ses quatre VNets n'existe sur le cluster : le devis d'avant-vol declarait
conforme un tenant dont les VM n'auraient eu nulle part ou naitre.

D-80 avait pourtant ete corrigee le 2026-08-13 — la liaison de placement est noeud,
stockage et gabarit, le pont se derive. Le devis continuait de compter quatre objets et de
nommer le mauvais : une doctrine corrigee dans un document ne se propage pas toute seule
dans le code qui l'applique.

MESURE MAINTENANT : en `sdn`, les VNets derives confrontes a /cluster/sdn/vnets ; en
`switch`, les ponts du noeud retenu. Avec le geste correctif quand il en manque.

NON-REGRESSION sur l'ecosysteme de reference : ses six VNets existent, conforme, code 0.
Quatre tests (Cluster simule, aucun reseau touche), harnais 38/38.

Au passage, dans patient 0 : le commentaire annoncait « les QUATRE valeurs qui rattachent
un tenant a une fabric » — trois, et le pont n'en est pas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-20 21:39:26 -04:00
parent 254268d5f2
commit 61488777ee
3 changed files with 152 additions and 23 deletions

View file

@ -1,5 +1,51 @@
# CHANGELOG — Set-OPS
## 2026-08-20 — Le devis d'avant-vol validait le mauvais réseau
Remarque de l'exploitant, en préparant patient 0 : *« le pont ne me semble pas approprié
du tout, depuis qu'on crée des VNets pour des tenants. »* Il avait raison, et le défaut
était plus grave que cosmétique.
`make placement-plan` confrontait `proxmox_clone_pont` — `vmbr1` — au cluster. Or ce
n'est **pas** là que les VM de la flotte atterrissent : `instancier` pose dans chaque hôte
le pont **dérivé** de sa zone (le VNet du tenant), et `make creer-vm` le passe au clone en
écrasant ce défaut. `vmbr1` n'est que le repli des clones **manuels**, hors plan.
Le devis mesurait donc un objet qui ne sert pas, et ne mesurait pas celui qui sert.
### Ce que ça donnait sur patient 0
```
avant : pont vmbr1 existe → CONFORME
après : reseaux VM t29appl, t29donn, t29fron, t29serv INTROUVABLE
-> VNet(s) absent(s) : ... — passer `make sdn-appliquer` AVANT de creer les VM
```
Aucun des quatre VNets de patient 0 n'existe sur le cluster. Le devis d'avant-vol disait
« conforme » à un tenant dont les VM n'auraient eu nulle part où naître.
D-80 avait pourtant été corrigée le 13 août : la liaison de placement est **nœud, stockage
et gabarit** — le pont se dérive. Le devis, lui, continuait de compter quatre objets et de
nommer le mauvais. Une doctrine corrigée dans un document ne se propage pas toute seule
dans le code qui l'applique.
### Ce qu'il mesure maintenant
Les réseaux **où les VM atterriront** : en `sdn`, les VNets dérivés confrontés à
`/cluster/sdn/vnets` ; en `switch`, les ponts du nœud retenu. Avec, quand il en manque, le
geste exact qui répare.
Non-régression vérifiée sur l'écosystème de référence : ses six VNets existent, verdict
conforme, code de sortie 0. Quatre tests, Cluster simulé, aucun réseau touché.
> **Le vert le plus dangereux est celui qui porte sur un objet voisin du bon.** Ici tout
> était vrai — `vmbr1` existe bel et bien — et la conclusion était fausse. C'est la
> quatrième fois cette semaine : la frontière qui poliçait les tenants d'un autre site,
> P03 qui comparait à l'inventaire d'une autre instance, le catalogue qui décrivait un
> moteur d'il y a quatre mois, et maintenant un devis qui contrôle un pont que la flotte
> n'utilise pas.
## 2026-08-20 — Le devis d'avant-vol mourait au lieu de parler
Soir de reconstruction, VPN pas encore monté. `make placement-plan` — le devis qu'on lance

View file

@ -36,6 +36,7 @@ import yaml
RACINE = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(RACINE / "scripts"))
import underlay as underlay_mod # noqa: E402
from proxmox_api import Cluster # noqa: E402
INVENTAIRES = ("principal", "production", "lab")
@ -87,9 +88,36 @@ def _liste(api: Cluster, chemin: str) -> list:
return rep
def constater(api: Cluster, place: dict) -> list[dict]:
def _ponts_derives(base: Path | None) -> set[str]:
"""Les reseaux ou les VM de CE tenant atterriront reellement.
Ce ne sont pas les valeurs du plan : `instancier` pose dans chaque hote le pont
DERIVE de sa zone (`proxmox_pont` = le VNet du tenant), et `make creer-vm` le passe au
clone en ecrasant le defaut. C'est donc la seule chose a confronter au cluster.
"""
ponts: set[str] = set()
if base is None:
return ponts
for f in sorted(base.glob("inventories/*/hosts.yml")):
data = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
def marcher(noeud):
if not isinstance(noeud, dict):
return
for hote in (noeud.get("hosts") or {}).values():
if isinstance(hote, dict) and hote.get("proxmox_pont"):
ponts.add(str(hote["proxmox_pont"]))
for enfant in (noeud.get("children") or {}).values():
marcher(enfant or {})
marcher((data.get("all") or {}))
return ponts
def constater(api: Cluster, place: dict, base: Path | None = None,
routage: str = "sdn") -> list[dict]:
"""Confronte chaque valeur au cluster. Renvoie une ligne par objet."""
lignes: list[dict] = []
place = {**place, "_instance": base}
noeuds = [n.get("node") for n in _liste(api, "/nodes")]
voulu = place.get("proxmox_clone_noeud")
@ -108,17 +136,42 @@ def constater(api: Cluster, place: dict) -> list[dict]:
"note": ("" if voulu not in stockages or "images" in stockages[voulu]
else f"present mais n'accepte pas `images` ({stockages[voulu]})")})
# Le pont doit exister SUR LE NOEUD retenu : un pont partiel est un piege, la VM ne
# demarre que sur certains noeuds et l'erreur ne le dit pas.
n = place.get("proxmox_clone_noeud")
ponts: list[str] = []
if n in noeuds:
ponts = [i.get("iface") for i in _liste(api, f"/nodes/{n}/network")
if str(i.get("type")) in ("bridge", "OVSBridge")]
voulu = place.get("proxmox_clone_pont")
lignes.append({"objet": "pont", "voulu": voulu,
"existe": voulu in ponts, "offre": ponts,
"note": "" if n in noeuds else "noeud introuvable : pont non verifiable"})
# LE RESEAU OU LES VM ATTERRISSENT — pas `proxmox_clone_pont`.
#
# Mesure du 2026-08-20, remarque de l'exploitant : « le pont ne me semble pas approprie
# depuis qu'on cree des VNets par tenant ». Il avait raison, et le defaut etait pire
# que cosmetique. Ce devis validait `vmbr1` — le REPLI des clones manuels — pendant que
# le point d'attache reel des VM de la flotte n'etait controle par personne :
# `creer-vm` lit le pont DERIVE de chaque hote (`SETOPS_PONT`, le VNet de sa zone) et
# ecrase le defaut. Resultat : « CONFORME » sur un tenant dont aucun VNet n'existait.
#
# D-80 corrigee le 2026-08-13 dit deja que la liaison de placement est nœud, stockage
# et gabarit — le pont se derive. Le devis, lui, continuait de compter quatre objets.
ponts_derives = sorted(_ponts_derives(place.get("_instance")))
if routage == "sdn":
vnets = {v.get("vnet") for v in _liste(api, "/cluster/sdn/vnets")}
manquants = [p for p in ponts_derives if p not in vnets]
lignes.append({
"objet": "reseaux VM", "voulu": ", ".join(ponts_derives) or "(aucun)",
"existe": bool(ponts_derives) and not manquants,
"offre": sorted(vnets),
"note": ("" if not manquants else
f"VNet(s) absent(s) du cluster : {', '.join(manquants)} — "
f"passer `make sdn-appliquer` AVANT de creer les VM")})
else:
n = place.get("proxmox_clone_noeud")
ponts: list[str] = []
if n in noeuds:
ponts = [i.get("iface") for i in _liste(api, f"/nodes/{n}/network")
if str(i.get("type")) in ("bridge", "OVSBridge")]
manquants = [p for p in ponts_derives if p not in ponts]
lignes.append({
"objet": "reseaux VM", "voulu": ", ".join(ponts_derives) or "(aucun)",
"existe": bool(ponts_derives) and not manquants,
"offre": ponts,
"note": ("noeud introuvable : ponts non verifiables" if n not in noeuds else
"" if not manquants else
f"pont(s) absent(s) sur {n} : {', '.join(manquants)}")})
# Le gabarit : celui que P37 ne peut PAS voir. Presence, et surtout `template=1` —
# cloner une VM ordinaire marcherait, et produirait quatorze copies d'une machine
@ -142,20 +195,20 @@ def afficher(lignes: list[dict], source: Path | None, depot: Path) -> bool:
# du lien, pas celui du tenant. Un devis doit nommer ce qu'il a mesure.
tenant = source.parents[3].resolve().name if source else "?"
print(f"Devis du placement — tenant « {tenant} » sur l'hebergeur « {depot.name} »\n")
print(f" {'OBJET':<10} {'DEMANDE':<22} {'VERDICT'}")
print(f" {'OBJET':<12} {'DEMANDE':<34} {'VERDICT'}")
ok = True
for l in lignes:
verdict = "existe" if l["existe"] else "INTROUVABLE"
ok = ok and l["existe"]
print(f" {l['objet']:<10} {str(l['voulu']):<22} {verdict}")
print(f" {l['objet']:<12} {str(l['voulu']):<34} {verdict}")
if l.get("note"):
print(f" {'':<10} {'':<22} -> {l['note']}")
print(f" {'':<12} {'':<34} -> {l['note']}")
if not l["existe"] and l["offre"]:
offert = ", ".join(str(x) for x in l["offre"][:6])
print(f" {'':<10} {'':<22} offert : {offert}")
print("\n" + ("CONFORME : les quatre objets de placement existent sur ce cluster."
if ok else
"ECART : le tenant designe des objets que ce cluster n'offre pas."))
print(f" {'':<12} {'':<34} offert : {offert}")
print("\n" + ("CONFORME : le noeud, le stockage, le gabarit et les reseaux des VM "
"existent sur ce cluster." if ok else
"ECART : le tenant a besoin d'objets que ce cluster n'offre pas."))
return ok
@ -174,7 +227,9 @@ def main() -> int:
print(f"Cluster injoignable : {e}", file=sys.stderr)
return 2
lignes = constater(api, place)
base = source.parents[3] if source else None
routage = underlay_mod.routage_tenants(underlay_mod.charger())
lignes = constater(api, place, base, routage)
if a.verifier:
mauvais = [l for l in lignes if not l["existe"]]
if mauvais:
@ -182,8 +237,8 @@ def main() -> int:
print(f"placement : {l['objet']} « {l['voulu']} » introuvable"
+ (f" — {l['note']}" if l.get("note") else ""), file=sys.stderr)
return 1
print(f"Placement conforme : noeud, stockage, pont et gabarit existent sur "
f"« {depot.name} ».")
print(f"Placement conforme : noeud, stockage, gabarit et reseaux des VM "
f"existent sur « {depot.name} ».")
return 0
return 0 if afficher(lignes, source, depot) else 1

View file

@ -69,11 +69,39 @@ def test_une_reponse_normale_traverse_la_garde():
"""La garde ne doit pas gener le cas nominal : quatre lignes de constat."""
api = _ClusterSimule([]) # cluster joignable, mais qui n'offre rien
lignes = dp.constater(api, PLACEMENT)
assert [l["objet"] for l in lignes] == ["noeud", "stockage", "pont", "gabarit"]
assert [l["objet"] for l in lignes] == ["noeud", "stockage", "reseaux VM", "gabarit"]
assert all(l["existe"] is False for l in lignes), \
"un cluster vide n'offre aucun des quatre objets"
def test_le_reseau_mesure_est_celui_ou_les_VM_atterrissent(tmp=None):
"""Le pont du plan n'est PAS ce qu'on verifie — c'est le VNet derive de chaque hote.
Le defaut du 2026-08-20 : le devis validait `proxmox_clone_pont` (vmbr1), qui n'est
que le repli des clones manuels, et rendait CONFORME sur un tenant dont AUCUN VNet
n'existait. `make creer-vm` passe le pont derive par hote et ecrase ce defaut.
"""
import tempfile
import yaml as _yaml
with tempfile.TemporaryDirectory() as d:
base = Path(d)
inv = base / "inventories" / "production"
inv.mkdir(parents=True)
(inv / "hosts.yml").write_text(_yaml.safe_dump({
"all": {"children": {"hotes_actifs": {"hosts": {
"a-01": {"proxmox_pont": "t29fron"},
"b-01": {"proxmox_pont": "t29serv"}}}}}}), encoding="utf-8")
api = _ClusterSimule([{"vnet": "t29fron"}]) # un seul des deux existe
ligne = [l for l in dp.constater(api, PLACEMENT, base, "sdn")
if l["objet"] == "reseaux VM"][0]
assert ligne["existe"] is False, "un VNet manquant doit faire echouer le devis"
assert "t29serv" in ligne["note"], ligne["note"]
assert "sdn-appliquer" in ligne["note"], "le geste correctif doit etre nomme"
assert "vmbr" not in str(ligne["voulu"]), \
"le devis ne doit plus presenter le pont des clones manuels"
if __name__ == "__main__":
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
for t in tests: