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
dans les deux sens.
`filiation` → `emancipation-prouver` se declarait « mesure » en portant
`fixes: {CONFIRMER: "true"}`. La cible tranche elle-meme — son refus dit
« cette preuve COUPE l'amont quelques secondes pour mesurer ». Un constat
rapporte ne rend pas inerte le geste qui l'obtient : l'etape passe a
`ecriture`.
Dans l'autre sens, trois cibles — flotte-creer, deployer-tout, reconstruire —
refusent sans confirmation sans que le registre le declare : un assistant les
lancait telles quelles, et sortait en 2. Comparer `nature` a `fixes` ne les
voyait pas, car c'est comparer deux champs ecrits par la meme main. `verifier`
lit desormais la RECETTE, qui ne ment pas.
Effet de bord traite : une etape qui agit barre celles qui la suivent dans la
console, alors qu'une « mesure » se debloque d'office. `depots-perimes` est
marque `facultative` — il nettoie ce que la filiation a laisse, il n'en est pas
le prealable.
`lister --json` rend le registre assemble d'un bloc, et le drapeau est refuse
hors de son action : rendre la prose humaine a qui demande du JSON est pire
qu'un refus. L'affichage humain ne bouge pas, et la preuve mesure ce qu'il
PORTE — un identifiant, un libelle, les marques de nature — et non seulement
qu'il n'est pas du JSON.
Valide : `verifier` a 0 ecart, test_runbooks.py 25/25, et sept des neuf
epreuves de `make test` ; test_raser et test_raser_resultat echouent sur
« Aucune instance montee », avant comme apres. Mesure : 127 etapes, dont 84 de
nature « mesure » — la surface qui n'agit pas.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RbXr5vv4Rat6PCPGG9GoG8
337 lines
15 KiB
Python
337 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Le registre des runbooks : sa garde attrape-t-elle vraiment le retard ?
|
|
|
|
P83 verifie que le registre REEL dit vrai aujourd'hui. Ce n'est pas la meme chose que
|
|
verifier que la GARDE fonctionne : un `verifier()` qui rendrait toujours une liste vide
|
|
passerait P83 tous les jours, et le jour ou le registre prendrait du retard, personne ne
|
|
le saurait. On lui presente donc quatre registres FAUX, un par forme de retard, et on
|
|
exige qu'elle les refuse.
|
|
|
|
La cinquieme verification porte sur l'index : « Le premier jour d'un site » lance
|
|
`site-deployer-tout` deux fois, et les deux places n'ont pas le meme sens. Chercher une
|
|
etape par son nom seul confondrait les deux.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
RACINE = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
|
|
import runbooks as rb # noqa: E402
|
|
|
|
SOURCE = (RACINE / "docs/runbooks-construction.yml").read_text(encoding="utf-8")
|
|
|
|
|
|
def _registre(texte: str) -> Path:
|
|
fd, nom = tempfile.mkstemp(suffix=".yml")
|
|
os.close(fd)
|
|
p = Path(nom)
|
|
p.write_text(texte, encoding="utf-8")
|
|
return p
|
|
|
|
|
|
class TestGardeDesRunbooks(unittest.TestCase):
|
|
|
|
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 test_le_registre_reel_ne_porte_aucun_ecart(self):
|
|
"""Le socle : ce que P83 mesure aussi, mais ici sans le harnais complet."""
|
|
self.assertEqual(rb.verifier(RACINE), [])
|
|
|
|
def test_une_cible_que_le_makefile_ne_porte_plus_est_refusee(self):
|
|
"""Le cas le plus courant : une cible renommee, un registre qui suit avec retard."""
|
|
faux = SOURCE.replace("- cible: site-creer", "- cible: site-creer-ancien-nom", 1)
|
|
ecarts = self._verifier(faux)
|
|
self.assertTrue(any("ne porte pas cette cible" in e for e in ecarts), ecarts)
|
|
|
|
def test_une_cible_documentee_oubliee_est_refusee(self):
|
|
"""L'oubli silencieux : la console cacherait un pouvoir que le moteur possede."""
|
|
bloc = """ - cible: dns-bascule-devis
|
|
nature: mesure
|
|
pourquoi: >-
|
|
Basculer nos serveurs de noms changerait-il quelque chose ? Le plan face au DNS
|
|
reellement en service, avant de toucher a la delegation.
|
|
"""
|
|
self.assertIn(bloc, SOURCE)
|
|
ecarts = self._verifier(SOURCE.replace(bloc, "", 1))
|
|
self.assertTrue(any("nul runbook ne porte" in e for e in ecarts), ecarts)
|
|
|
|
def test_une_etape_sans_pourquoi_est_refusee(self):
|
|
"""Une etape qui ne dit pas pourquoi elle est la se fait sauter par l'exploitant."""
|
|
bloc = """ - cible: lint
|
|
nature: mesure
|
|
pourquoi: "`ansible-lint` sur tout le depot."
|
|
"""
|
|
self.assertIn(bloc, SOURCE)
|
|
faux = SOURCE.replace(bloc, """ - cible: lint
|
|
nature: mesure
|
|
""", 1)
|
|
ecarts = self._verifier(faux)
|
|
self.assertTrue(any("aucun « pourquoi »" in e for e in ecarts), ecarts)
|
|
|
|
def test_une_exemption_sans_objet_est_refusee(self):
|
|
"""Une exemption qui survit a sa cible devient un mensonge tranquille."""
|
|
ecarts = self._verifier(SOURCE + '\n cible-qui-n-existe-pas: "motif quelconque"\n')
|
|
self.assertTrue(any("exemption devenue sans objet" in e for e in ecarts), ecarts)
|
|
|
|
def test_une_nature_hors_vocabulaire_est_refusee(self):
|
|
"""La console ne sait pas peser une nature qu'elle ignore : elle doit bloquer."""
|
|
faux = SOURCE.replace(""" - cible: lint
|
|
nature: mesure""", """ - cible: lint
|
|
nature: peut-etre""", 1)
|
|
ecarts = self._verifier(faux)
|
|
self.assertTrue(any("hors" in e and "vocabulaire" in e for e in ecarts), ecarts)
|
|
|
|
def test_une_variable_hors_catalogue_est_refusee(self):
|
|
"""Sans invite declaree, la console afficherait un champ libre sans le nommer."""
|
|
faux = SOURCE.replace(" variables: [HOTE]",
|
|
" variables: [MACHIN]", 1)
|
|
ecarts = self._verifier(faux)
|
|
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.
|
|
|
|
Declaree au seul runbook, une unique etape qui materialise fermait TOUTE la sequence
|
|
a un locataire : il ne pouvait plus deployer sa propre flotte, ce qui est exactement
|
|
son metier. Ces tests refusent le retour de ce defaut.
|
|
"""
|
|
|
|
def test_chaque_etape_porte_une_portee_connue(self):
|
|
for rbk in rb.assembler(RACINE):
|
|
for etape in rbk["etapes"]:
|
|
self.assertIn(etape["portee"], rb.PORTEES,
|
|
f"{rbk['id']} → {etape['cible']}")
|
|
|
|
def test_une_etape_muette_herite_de_son_runbook(self):
|
|
for rbk in rb.assembler(RACINE):
|
|
brut = next(r for r in rb.charger()["runbooks"] if r["id"] == rbk["id"])
|
|
for etape, cru in zip(rbk["etapes"], brut["etapes"]):
|
|
if "portee" not in cru:
|
|
self.assertEqual(etape["portee"], rbk["portee"],
|
|
f"{rbk['id']} → {etape['cible']}")
|
|
|
|
def test_un_locataire_conduit_le_deploiement_de_sa_flotte(self):
|
|
"""Le defaut exact, nomme : `deployer-tout` doit rester a portee d'un locataire."""
|
|
rbk = next(r for r in rb.assembler(RACINE) if r["id"] == "locataire-deployer")
|
|
self.assertEqual(rbk["portee"], "tenant")
|
|
par_cible = {e["cible"]: e["portee"] for e in rbk["etapes"]}
|
|
self.assertEqual(par_cible["deployer-tout"], "tenant")
|
|
self.assertEqual(par_cible["valider"], "tenant")
|
|
# Et il bute precisement la ou il faut : sur la machine a engendrer.
|
|
self.assertEqual(par_cible["flotte-creer"], "poste")
|
|
|
|
def test_engendrer_une_machine_exige_la_fabric(self):
|
|
rbk = next(r for r in rb.assembler(RACINE) if r["id"] == "machine-une")
|
|
par_cible = {e["cible"]: e["portee"] for e in rbk["etapes"]}
|
|
for cible in ("creer-vm", "cloner-vm"):
|
|
self.assertEqual(par_cible[cible], "poste", cible)
|
|
for cible in ("deployer", "verifier-hote", "hote-afficher"):
|
|
self.assertEqual(par_cible[cible], "tenant", cible)
|
|
|
|
|
|
class TestEtapeDeclaree(unittest.TestCase):
|
|
"""Le point de controle du serveur : une PLACE, pas un nom."""
|
|
|
|
def test_une_cible_absente_du_runbook_ne_se_trouve_pas(self):
|
|
self.assertIsNone(rb.etape_declaree("locataire-naitre", "raser", RACINE))
|
|
|
|
def test_l_index_doit_designer_la_meme_cible(self):
|
|
etapes = next(r for r in rb.assembler(RACINE) if r["id"] == "locataire-naitre")["etapes"]
|
|
i = next(i for i, e in enumerate(etapes) if e["cible"] == "instance-courante")
|
|
self.assertIsNotNone(rb.etape_declaree("locataire-naitre", "instance-courante", RACINE, i))
|
|
# Le meme nom, une autre place : on ne lance rien.
|
|
autre = 0 if i != 0 else 1
|
|
self.assertIsNone(rb.etape_declaree("locataire-naitre", "instance-courante", RACINE, autre))
|
|
|
|
def test_une_cible_qui_revient_garde_ses_deux_places(self):
|
|
"""`site-deployer-tout` est joue DEUX fois, et les deux n'ont pas le meme sens."""
|
|
rbk = next(r for r in rb.assembler(RACINE) if r["id"] == "site-premier-jour")
|
|
places = [i for i, e in enumerate(rbk["etapes"]) if e["cible"] == "site-deployer-tout"]
|
|
self.assertEqual(len(places), 2, "le runbook du premier jour doit porter deux passages")
|
|
for i in places:
|
|
self.assertIsNotNone(
|
|
rb.etape_declaree("site-premier-jour", "site-deployer-tout", RACINE, i))
|
|
|
|
def test_le_libelle_vient_du_makefile_et_non_du_registre(self):
|
|
"""La preuve que le registre ne recopie rien : le libelle est celui du Makefile."""
|
|
aides = rb.cibles_du_makefile(RACINE)
|
|
for rbk in rb.assembler(RACINE):
|
|
for etape in rbk["etapes"]:
|
|
self.assertEqual(etape["libelle"], aides[etape["cible"]])
|
|
# Et le registre BRUT n'en porte aucune copie : on lit la donnee, pas le texte
|
|
# (le mot « libelle » apparait dans les commentaires, qui expliquent justement
|
|
# pourquoi il ne doit pas y etre).
|
|
brut = rb.charger(RACINE / "docs/runbooks-construction.yml")
|
|
for rbk in brut["runbooks"]:
|
|
for etape in rbk["etapes"]:
|
|
self.assertNotIn("libelle", etape,
|
|
f"{rbk['id']} → {etape['cible']} recopie un libelle")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|