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:
parent
7dcbc6a0a4
commit
a430edf014
6 changed files with 216 additions and 6 deletions
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
4
Makefile
4
Makefile
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 | [0m[0m |
|
||||
| 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
150
scripts/raser.py
Executable 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())
|
||||
30
scripts/tests/test_raser.py
Normal file
30
scripts/tests/test_raser.py
Normal 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")
|
||||
Loading…
Reference in a new issue