make raser : la seule commande destructive du moteur, avec ses quatre verrous

Ajoutee pour rendre la reconstruction from-zero REPETABLE — un test qu'on ne
peut jouer qu'une fois n'est pas une recette.

Verrous, tous eprouves avant usage :
- VMID derives du plan uniquement (le gabarit dore est structurellement exclu)
- le NOM doit correspondre : un VMID du plan sous un autre nom fait refuser
  l'operation ENTIERE. Pas theorique — le 2026-08-07 une VM heritee portait un
  VMID du plan sous le nom web-frontal-01 et proxmox_kvm rapportait ok.
- il faut NOMMER l'ecosysteme (INSTANCE=) : le symlink instance/ peut pointer
  n'importe ou ; taper le nom distingue le POC de la production.
- CONFIRMER=true ; sans lui, inventaire et rien d'autre.

Le verrou du nom est le seul qu'on ne peut pas eprouver sur le vrai cluster
sans y fabriquer une collision : scripts/tests/test_raser.py l'isole derriere
un faux cluster, rattache a P02. Harnais : 31 preuves, 0 sautee.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-08 14:22:04 -04:00
parent 7dcbc6a0a4
commit a430edf014
6 changed files with 216 additions and 6 deletions

View file

@ -1,5 +1,30 @@
# CHANGELOG — Set-OPS
## 2026-08-08 — `make raser` : la seule commande destructive du moteur
Ajoutée pour rendre la reconstruction from-zero **répétable** — un test qu'on ne peut jouer
qu'une fois, à la main, n'est pas une recette. Tout le reste du dépôt crée ou réconcilie ;
celle-ci détruit, et elle est écrite en conséquence.
**Quatre verrous, tous éprouvés avant usage :**
| Verrou | Ce qu'il empêche | Vérifié |
|---|---|---|
| VMID **dérivés du plan** uniquement | détruire une VM hors écosystème ; le gabarit doré est structurellement exclu, son VMID ne se dérive pas | à blanc : 14 VM listées, template absent |
| **le nom doit correspondre** | détruire la machine de quelqu'un d'autre sous un VMID du plan | test isolé, faux cluster |
| **nommer l'écosystème** (`INSTANCE=`) | raser la mauvaise instance : le symlink `instance/` peut pointer n'importe où | refus sans nom, et refus sur mauvais nom |
| `CONFIRMER=true` | tout le reste | sans lui : inventaire, rien d'autre |
Le deuxième mérite d'être détaillé, parce qu'il vient d'un fait et non d'une précaution
abstraite : le 2026-08-07, une VM héritée portait un VMID du plan sous le nom
`web-frontal-01`, et `proxmox_kvm` avait rapporté `ok` sans rien faire. Rasé sans ce
contrôle, on détruisait une machine étrangère. Le refus porte sur **l'opération entière**,
pas sur la seule VM en conflit — un cluster qui ment sur un VMID peut mentir sur d'autres.
C'est aussi le seul verrou qu'on ne peut pas éprouver sur le vrai cluster sans y fabriquer
une collision : `scripts/tests/test_raser.py` isole la logique derrière un faux cluster, et
le test est rattaché à **P02**. Le harnais passe désormais **31 preuves, 0 sautée**.
## 2026-08-08 — État de référence figé avant la reconstruction from-zero
L'exploitant recadre : les 14 VM sont un **POC**, pas de la production. Ma prudence venait

View file

@ -209,6 +209,7 @@ syntaxe-proxmox: ansible-runtime ## Verifie la syntaxe du playbook de clonage de
.PHONY: test
test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
python3 scripts/tests/test_inventory_host.py
python3 scripts/tests/test_raser.py
.PHONY: verifier
verifier: lint test inventaire-verifier site-verifier flux-verifier syntaxe ## Rejoue les preuves SANS reecrire le rapport (verification rapide)
@ -580,6 +581,9 @@ _attendre-flotte: ansible-runtime
printf 'Flotte joignable.\n'
.PHONY: reconstruire
raser: ansible-runtime _instance-requise ## DESTRUCTIF : detruit les VM derivees du plan — exige CONFIRMER=true ET INSTANCE=<nom>
@python3 scripts/raser.py $(if $(INSTANCE),--instance $(INSTANCE)) $(if $(filter true,$(CONFIRMER)),--confirmer)
reconstruire: _instance-requise ## Reconstruit un ecosysteme depuis zero : VM puis deploiement complet
@set -e; \
if [[ "$(CONFIRMER)" != "true" ]]; then \

