diff --git a/CHANGELOG.md b/CHANGELOG.md index 9af0616..9de3013 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,48 @@ # CHANGELOG — Set-OPS +## 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 +**avant** quarante minutes de déploiement, pour savoir si le terrain est bon : + +``` +AttributeError: 'str' object has no attribute 'get' +``` + +Dix lignes de trace Python pour dire *« le nom `asgard` ne se résout pas d'ici »*. + +En panne, `Cluster.__call__` rend `{"_erreur": "…"}` — un **dict**. Le devis l'itérait +comme une liste, et un dict itéré rend ses **clés** : d'où un `str` là où le code +attendait un objet. `Cluster.rate()` existait précisément pour ça, et n'était appelé +nulle part ici. Les quatre appels passent désormais par une garde qui nomme la cause, +l'hôte interrogé et le geste à tenter. + +### Et le devis mesurait le mauvais tenant + +`placement_du_tenant()` lisait `instance/` **en dur** : viser patient 0 avec +`SETOPS_INSTANCE` mesurait en silence le placement de l'instance montée. Le verdict était +juste — pour l'autre tenant. Les deux portaient les mêmes quatre valeurs, ce qui est +exactement la circonstance où l'erreur ne se voit pas. + +C'est la **huitième** résolution d'inventaire ou d'instance codée en dur trouvée en trois +jours. À ce compte, ce n'est plus une série de bogues : c'est une pièce manquante. + +Au passage, l'en-tête annonçait « tenant *instance* » — le nom du lien, pas celui du +tenant. Un devis doit nommer ce qu'il a mesuré. + +### Trois tests, aucun réseau touché + +`test_devis_placement.py` simule le Cluster : une panne devient un refus lisible (cause, +hôte, geste), une réponse qui n'est pas une liste est refusée elle aussi — c'est le cas +silencieux, celui qui franchirait la première garde —, et le cas nominal traverse sans +gêne. Branchés sur `make test`. + +> **Ce qu'on répare ici n'est pas une exception, c'est un message.** Un outil de +> diagnostic qui échoue en langage machine transforme une panne de trente secondes (monter +> le VPN) en une demi-heure de fouille. Le pire moment pour ça est celui où on l'utilise : +> quand quelque chose ne va déjà pas. + + ## 2026-08-20 — Le harnais ne se déclenchait que par mémoire Trente-huit preuves, des tests, un lint — et **rien** ne les exécutait sans qu'un humain diff --git a/Makefile b/Makefile index d0f18d2..10a3477 100644 --- a/Makefile +++ b/Makefile @@ -225,6 +225,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire) python3 scripts/tests/test_raser_resultat.py python3 scripts/tests/test_adressage_derive.py python3 scripts/tests/test_gui_intrants.py + python3 scripts/tests/test_devis_placement.py .PHONY: verifier verifier: lint test inventaire-verifier site-verifier flux-verifier syntaxe ## Rejoue les preuves SANS reecrire le rapport (verification rapide) diff --git a/scripts/devis_placement.py b/scripts/devis_placement.py index 8f27ddc..5ee01cb 100644 --- a/scripts/devis_placement.py +++ b/scripts/devis_placement.py @@ -27,6 +27,7 @@ N'ECRIT RIEN. Lecture seule, sans effet de bord. from __future__ import annotations import argparse +import os import sys from pathlib import Path @@ -41,19 +42,56 @@ INVENTAIRES = ("principal", "production", "lab") def placement_du_tenant() -> tuple[dict, Path | None]: - """Les valeurs de placement declarees par le tenant MONTE.""" + """Les valeurs de placement declarees par le tenant VISE. + + SETOPS_INSTANCE d'abord ; le symlink `instance/` n'en est que le cas courant. Code en + dur, il rendait ce devis incapable de regarder un AUTRE tenant : viser patient 0 + mesurait en silence le placement de l'instance montee, et rendait un verdict juste + pour le mauvais tenant (mesure du 2026-08-20 — les deux avaient les memes quatre + valeurs, ce qui est exactement la circonstance ou l'erreur ne se voit pas). + """ + base = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) for nom in INVENTAIRES: - f = RACINE / "instance" / "inventories" / nom / "group_vars" / "proxmox.yml" + f = base / "inventories" / nom / "group_vars" / "proxmox.yml" if f.is_file(): return (yaml.safe_load(f.read_text(encoding="utf-8")) or {}), f return {}, None +def _liste(api: Cluster, chemin: str) -> list: + """Une reponse de l'API garantie ITERABLE — ou un refus lisible. + + POURQUOI (mesure du 2026-08-20, un soir de reconstruction). Sur erreur, + `Cluster.__call__` rend `{"_erreur": "..."}` — un DICT. Itere comme une liste, un + dict rend ses CLEFS : le devis mourait sur + `AttributeError: 'str' object has no attribute 'get'`, dix lignes de trace Python + pour dire « le nom `asgard` ne se resout pas ». + + C'est le pire endroit pour un message illisible : ce devis est precisement ce qu'on + lance AVANT quarante minutes de deploiement, quand on cherche a savoir si le terrain + est bon. `Cluster.rate()` existait pour ca ; il n'etait pas appele ici. + """ + rep = api(chemin) + faute = api.rate(rep) + if faute: + raise SystemExit( + f"Le cluster ne repond pas sur `{chemin}` : {faute}\n" + f" hote interroge : {api.hote}\n" + f" a verifier : le nom se resout-il d'ici, le VPN est-il monte, " + f"le jeton d'API est-il encore valide ?") + if not isinstance(rep, list): + raise SystemExit( + f"Reponse inattendue du cluster sur `{chemin}` : " + f"{type(rep).__name__} au lieu d'une liste.\n" + f" hote interroge : {api.hote}") + return rep + + def constater(api: Cluster, place: dict) -> list[dict]: """Confronte chaque valeur au cluster. Renvoie une ligne par objet.""" lignes: list[dict] = [] - noeuds = [n.get("node") for n in (api("/nodes") or [])] + noeuds = [n.get("node") for n in _liste(api, "/nodes")] voulu = place.get("proxmox_clone_noeud") lignes.append({"objet": "noeud", "voulu": voulu, "existe": voulu in noeuds, "offre": noeuds}) @@ -61,7 +99,7 @@ def constater(api: Cluster, place: dict) -> list[dict]: # Un stockage ne suffit pas : il doit accepter le contenu `images`, sinon on ne peut # pas y poser un disque de VM — et l'erreur ne se lit qu'au clone. stockages = {} - for s in (api("/storage") or []): + for s in _liste(api, "/storage"): stockages[s.get("storage")] = str(s.get("content") or "") voulu = place.get("proxmox_clone_stockage") lignes.append({"objet": "stockage", "voulu": voulu, @@ -75,7 +113,7 @@ def constater(api: Cluster, place: dict) -> list[dict]: n = place.get("proxmox_clone_noeud") ponts: list[str] = [] if n in noeuds: - ponts = [i.get("iface") for i in (api(f"/nodes/{n}/network") or []) + 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, @@ -86,7 +124,7 @@ def constater(api: Cluster, place: dict) -> list[dict]: # cloner une VM ordinaire marcherait, et produirait quatorze copies d'une machine # vivante. vmid = place.get("proxmox_clone_vmid_modele") - vms = {v.get("vmid"): v for v in (api("/cluster/resources?type=vm") or [])} + vms = {v.get("vmid"): v for v in _liste(api, "/cluster/resources?type=vm")} trouve = vms.get(int(vmid)) if vmid is not None else None est_gabarit = bool(trouve and str(trouve.get("template", 0)) == "1") lignes.append({"objet": "gabarit", "voulu": vmid, @@ -100,8 +138,10 @@ def constater(api: Cluster, place: dict) -> list[dict]: def afficher(lignes: list[dict], source: Path | None, depot: Path) -> bool: - print(f"Devis du placement — tenant « {source.parents[3].name if source else '?'} » " - f"sur l'hebergeur « {depot.name} »\n") + # `.resolve()` : sans lui, un tenant monte par symlink s'annonce « instance » — le nom + # 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'}") ok = True for l in lignes: diff --git a/scripts/tests/test_devis_placement.py b/scripts/tests/test_devis_placement.py new file mode 100644 index 0000000..b6b5a03 --- /dev/null +++ b/scripts/tests/test_devis_placement.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Le devis de placement dit ce qui ne va pas, au lieu de mourir. + +Ce que ce test garde, et pourquoi il existe. Ce devis est ce qu'on lance AVANT quarante +minutes de deploiement, pour savoir si le terrain est bon — sur un site inconnu, un soir +d'intervention. C'est donc le pire endroit du depot pour un message illisible. + +Le 2026-08-20, il mourait sur `AttributeError: 'str' object has no attribute 'get'` : +`Cluster.__call__` rend `{"_erreur": "..."}` en cas de panne, un DICT, et le devis +l'iterait comme une liste — un dict itere rend ses CLEFS. Dix lignes de trace Python pour +dire « le nom `asgard` ne se resout pas ». `Cluster.rate()` existait pour ca et n'etait +pas appele. + +Le test ne verifie pas que le cluster repond — il verifie que le devis REFUSE PROPREMENT +quand il ne repond pas. Aucun reseau n'est touche : le Cluster est simule. +""" +import sys +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(RACINE / "scripts")) + +import devis_placement as dp # noqa: E402 +from proxmox_api import Cluster # noqa: E402 + + +class _ClusterSimule(Cluster): + """Un Cluster qui ne parle a personne : il rend ce qu'on lui dit de rendre.""" + + def __init__(self, reponse): + self.hote = "cluster-de-test" + self._reponse = reponse + + def __call__(self, chemin, methode="GET", corps=None): + return self._reponse + + +PLACEMENT = {"proxmox_clone_noeud": "n1", "proxmox_clone_stockage": "s1", + "proxmox_clone_pont": "vmbr0", "proxmox_clone_vmid_modele": 99998} + + +def test_une_panne_reseau_devient_un_refus_lisible(): + api = _ClusterSimule({"_erreur": ""}) + try: + dp.constater(api, PLACEMENT) + except SystemExit as e: + message = str(e) + assert "ne repond pas" in message, message + assert "name resolution" in message, "la cause reelle doit etre citee" + assert "cluster-de-test" in message, "l'hote interroge doit etre nomme" + assert "VPN" in message, "le geste a tenter doit etre suggere" + else: + raise AssertionError("une reponse d'erreur aurait du produire un refus") + + +def test_une_reponse_qui_n_est_pas_une_liste_est_refusee(): + """Le cas silencieux : un dict SANS `_erreur` passerait la premiere garde.""" + api = _ClusterSimule({"data": "quelque chose d'inattendu"}) + try: + dp.constater(api, PLACEMENT) + except SystemExit as e: + assert "Reponse inattendue" in str(e), str(e) + else: + raise AssertionError("une reponse non iterable aurait du produire un refus") + + +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 all(l["existe"] is False for l in lignes), \ + "un cluster vide n'offre aucun des quatre objets" + + +if __name__ == "__main__": + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + for t in tests: + t() + print(f"OK — {len(tests)} test(s) devis de placement : " + f"une panne de cluster se lit, elle ne se decode pas.")