Set-OPS-Public/scripts/raser.py

195 lines
8.5 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Rase les VM d'un ecosysteme — la seule commande destructive du moteur.
Elle n'existe que pour rendre la reconstruction from-zero REPETABLE : un test qu'on ne
peut jouer qu'une fois, a la main, n'est pas une recette. Tout le reste du depot cree ou
reconcilie ; celle-ci detruit, et elle est ecrite en consequence.
QUATRE VERROUS, et aucun n'est cosmetique :
1. Ne touche QUE les VMID **derives du plan** de l'instance active. Une VM qui n'est
pas au plan n'est jamais candidate — le gabarit dore en particulier, dont le VMID
ne se derive pas.
2. **Le nom doit correspondre.** Un VMID present sous un AUTRE nom que celui du plan
est la machine de quelqu'un d'autre : l'operation entiere est refusee, pas
seulement cette VM. Ce n'est pas theorique — le 2026-08-07, une VM heritee portait
un VMID du plan sous le nom `web-frontal-01`, et `proxmox_kvm` avait rapporte `ok`
sans rien faire.
3. **Il faut NOMMER l'ecosysteme** (`INSTANCE=<nom>`) et que ce nom corresponde a
l'instance montee. Le symlink `instance/` peut pointer n'importe ou ; taper le nom
est ce qui distingue « je rase le POC » de « je rase la production ».
4. `CONFIRMER=true`. Sans lui : inventaire de ce qui serait detruit, et rien d'autre.
Usage :
python3 scripts/raser.py # dit ce qu'il ferait
python3 scripts/raser.py --instance chezlepro --confirmer
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from proxmox_api import Cluster # noqa: E402
RACINE = Path(__file__).resolve().parent.parent
def instance_active() -> tuple[Path, str]:
"""Dossier de l'instance montee, et son nom court (celui qu'il faudra taper)."""
base = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance"))
if not base.exists():
raise SystemExit("Aucune instance montee : rien a raser.")
reel = base.resolve()
nom = reel.name.removeprefix("OPS-").lower()
return reel, nom
def plan_derive(base: Path) -> list[tuple[str, int]]:
"""(nom d'hote, vmid) tels que le PLAN les derive — la seule source légitime."""
env = dict(os.environ, SETOPS_INSTANCE=str(base))
r = subprocess.run([sys.executable, str(RACINE / "scripts" / "serveurs.py"), "lister"],
capture_output=True, text=True, env=env, cwd=RACINE)
if r.returncode != 0:
raise SystemExit(f"Plan illisible : {r.stderr.strip()[:200]}")
couples = []
for ligne in r.stdout.splitlines():
if "vmid" not in ligne:
continue
nom = ligne.split()[0]
for morceau in ligne.split():
if morceau.rstrip(",").isdigit() and len(morceau.rstrip(",")) >= 6:
couples.append((nom, int(morceau.rstrip(","))))
break
return couples
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>
2026-08-10 22:47:28 -04:00
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)")
ap.add_argument("--hote", help="ne raser QUE cet hote (les quatre verrous s'appliquent)")
ap.add_argument("--confirmer", action="store_true")
a = ap.parse_args(argv)
base, nom_actif = instance_active()
couples = plan_derive(base)
if a.hote:
# Les quatre verrous restent en vigueur : on ne fait que RESTREINDRE la liste
# derivee du plan. Raser un seul hote sert a eprouver un gabarit ou a reprendre
# une machine, sans toucher aux treize autres.
couples = [c for c in couples if c[0] == a.hote]
if not couples:
print(f"L'hote « {a.hote} » n'est pas au plan : rien a raser.")
return 2
if not couples:
print("Le plan ne derive aucune VM : rien a raser.")
return 0
cl, _ = Cluster.depuis_hebergeur()
rep = cl("/cluster/resources?type=vm")
if err := cl.rate(rep):
raise SystemExit(f"Cluster injoignable : {err}")
reelles = {int(v["vmid"]): v for v in rep if v.get("type") == "qemu"}
presentes, absentes, usurpees = [], [], []
for nom, vmid in sorted(couples, key=lambda c: c[1]):
v = reelles.get(vmid)
if v is None:
absentes.append((nom, vmid))
elif v.get("name") != nom:
usurpees.append((nom, vmid, v.get("name")))
else:
presentes.append((nom, vmid, v.get("node")))
print(f"Écosystème monté : « {nom_actif} » ({base})\n")
for nom, vmid, noeud in presentes:
print(f" À DÉTRUIRE {vmid} {nom:16} sur {noeud}")
for nom, vmid in absentes:
print(f" absente {vmid} {nom:16} (rien à faire)")
for nom, vmid, autre in usurpees:
print(f" CONFLIT {vmid} le plan dit « {nom} », le cluster dit « {autre} »")
if usurpees:
print(f"\nREFUS : {len(usurpees)} VMID du plan portent un AUTRE nom sur le cluster.\n"
f"Ce sont des machines qui ne nous appartiennent pas. Les renommer ou corriger\n"
f"le plan avant de raser quoi que ce soit — aucune VM n'a ete touchee.")
return 2
if not presentes:
print("\nAucune VM du plan n'est presente : rien a faire.")
return 0
if not a.confirmer:
print(f"\n{len(presentes)} VM seraient DETRUITES, disques compris.\n"
f"Relancer avec : make raser CONFIRMER=true INSTANCE={nom_actif}"
+ (f" HOTE={a.hote}" if a.hote else ""))
return 0
if (a.instance or "").lower() != nom_actif:
print(f"\nREFUS : il faut NOMMER l'ecosysteme a raser.\n"
f"Monte : « {nom_actif} » ; recu : « {a.instance or '(rien)'} ».\n"
f"Taper le nom est ce qui distingue le POC de la production.")
return 2
echecs = 0
for nom, vmid, noeud in presentes:
print(f"\n {vmid} {nom} — arret…", flush=True)
cl(f"/nodes/{noeud}/qemu/{vmid}/status/stop", "POST")
for _ in range(30):
time.sleep(2)
st = cl(f"/nodes/{noeud}/qemu/{vmid}/status/current")
if isinstance(st, dict) and st.get("status") == "stopped":
break
r = cl(f"/nodes/{noeud}/qemu/{vmid}?purge=1&destroy-unreferenced-disks=1", "DELETE")
if err := cl.rate(r):
print(f" {vmid} {nom} — ECHEC : {err}")
echecs += 1
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>
2026-08-10 22:47:28 -04:00
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")
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>
2026-08-10 22:47:28 -04:00
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 ""))
return 1 if echecs else 0
if __name__ == "__main__":
raise SystemExit(main())