diff --git a/CHANGELOG.md b/CHANGELOG.md index 9de3013..72329a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/scripts/devis_placement.py b/scripts/devis_placement.py index 5ee01cb..b218e5a 100644 --- a/scripts/devis_placement.py +++ b/scripts/devis_placement.py @@ -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 diff --git a/scripts/tests/test_devis_placement.py b/scripts/tests/test_devis_placement.py index b6b5a03..953c479 100644 --- a/scripts/tests/test_devis_placement.py +++ b/scripts/tests/test_devis_placement.py @@ -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: