diff --git a/CHANGELOG.md b/CHANGELOG.md index dc506a2..d46f065 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/Makefile b/Makefile index f257520..814a195 100644 --- a/Makefile +++ b/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= + @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 \ diff --git a/docs/audit/preuve-2026-08-08.md b/docs/audit/preuve-2026-08-08.md index 4a07e56..c4a071b 100644 --- a/docs/audit/preuve-2026-08-08.md +++ b/docs/audit/preuve-2026-08-08.md @@ -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 diff --git a/scripts/prouver.py b/scripts/prouver.py index f702d12..076b9da 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -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"], diff --git a/scripts/raser.py b/scripts/raser.py new file mode 100755 index 0000000..84ba9ef --- /dev/null +++ b/scripts/raser.py @@ -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=`) 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()) diff --git a/scripts/tests/test_raser.py b/scripts/tests/test_raser.py new file mode 100644 index 0000000..80e89cf --- /dev/null +++ b/scripts/tests/test_raser.py @@ -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")