View file

@ -7,14 +7,14 @@
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (30 OK · 0 echec · 1 saute)
- **Verdict** : ✅ CONFORME (31 OK · 0 echec · 0 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  |
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | 5 tests passes. |
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | >>> le verrou tient : aucune VM n'aurait ete touchee |
| P03 | Diff-vide du plan (inventaire genere) | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | DIFF VIDE : le plan reproduit exactement l'inventaire actuel. Bascule possible. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
@ -28,9 +28,9 @@
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ⚪ SAUTE | Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026). |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 14 hotes, 31 groupes (inventaire dechiffre et parse). |
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 25 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 25 secret(s) exige(s), tous presents. Voute reelle : 28 cle(s), aucun manque. |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 2 instance(s) federee(s), aucun index en collision. |
@ -43,7 +43,7 @@
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 2 pool(s) Proxmox, 28 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 23 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 12, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 35 scripts expliques et atteignables, 85 cibles make documentees, 54 roles avec README. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 36 scripts expliques et atteignables, 86 cibles make documentees, 54 roles avec README. |
## Couverture des affirmations ✅ du registre

View file

@ -496,7 +496,8 @@ PREUVES: list[dict] = [
{"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"],
"cmds": [["ansible-lint", "-q"]]},
{"id": "P02", "titre": "Tests unitaires (inventory_host)", "refs": [],
"cmds": [[sys.executable, "scripts/tests/test_inventory_host.py"]]},
"cmds": [[sys.executable, "scripts/tests/test_inventory_host.py"],
[sys.executable, "scripts/tests/test_raser.py"]]},
{"id": "P03", "titre": "Diff-vide du plan (inventaire genere)", "refs": ["AFF-001", "AFF-004", "AFF-030", "AFF-031", "AFF-032"],
"refs": ["AFF-001", "AFF-004", "AFF-030", "AFF-031", "AFF-032"],
"cmds": [[sys.executable, "scripts/instancier.py", "generer"],

150
scripts/raser.py Executable file
View file

@ -0,0 +1,150 @@
#!/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
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("--confirmer", action="store_true")
a = ap.parse_args(argv)
base, nom_actif = instance_active()
couples = plan_derive(base)
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}")
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
else:
print(f" {vmid} {nom} — detruite")
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())

View file

@ -0,0 +1,30 @@
"""`raser` : un VMID du plan portant un AUTRE nom doit faire REFUSER l'operation entiere.
C'est le verrou le plus important de la seule commande destructive du moteur, et le seul
qu'on ne peut pas eprouver sur le vrai cluster sans y fabriquer une collision. On isole
donc la logique derriere un faux cluster.
Le danger 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. Rase sans ce
controle, on detruisait la machine de quelqu'un d'autre.
"""
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import raser
class FauxCluster:
"""Le cluster repond que 117603101 s'appelle « vm-heritee-de-2019 »."""
@classmethod
def depuis_hebergeur(cls):
return cls(), None
def __call__(self, chemin, methode="GET", corps=None):
return [{"type": "qemu", "vmid": 117603101, "name": "vm-heritee-de-2019", "node": "asgard"},
{"type": "qemu", "vmid": 117101101, "name": "infra-edge-01", "node": "asgard"}]
def rate(self, rep):
return None
raser.Cluster = FauxCluster
code = raser.main(["--instance", "chezlepro", "--confirmer"])
print(f"\n>>> code de sortie : {code} (2 = refus attendu)")
assert code == 2, "LE VERROU N'A PAS TENU"
print(">>> le verrou tient : aucune VM n'aurait ete touchee")