raser : lire le RESULTAT de la destruction, pas son accuse de reception
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 <noreply@anthropic.com>
This commit is contained in:
parent
776f087f70
commit
a1908f2c63
4 changed files with 137 additions and 1 deletions
31
CHANGELOG.md
31
CHANGELOG.md
|
|
@ -1,5 +1,36 @@
|
||||||
# CHANGELOG — Set-OPS
|
# 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
|
## 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.**
|
**Mon optimisation de la veille au soir a mis le cluster à genoux, et la faute est entière.**
|
||||||
|
|
|
||||||
1
Makefile
1
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)
|
test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
|
||||||
python3 scripts/tests/test_inventory_host.py
|
python3 scripts/tests/test_inventory_host.py
|
||||||
python3 scripts/tests/test_raser.py
|
python3 scripts/tests/test_raser.py
|
||||||
|
python3 scripts/tests/test_raser_resultat.py
|
||||||
|
|
||||||
.PHONY: verifier
|
.PHONY: verifier
|
||||||
verifier: lint test inventaire-verifier site-verifier flux-verifier syntaxe ## Rejoue les preuves SANS reecrire le rapport (verification rapide)
|
verifier: lint test inventaire-verifier site-verifier flux-verifier syntaxe ## Rejoue les preuves SANS reecrire le rapport (verification rapide)
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,25 @@ def plan_derive(base: Path) -> list[tuple[str, int]]:
|
||||||
return couples
|
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:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
ap = argparse.ArgumentParser(description="Rase les VM derivees du plan de l'instance active.")
|
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)")
|
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):
|
if err := cl.rate(r):
|
||||||
print(f" {vmid} {nom} — ECHEC : {err}")
|
print(f" {vmid} {nom} — ECHEC : {err}")
|
||||||
echecs += 1
|
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")
|
print(f" {vmid} {nom} — detruite")
|
||||||
|
else:
|
||||||
|
print(f" {vmid} {nom} — ECHEC : {etat}")
|
||||||
|
echecs += 1
|
||||||
|
|
||||||
print(f"\n{len(presentes) - echecs}/{len(presentes)} VM detruites."
|
print(f"\n{len(presentes) - echecs}/{len(presentes)} VM detruites."
|
||||||
+ (f" {echecs} echec(s)." if echecs else ""))
|
+ (f" {echecs} echec(s)." if echecs else ""))
|
||||||
|
|
|
||||||
70
scripts/tests/test_raser_resultat.py
Normal file
70
scripts/tests/test_raser_resultat.py
Normal file
|
|
@ -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")
|
||||||
Loading…
Reference in a new issue