From a1908f2c6379c9a3a46a1318d21e012f91c4fd17 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Mon, 10 Aug 2026 22:47:28 -0400 Subject: [PATCH] raser : lire le RESULTAT de la destruction, pas son accuse de reception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le defaut note hier est corrige. `raser` concluait au succes sur la reponse immediate de l'API : le DELETE rend un UPID et la main tout de suite, la destruction se fait en tache de fond, et elle peut echouer APRES. Le 2026-08-10, six VM ont ete rapportees « detruites » alors qu'elles etaient toujours la — la tache sortait sur « VM is locked (clone) », verrou laisse par des clonages interrompus. Confondre « demande acceptee » et « travail fait » est le pire mensonge possible pour la SEULE commande destructive du moteur : on croit la place libre, on relance la construction, et rien ne se cree sans qu'on comprenne pourquoi. _attendre_tache() relit l'UPID, interroge l'etat jusqu'a `stopped` et rend l'exitstatus reel. Chaque VM est annoncee detruite OU en echec, avec la cause telle que le cluster l'a donnee ; le compte final ne ment plus. test_raser_resultat.py fabrique la situation exacte : un faux cluster qui accepte tout puis rend une tache terminee en erreur. EPROUVE DANS LES DEUX SENS — avec l'ancien comportement retabli temporairement il echoue en designant le defaut, avec le correctif il passe. Raccorde a `make test`, donc rejoue par P02. Meme motif que le clonage corrige une heure plus tot, dans l'autre sens : une operation asynchrone dont on ne verifie pas l'issue. Les deux venaient du passage a des appels d'API directs, ou plus rien n'attend a notre place. Verifie : make test vert, prouver.py 35 OK. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 31 ++++++++++++ Makefile | 1 + scripts/raser.py | 36 +++++++++++++- scripts/tests/test_raser_resultat.py | 70 ++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 scripts/tests/test_raser_resultat.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d1f9bb..3447a8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # CHANGELOG — Set-OPS +## 2026-08-10 — `raser` n'annonce plus des destructions qui n'ont pas eu lieu + +Le défaut noté la veille est corrigé. `raser` lisait l'**accusé de réception** de l'API et +concluait au succès : le `DELETE` rend un UPID et la main immédiatement, la destruction se +fait en tâche de fond, et elle peut échouer **après**. Le 2026-08-10, six VM ont été +rapportées « détruites » alors qu'elles étaient toujours là — la tâche sortait sur +`VM is locked (clone)`, un verrou laissé par des clonages interrompus. + +Confondre « demande acceptée » et « travail fait » est le pire mensonge possible pour la +**seule commande destructive du moteur** : on croit la place libre, on relance la +construction, et rien ne se crée sans qu'on comprenne pourquoi. + +`_attendre_tache()` relit l'UPID, interroge l'état jusqu'à `stopped`, et rend l'`exitstatus` +réel. Chaque VM est annoncée détruite **ou** en échec, avec la cause telle que le cluster +l'a donnée — et le compte final ne ment plus. + +### Un test qui exerce le défaut, pas seulement le correctif + +`test_raser_resultat.py` fabrique la situation exacte : un faux cluster qui accepte tout, +puis rend une tâche **terminée en erreur**. `raser` doit sortir en 1, nommer la cause, et +n'annoncer aucune destruction. + +**Éprouvé dans les deux sens** — c'est ce qui distingue un test d'une décoration. Avec +l'ancien comportement rétabli temporairement, il échoue en désignant précisément le défaut +(« raser a rendu 0 alors que la destruction a ÉCHOUÉ ») ; avec le correctif, il passe. +Raccordé à `make test`, donc rejoué par **P02**. + +C'est le même motif que le clonage corrigé une heure plus tôt, dans l'autre sens : une +opération asynchrone dont on ne vérifie pas l'issue. Les deux venaient du passage à des +appels d'API directs, où plus rien n'attend à notre place. + ## 2026-08-10 — Le clonage ne s'attendait plus lui-même, et ça a saturé le stockage **Mon optimisation de la veille au soir a mis le cluster à genoux, et la faute est entière.** diff --git a/Makefile b/Makefile index 2338f3c..0e689ec 100644 --- a/Makefile +++ b/Makefile @@ -210,6 +210,7 @@ syntaxe-proxmox: ansible-runtime ## Verifie la syntaxe du playbook de clonage de test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire) python3 scripts/tests/test_inventory_host.py python3 scripts/tests/test_raser.py + python3 scripts/tests/test_raser_resultat.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/raser.py b/scripts/raser.py index 044b3f5..f38106a 100755 --- a/scripts/raser.py +++ b/scripts/raser.py @@ -68,6 +68,25 @@ def plan_derive(base: Path) -> list[tuple[str, int]]: return couples +def _attendre_tache(cl, noeud: str, reponse, patience: int = 120) -> str: + """Etat FINAL d'une tache Proxmox : « OK », un message d'erreur, ou l'attente epuisee. + + L'API rend un UPID et la main immediatement. Lire cette reponse et conclure au succes, + c'est confondre « demande acceptee » et « travail fait » — l'erreur qui a fait + annoncer a `raser` six destructions qui n'avaient pas eu lieu (2026-08-10). + """ + upid = reponse.get("data", reponse) if isinstance(reponse, dict) else reponse + if not (isinstance(upid, str) and upid.startswith("UPID")): + return "reponse sans identifiant de tache" + for _ in range(patience // 2): + time.sleep(2) + st = cl(f"/nodes/{noeud}/tasks/{upid}/status") + st = st.get("data", st) if isinstance(st, dict) else st + if isinstance(st, dict) and st.get("status") == "stopped": + return str(st.get("exitstatus") or "sortie inconnue") + return f"toujours en cours apres {patience} s" + + def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description="Rase les VM derivees du plan de l'instance active.") ap.add_argument("--instance", help="nom de l'ecosysteme a raser (doit correspondre au monte)") @@ -148,8 +167,23 @@ def main(argv: list[str] | None = None) -> int: if err := cl.rate(r): print(f" {vmid} {nom} — ECHEC : {err}") echecs += 1 - else: + continue + # ATTENDRE LE RESULTAT, PAS SEULEMENT L'ACCUSE DE RECEPTION. + # + # L'API rend un UPID et la main IMMEDIATEMENT : la destruction se fait en tache + # de fond, et elle peut echouer APRES. Le 2026-08-10, `raser` a rapporte + # « 6/6 VM detruites » alors que les six etaient toujours la — la tache sortait + # sur « VM is locked (clone) », verrou laisse par des clonages interrompus. + # + # Annoncer une destruction qui n'a pas eu lieu est le pire mensonge possible + # pour la SEULE commande destructive du moteur : on croit la place libre, on + # relance, et on ne comprend pas pourquoi rien ne se cree. + etat = _attendre_tache(cl, noeud, r) + if etat == "OK": print(f" {vmid} {nom} — detruite") + else: + print(f" {vmid} {nom} — ECHEC : {etat}") + echecs += 1 print(f"\n{len(presentes) - echecs}/{len(presentes)} VM detruites." + (f" {echecs} echec(s)." if echecs else "")) diff --git a/scripts/tests/test_raser_resultat.py b/scripts/tests/test_raser_resultat.py new file mode 100644 index 0000000..915a416 --- /dev/null +++ b/scripts/tests/test_raser_resultat.py @@ -0,0 +1,70 @@ +"""`raser` : une destruction qui ECHOUE ne doit pas etre annoncee comme reussie. + +Le 2026-08-10, `raser` a rapporte « 6/6 VM detruites » alors que les six etaient toujours +la. L'API Proxmox accepte le `DELETE`, rend un UPID et la main IMMEDIATEMENT ; la +destruction se fait en tache de fond, et elle avait echoue APRES sur +« VM is locked (clone) » — un verrou laisse par des clonages interrompus. + +`raser` ne lisait que l'accuse de reception. Confondre « demande acceptee » et « travail +fait » est le pire mensonge possible pour la SEULE commande destructive du moteur : on +croit la place libre, on relance la construction, et rien ne se cree sans qu'on comprenne +pourquoi. + +Ce test fabrique exactement cette situation : le faux cluster accepte tout, puis rend une +tache terminee en erreur. `raser` doit sortir en 1 et n'annoncer aucune destruction. +""" +import io +import pathlib +import sys +from contextlib import redirect_stdout + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) +import raser # noqa: E402 + +BASE, NOM = raser.instance_active() +COUPLES = raser.plan_derive(BASE) +if not COUPLES: + raise SystemExit("Plan sans serveur : rien a eprouver.") +NOM_1, VMID_1 = COUPLES[0] + +MOTIF = "VM is locked (clone)" + + +class ClusterQuiEchoueApresCoup: + """Accepte tout, puis rend une tache TERMINEE EN ERREUR — le cas reel du 2026-08-10.""" + + @classmethod + def depuis_hebergeur(cls): + return cls(), None + + def __call__(self, chemin, methode="GET", corps=None): + if chemin.startswith("/cluster/resources"): + # Le plan et le cluster s'accordent : le verrou de nom ne doit pas se + # declencher, sinon on n'atteindrait jamais la destruction. + return [{"type": "qemu", "vmid": VMID_1, "name": NOM_1, "node": "asgard"}] + if chemin.endswith("/status/current"): + return {"status": "stopped"} + if chemin.endswith("/status/stop"): + return "UPID:asgard:0:0:0:qmstop:0:test:" + if "/tasks/" in chemin and chemin.endswith("/status"): + return {"status": "stopped", "exitstatus": MOTIF} + if methode == "DELETE": + return "UPID:asgard:0:0:0:qmdestroy:0:test:" + return {} + + def rate(self, rep): + return None + + +raser.Cluster = ClusterQuiEchoueApresCoup +sortie = io.StringIO() +with redirect_stdout(sortie): + code = raser.main(["--instance", NOM, "--confirmer"]) +texte = sortie.getvalue() + +print(f"\n>>> code de sortie : {code} (1 = echec attendu)") +assert code == 1, "raser a rendu 0 alors que la destruction a ECHOUE" +assert "ECHEC" in texte, "raser n'a pas signale l'echec" +assert MOTIF in texte, "raser n'a pas rapporte la cause rendue par le cluster" +assert "1/1 VM detruites" not in texte, "raser annonce une destruction qui n'a pas eu lieu" +print(">>> l'echec est vu, nomme, et compte : aucune destruction fictive annoncee")