diff --git a/CHANGELOG.md b/CHANGELOG.md index c4bc8fb..4a13261 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,64 @@ # CHANGELOG — Set-OPS +## 2026-09-25 — Le registre disait « mesure » d'une cible qui coupe l'amont + +**21 preuves sur `test_runbooks.py`, `verifier` à 0 écart.** Un outil tiers veut +n'offrir de ce moteur que ce qui n'agit pas. Le seul champ qui le lui dise est +`nature`. Il fallait donc qu'il ne mente pas — et il mentait une fois. + +### Une mesure qui demande confirmation n'en est pas une + +`filiation → emancipation-prouver` se déclarait `nature: mesure` et portait +`fixes: {CONFIRMER: "true"}`. Les deux ne peuvent pas être vrais ensemble : la +cible refuse sans confirmation, et son propre refus dit pourquoi — *« cette +preuve COUPE l'amont quelques secondes pour mesurer »*. La coupure est retirée +quoi qu'il arrive, mais pendant ce temps la fonction éprouvée peut échouer. + +Le mot « prouver » avait emporté la décision. Un constat rapporté ne rend pas +inerte le geste qui l'obtient. L'étape est désormais `nature: ecriture`, et son +`pourquoi` dit ce qu'elle coupe. + +`verifier()` refuse maintenant cette contradiction : rien ne la voyait, et +chaque lecteur du registre refaisait l'enquête. Mesuré avant correction : un +écart, exactement celui-là. + +### Lire le registre sans analyser un arbre fait pour l'œil + +`runbooks.py lister --json` rend le registre assemblé d'un bloc. Sans lui, un +outil tiers n'avait le choix qu'entre analyser la sortie humaine — qui dérive +au premier changement de mise en page — et importer ce module, ce qui le lie à +nos noms internes. Les deux se paient plus tard. + +L'affichage humain ne change pas, et une preuve le tient : un `lister` qui +rendrait toujours du JSON passerait sinon les épreuves du drapeau sans que +personne ne le voie. + +### La recette tranche, le registre ne se compare plus à lui-même + +Le premier invariant comparait `nature` à `fixes` — deux champs écrits par la +même main. Le même mensonge repassait en EFFAÇANT la ligne `fixes`, et trois +cibles le portaient ainsi : `flotte-creer`, `deployer-tout`, `reconstruire` +refusent sans confirmation, le registre ne le déclarait pas, et un assistant +les lançait telles quelles — sortie en 2. Le contrôle lit désormais la +RECETTE, qui ne ment pas : elle refuse, et c'est ce refus que l'exploitant +rencontre. + +### Une étape qui agit barre celles qui la suivent + +Passer `emancipation-prouver` à `ecriture` l'a placée derrière un ménage +destructif : la console débloque d'office une étape « mesure », mais fait +attendre toute autre que la précédente ait réussi dans la session. Un ménage +qu'on peut n'avoir rien à faire rendait donc la preuve injouable. +`depots-perimes` est marqué `facultative` — il nettoie ce que la filiation a +laissé, il n'est pas son préalable. + +Mesuré sur le registre réel : 17 runbooks, 127 étapes. **84 sont de nature +`mesure`** — c'est la surface qui n'agit pas, et la seule qu'un outil puisse +offrir sans confirmation. Les 110 dont les `fixes` ne portent pas de +`CONFIRMER` en contiennent 26 d'écriture, dont « déploie TOUTE la flotte » : +compter sur ce chiffre pour dire « sûr » serait une erreur de lecture. + + ## 2026-09-20 (12) — Une décision appliquée à moitié se croit tenue **83 preuves, `make test` à 0 échec.** L'exploitant a demandé : « n'avions-nous pas décidé diff --git a/docs/runbooks-construction.yml b/docs/runbooks-construction.yml index e81f048..3ec583a 100644 --- a/docs/runbooks-construction.yml +++ b/docs/runbooks-construction.yml @@ -357,12 +357,14 @@ runbooks: nature: ecriture portee: poste variables: [PARALLELE] + fixes: {CONFIRMER: "true"} pourquoi: >- Les VM manquantes, clonees depuis le gabarit dore. VMID, IP et VLAN sont DERIVES du plan : on ne les saisit nulle part. - cible: deployer-tout nature: ecriture duree: "long" + fixes: {CONFIRMER: "true"} pourquoi: >- Toute la flotte, dans l'ordre des couches. L'ordre vient du graphe de dependances, pas d'une liste tenue a la main. @@ -449,6 +451,7 @@ runbooks: nature: ecriture portee: poste duree: "long" + fixes: {CONFIRMER: "true"} pourquoi: >- Refaire tout depuis zero : les VM, puis le deploiement complet. Si le code ne suffit pas, c'est ici qu'on l'apprend. @@ -635,18 +638,32 @@ runbooks: - cible: depots-perimes nature: destructif fixes: {CONFIRMER: "true"} + # FACULTATIVE, SINON ELLE BARRE LA PREUVE QUI LA SUIT. La console + # debloque d'office une etape « mesure » ; toute autre attend que la + # precedente non facultative ait REUSSI dans la session. Un menage + # qu'on peut ne pas avoir a faire — aucun depot perime ce jour-la — + # rendrait alors l'emancipation injouable. Le ménage n'est pas un + # prealable a la preuve : il nettoie ce que la filiation a laisse. + facultative: true pourquoi: >- Un depot raye du plan reste sur le disque du runner, qui garde de quoi lire un ecosysteme qu'il ne declare plus. Trois choses ne sont jamais retirees : ce qui n'est pas un depot git, ce qui porte des modifications non validees, et ce qui porte des commits qu'aucun distant ne porte. - cible: emancipation-prouver - nature: mesure + # ECRITURE, ET NON MESURE, MALGRE LE MOT « PROUVER ». La cible COUPE + # l'amont quelques secondes pour mesurer : elle refuse d'ailleurs sans + # CONFIRMER=true, et le dit. Le fait qu'elle rapporte un constat ne la + # rend pas inerte. Declarée « mesure », elle se serait offerte a tout + # outil qui ne propose que ce qui n'agit pas. + nature: ecriture variables: [SERVICE, HOTE] fixes: {CONFIRMER: "true"} pourquoi: >- - Prouver qu'un lien est coupe. La mesure instruit ; l'humain decide. Une - emancipation automatique serait une expulsion. + Prouver qu'un lien est coupe, en le coupant. La coupure est retiree quoi + qu'il arrive, mais la fonction eprouvee peut echouer pendant ce temps. + La mesure instruit ; l'humain decide. Une emancipation automatique serait + une expulsion. # ───────────────────────────────────────────────────────────────────────────── - id: remise diff --git a/scripts/runbooks.py b/scripts/runbooks.py index a0bdc7c..f79486b 100644 --- a/scripts/runbooks.py +++ b/scripts/runbooks.py @@ -29,6 +29,7 @@ carte perimee est pire que pas de carte, parce qu'on la suit. from __future__ import annotations import argparse +import json import re import sys from pathlib import Path @@ -74,6 +75,39 @@ def cibles_du_makefile(racine: Path = RACINE) -> dict[str, str]: return {nom: aide for nom, aide in MOTIF_CIBLE.findall(texte)} +def cibles_qui_exigent_confirmation(racine: Path = RACINE) -> set[str]: + """Les cibles dont la RECETTE teste `CONFIRMER`. + + POURQUOI LIRE LA RECETTE ET NON LE REGISTRE. Comparer `nature` a `fixes` + ne compare que deux champs ecrits par la meme main : le meme mensonge + repasse en EFFACANT la ligne `fixes`. La recette, elle, ne ment pas — elle + refuse, et c'est ce refus que l'exploitant rencontre. + + Une recette va de sa ligne de cible jusqu'a la prochaine ligne non + indentee ; une ligne vide ne l'interrompt pas. + """ + texte = (racine / "Makefile").read_text(encoding="utf-8") + exigent: set[str] = set() + etat: dict = {"cible": "", "recette": []} + + def _clore() -> None: + if etat["cible"] and "CONFIRMER" in "\n".join(etat["recette"]): + exigent.add(etat["cible"]) + + for ligne in texte.splitlines(): + debut = re.match(r"^([A-Za-z0-9_.-]+):", ligne) + if debut: + _clore() + etat["cible"], etat["recette"] = debut.group(1), [] + elif etat["cible"] and (ligne.startswith("\t") or ligne.startswith(" ")): + etat["recette"].append(ligne) + elif ligne.strip(): + _clore() + etat["cible"], etat["recette"] = "", [] + _clore() + return exigent + + def charger(chemin: Path = REGISTRE) -> dict: """Le registre brut, tel qu'il est ecrit. Aucune jointure, aucune validation.""" if not chemin.is_file(): @@ -165,6 +199,7 @@ def verifier(racine: Path = RACINE, chemin: Path | None = None) -> list[str]: aides = cibles_du_makefile(racine) catalogue = data.get("variables", {}) or {} hors = data.get("hors_assistant", {}) or {} + exigent = cibles_qui_exigent_confirmation(racine) ecarts: list[str] = [] portees_vues: dict[str, set[str]] = {} @@ -198,6 +233,28 @@ def verifier(racine: Path = RACINE, chemin: Path | None = None) -> list[str]: if etape.get("nature") not in NATURES: ecarts.append(f"{rid} → {cible} : nature « {etape.get('nature')} » hors " f"vocabulaire ({', '.join(sorted(NATURES))}).") + # LA RECETTE TRANCHE, DANS LES DEUX SENS. Ce que la cible EXIGE + # se lit dans son Makefile ; ce que le registre PROMET se lit dans + # `fixes`. Comparer le registre a lui-meme laissait passer le meme + # mensonge ecrit dans l'autre sens — il suffisait d'effacer + # `fixes`, et trois cibles le portaient ainsi : un assistant les + # lancait sans confirmation, et sortait en 2. + declare = "CONFIRMER" in (etape.get("fixes") or {}) + if cible in exigent and not declare: + ecarts.append(f"{rid} → {cible} : la recette refuse sans CONFIRMER " + f"et le registre ne le declare pas. Un assistant la " + f"lancerait telle quelle, et sortirait en 2.") + if declare and cible not in exigent: + ecarts.append(f"{rid} → {cible} : le registre declare CONFIRMER que " + f"la recette n'exige pas. Une confirmation de plus " + f"s'apprend a cliquer sans lire.") + # UNE MESURE QUI AGIT N'EN EST PAS UNE : une cible qui refuse sans + # confirmation TOUCHE au systeme, quel que soit le fait qu'elle en + # rapporte ensuite. + if etape.get("nature") == "mesure" and (declare or cible in exigent): + ecarts.append(f"{rid} → {cible} : declaree « mesure » alors qu'elle " + f"exige CONFIRMER. Une mesure qui demande " + f"confirmation agit.") if not etape.get("pourquoi"): ecarts.append(f"{rid} → {cible} : aucun « pourquoi ». Une etape qui ne dit " f"pas pourquoi elle est la se fait sauter.") @@ -226,7 +283,21 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("action", choices=("verifier", "lister", "couverture"), nargs="?", default="verifier") + # POURQUOI UN --json. Le registre est la seule source qui dise la NATURE + # d'une etape et la confirmation qu'elle exige. Un outil tiers qui veut + # n'offrir que ce qui ne detruit pas doit donc le lire — et sans drapeau, + # il n'a le choix qu'entre analyser un arbre fait pour l'oeil humain, qui + # derive au premier changement de mise en page, et importer ce module, + # ce qui le lie a nos noms internes. Les deux se paient plus tard. + parser.add_argument("--json", action="store_true", + help="Rend le registre assemble en JSON (avec " + "« lister »), pour un outil tiers.") args = parser.parse_args() + # RENDRE LA PROSE HUMAINE A QUI DEMANDE DU JSON est pire qu'un refus : + # l'appelant lit une reponse qu'il croit analysable, et ne s'en apercoit + # qu'au premier caractere inattendu. + if args.json and args.action != "lister": + parser.error("--json n'existe qu'avec « lister ».") if args.action == "verifier": ecarts = verifier() @@ -242,6 +313,11 @@ def main() -> int: return 0 if args.action == "lister": + if args.json: + # Sortie NUE, sans en-tete ni resume : un appelant la lit d'un + # bloc. `ensure_ascii=False` garde les accents du registre. + print(json.dumps(assembler(), ensure_ascii=False)) + return 0 for rb in assembler(): print(f"\n{rb['id']} [{rb['portee']}] {rb['titre']}") print(f" {rb['but']}") diff --git a/scripts/tests/test_runbooks.py b/scripts/tests/test_runbooks.py index aff450b..ff3540c 100644 --- a/scripts/tests/test_runbooks.py +++ b/scripts/tests/test_runbooks.py @@ -13,7 +13,9 @@ etape par son nom seul confondrait les deux. """ from __future__ import annotations +import json import os +import subprocess import sys import tempfile import unittest @@ -105,6 +107,152 @@ class TestGardeDesRunbooks(unittest.TestCase): self.assertTrue(any("n'est pas au catalogue" in e for e in ecarts), ecarts) +class TestUneMesureNagitPas(unittest.TestCase): + """`nature` est le seul champ sur lequel un outil tiers peut decider de + n'offrir que ce qui n'agit pas. Il faut donc qu'il ne mente pas. + + Le registre portait une etape declaree « mesure » et refusant de tourner + sans CONFIRMER : la cible COUPE l'amont quelques secondes pour mesurer. + Rien ne le disait, et un lecteur refaisait l'enquete a chaque passage. + """ + + def setUp(self): + self.temporaires: list[Path] = [] + + def tearDown(self): + for p in self.temporaires: + p.unlink(missing_ok=True) + + def _verifier(self, texte: str) -> list[str]: + p = _registre(texte) + self.temporaires.append(p) + return rb.verifier(RACINE, p) + + def _muter(self, avant: str, apres: str) -> list[str]: + """Les ecarts d'un registre mute. La MUTATION est verifiee : une + substitution qui ne s'applique plus rendrait le controle vert sans + avoir rien change.""" + faux = SOURCE.replace(avant, apres, 1) + self.assertNotEqual(faux, SOURCE, "la substitution ne s'applique plus") + return self._verifier(faux) + + def test_une_mesure_qui_exige_une_confirmation_est_refusee(self): + """`depots-perimes` refuse sans CONFIRMER : la declarer « mesure » + fait mentir le seul champ sur lequel un tiers decide.""" + ecarts = self._muter(""" - cible: depots-perimes + nature: destructif""", """ - cible: depots-perimes + nature: mesure""") + self.assertTrue( + any("mesure" in e and "CONFIRMER" in e for e in ecarts), ecarts) + + def test_une_ecriture_qui_exige_une_confirmation_est_normale(self): + """Controle positif : refuser toute confirmation fermerait les + etapes d'ecriture que le moteur garde deliberement ainsi.""" + ecarts = self._muter(""" - cible: depots-perimes + nature: destructif""", """ - cible: depots-perimes + nature: ecriture""") + self.assertEqual( + [], [e for e in ecarts if "CONFIRMER" in e], ecarts) + + def test_une_cible_qui_refuse_sans_confirmation_doit_la_declarer(self): + """L'INVARIANT DANS L'AUTRE SENS. Comparer `nature` a `fixes` ne + compare que deux champs ecrits par la meme main : effacer `fixes` + faisait repasser le meme mensonge. La recette, elle, refuse — et + c'est ce refus que l'exploitant rencontre, en sortie 2.""" + ecarts = self._muter( + """ - cible: depots-perimes + nature: destructif + fixes: {CONFIRMER: "true"}""", + """ - cible: depots-perimes + nature: destructif""") + self.assertTrue( + any("sortirait en 2" in e for e in ecarts), ecarts) + + def test_une_confirmation_declaree_que_la_recette_nexige_pas_est_refusee( + self, + ): + """Une confirmation de plus s'apprend a cliquer sans lire.""" + ecarts = self._muter(""" - cible: lint + nature: mesure""", """ - cible: lint + nature: ecriture + fixes: {CONFIRMER: "true"}""") + self.assertTrue( + any("n'exige pas" in e for e in ecarts), ecarts) + + def test_la_recette_se_lit_vraiment(self): + """Un lecteur de recettes qui rendrait un ensemble vide passerait + les refus ci-dessus sans avoir regarde le Makefile.""" + exigent = rb.cibles_qui_exigent_confirmation(RACINE) + self.assertGreater(len(exigent), 5) + self.assertIn("depots-perimes", exigent) + self.assertNotIn("lint", exigent) + + def test_aucune_mesure_du_registre_reel_nexige_de_confirmation(self): + for rbk in rb.assembler(): + for etape in rbk["etapes"]: + if etape["nature"] != "mesure": + continue + with self.subTest(runbook=rbk["id"], cible=etape["cible"]): + self.assertNotIn("CONFIRMER", etape.get("fixes") or {}) + + +class TestLeRegistreEnJson(unittest.TestCase): + """Un outil tiers lit le registre sans analyser un arbre fait pour l'oeil, + et sans importer ce module — deux liens qui se paient plus tard.""" + + def _lister_json(self) -> list[dict]: + sortie = subprocess.run( + [sys.executable, "-B", str(RACINE / "scripts" / "runbooks.py"), + "lister", "--json"], + capture_output=True, text=True, cwd=RACINE, check=False) + self.assertEqual(0, sortie.returncode, sortie.stderr) + return json.loads(sortie.stdout) + + def test_le_json_dit_la_meme_chose_que_le_registre_assemble(self): + self.assertEqual(rb.assembler(), self._lister_json()) + + def test_le_json_porte_la_nature_et_les_fixes_de_chaque_etape(self): + """Ce sont les deux champs sur lesquels un tiers decide d'offrir une + etape ou non : absents, le drapeau ne sert a rien.""" + etapes = [e for rbk in self._lister_json() for e in rbk["etapes"]] + self.assertTrue(etapes) + for etape in etapes: + with self.subTest(cible=etape.get("cible")): + self.assertIn(etape.get("nature"), rb.NATURES) + self.assertIsInstance(etape.get("fixes"), dict) + + def _lister_humain(self) -> str: + sortie = subprocess.run( + [sys.executable, "-B", str(RACINE / "scripts" / "runbooks.py"), + "lister"], + capture_output=True, text=True, cwd=RACINE, check=False) + self.assertEqual(0, sortie.returncode, sortie.stderr) + return sortie.stdout + + def test_laffichage_humain_porte_toujours_ce_qu_il_portait(self): + """Controle positif. Exiger seulement que la sortie ne soit PAS du + JSON laissait passer trois regressions, dont « ne rien afficher ». + On mesure donc ce qu'elle doit PORTER : un identifiant de runbook, + un libelle de cible, et les trois marques de nature.""" + vu = self._lister_humain() + premier = rb.assembler()[0] + self.assertIn(premier["id"], vu) + self.assertIn(premier["etapes"][0]["cible"], vu) + for marque in ("*", "!"): + self.assertIn(marque, vu) + with self.assertRaises(json.JSONDecodeError): + json.loads(vu) + + def test_le_drapeau_est_refuse_hors_de_son_action(self): + """Rendre la prose humaine a qui demande du JSON est pire qu'un + refus : l'appelant lit une reponse qu'il croit analysable.""" + sortie = subprocess.run( + [sys.executable, "-B", str(RACINE / "scripts" / "runbooks.py"), + "verifier", "--json"], + capture_output=True, text=True, cwd=RACINE, check=False) + self.assertNotEqual(0, sortie.returncode) + + class TestPorteeParEtape(unittest.TestCase): """La portee se pese a l'etape — mesure faite sur la console de TechnoLibre.