Set-OPS-Public/scripts/raser.py
Daniel Allaire a204ead8ab pre-vol M4 : raser et le devis de placement nomment le locataire designe
Sur le chemin TENANT=, raser annoncait un ecosysteme monte (rien n'est
monte sur le runner du site) et le devis de placement un tenant « ? ».
Les deux en-tetes disent desormais ce qui est vise et d'ou vient la liste.
Le pre-vol lui-meme est consigne : 13 VM, aucun conflit, placement conforme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:16:04 -04:00

236 lines
10 KiB
Python
Executable file

#!/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
from inventory_rules import instance_courante # noqa: E402
RACINE = Path(__file__).resolve().parent.parent
def nom_court(dossier: Path) -> str:
"""Le nom qu'il faut TAPER pour raser cet ecosysteme : `OPS-Technolibre` -> `technolibre`.
Une seule derivation : `reconstruire_locataire.py` l'importe au lieu de redemander le
nom (2026-09-30) — la ou l'ecosysteme est deja designe explicitement par `TENANT=`.
"""
return dossier.name.removeprefix("OPS-").lower()
def instance_active() -> tuple[Path, str]:
"""Dossier de l'instance montee, et son nom court (celui qu'il faudra taper)."""
base = instance_courante()
if not base.exists():
raise SystemExit("Aucune instance montee : rien a raser.")
reel = base.resolve()
return reel, nom_court(reel)
def couples_publies(locataire: str) -> tuple[Path, list[tuple[str, int]]]:
"""(dossier, [(nom d'hote, vmid)]) d'apres la face reseau que le locataire publie."""
import contexte
loc = contexte.Locataire.charger(locataire)
face = loc.face_publiee()
if face is None:
raise SystemExit(f"{locataire} n'a pas publie sa face reseau : rien a raser par elle.")
return loc.depot, contexte.couples_a_raser(face)
def entete(nom: str, base: Path, nomme: bool) -> str:
"""Ce que le devis vise, et COMMENT il le designe (2026-10-07).
Le chemin `--locataire` annoncait « Ecosysteme monte » sur le runner du site, ou rien
n'est monte : le locataire y est NOMME, et sa face publiee fait foi. Un devis qui raconte
une autre provenance que la sienne fait douter de tout le reste de la liste.
"""
if nomme:
return f"Locataire nommé : « {nom} » ({base}), d'après sa face publiée"
return f"Écosystème monté : « {nom} » ({base})"
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
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")
ap.add_argument("--locataire", help="le locataire NOMME, d'apres sa face reseau publiee "
"(le site, sans monter son depot)")
a = ap.parse_args(argv)
if a.locataire:
# LE SITE RASE UN LOCATAIRE NOMME (2026-10-05, materialisation M2) : ses machines et leurs
# VMID viennent de sa face publiee, sans monter son depot. Les quatre verrous restent :
# le nom court a taper en derive, comme pour l'instance montee.
base, couples = couples_publies(a.locataire)
nom_actif = nom_court(base)
else:
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(entete(nom_actif, base, bool(a.locataire)) + "\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" TENANT={a.locataire}" if a.locataire else "")
+ (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"{'Nomme' if a.locataire else '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
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 ""))
return 1 if echecs else 0
if __name__ == "__main__":
raise SystemExit(main())