2026-08-08 14:22:04 -04:00
|
|
|
#!/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"
|
|
|
|
|
|
|
|
|
|
|
2026-08-08 14:22:04 -04:00
|
|
|
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)")
|
2026-08-09 11:50:34 -04:00
|
|
|
ap.add_argument("--hote", help="ne raser QUE cet hote (les quatre verrous s'appliquent)")
|
2026-08-08 14:22:04 -04:00
|
|
|
ap.add_argument("--confirmer", action="store_true")
|
|
|
|
|
a = ap.parse_args(argv)
|
|
|
|
|
|
|
|
|
|
base, nom_actif = instance_active()
|
|
|
|
|
couples = plan_derive(base)
|
2026-08-09 11:50:34 -04:00
|
|
|
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
|
2026-08-08 14:22:04 -04:00
|
|
|
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"
|
2026-08-09 11:50:34 -04:00
|
|
|
f"Relancer avec : make raser CONFIRMER=true INSTANCE={nom_actif}"
|
|
|
|
|
+ (f" HOTE={a.hote}" if a.hote else ""))
|
2026-08-08 14:22:04 -04:00
|
|
|
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":
|
2026-08-08 14:22:04 -04:00
|
|
|
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
|
2026-08-08 14:22:04 -04:00
|
|
|
|
|
|
|
|
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())
|