assistants : cent trente-deux cibles, et aucune ne disait dans quel ordre
La console offrait des boutons sans sequence. Rien n'y apprenait que site-creer precede
forge-amorcer, que le premier passage de site-deployer-tout s'arrete sur une forge vide
sans que ce soit un echec, ni que rien n'est pret avant valider : cet ordre vivait en
prose dans des documents que la console ne porte pas.
La vue Assistants conduit 17 runbooks et 126 etapes. Les 132 cibles documentees y sont,
chacune portee par un assistant ou exemptee avec son motif — une exemption muette est
refusee. Le registre ne recopie pas le Makefile : il declare l'ordre, la nature, la portee
et le pourquoi, et le libelle de chaque etape est lu dans le Makefile au moment de servir.
P83 est ecrite en meme temps que la liste, pas apres, parce qu'une liste qui suit une
autre prend du retard. Onze tests lui presentent des registres faux, un par forme de
retard, et exigent qu'elle les refuse.
Le navigateur ne nomme pas une commande, il nomme une place : la route lance ce que le
registre declare a cet index-la, avec les seules variables declarees. L'index compte, le
premier jour d'un site jouant site-deployer-tout deux fois. Une etape qui ecrit attend que
la precedente ait reussi ; une mesure reste toujours offerte, parce que mesurer apres un
echec est exactement ce qu'on fait ensuite.
Valide : runbooks.py verifier a 0 ecart, make test a 0 echec, les 83 preuves rejouees, et
la console lancee pour de vrai — 17 runbooks servis, six requetes malformees refusees une
a une, une etape de mesure executee de bout en bout avec son journal.
Limite, anterieure a ce travail : P02 (test_ecriture_plan) echoue sur domaines.yml, a
l'identique sur une copie de HEAD. Ajouter ou retirer un domaine public depuis la vue
Domaines leverait a l'enregistrement. Non corrige ici.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:31:16 -04:00
|
|
|
#!/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
|
|
|
|
|
|
runbooks : la recette tranche, le registre ne se compare plus a lui-meme
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
2026-09-25 17:15:03 -04:00
|
|
|
import json
|
assistants : cent trente-deux cibles, et aucune ne disait dans quel ordre
La console offrait des boutons sans sequence. Rien n'y apprenait que site-creer precede
forge-amorcer, que le premier passage de site-deployer-tout s'arrete sur une forge vide
sans que ce soit un echec, ni que rien n'est pret avant valider : cet ordre vivait en
prose dans des documents que la console ne porte pas.
La vue Assistants conduit 17 runbooks et 126 etapes. Les 132 cibles documentees y sont,
chacune portee par un assistant ou exemptee avec son motif — une exemption muette est
refusee. Le registre ne recopie pas le Makefile : il declare l'ordre, la nature, la portee
et le pourquoi, et le libelle de chaque etape est lu dans le Makefile au moment de servir.
P83 est ecrite en meme temps que la liste, pas apres, parce qu'une liste qui suit une
autre prend du retard. Onze tests lui presentent des registres faux, un par forme de
retard, et exigent qu'elle les refuse.
Le navigateur ne nomme pas une commande, il nomme une place : la route lance ce que le
registre declare a cet index-la, avec les seules variables declarees. L'index compte, le
premier jour d'un site jouant site-deployer-tout deux fois. Une etape qui ecrit attend que
la precedente ait reussi ; une mesure reste toujours offerte, parce que mesurer apres un
echec est exactement ce qu'on fait ensuite.
Valide : runbooks.py verifier a 0 ecart, make test a 0 echec, les 83 preuves rejouees, et
la console lancee pour de vrai — 17 runbooks servis, six requetes malformees refusees une
a une, une etape de mesure executee de bout en bout avec son journal.
Limite, anterieure a ce travail : P02 (test_ecriture_plan) echoue sur domaines.yml, a
l'identique sur une copie de HEAD. Ajouter ou retirer un domaine public depuis la vue
Domaines leverait a l'enregistrement. Non corrige ici.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:31:16 -04:00
|
|
|
import os
|
runbooks : la recette tranche, le registre ne se compare plus a lui-meme
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
2026-09-25 17:15:03 -04:00
|
|
|
import subprocess
|
assistants : cent trente-deux cibles, et aucune ne disait dans quel ordre
La console offrait des boutons sans sequence. Rien n'y apprenait que site-creer precede
forge-amorcer, que le premier passage de site-deployer-tout s'arrete sur une forge vide
sans que ce soit un echec, ni que rien n'est pret avant valider : cet ordre vivait en
prose dans des documents que la console ne porte pas.
La vue Assistants conduit 17 runbooks et 126 etapes. Les 132 cibles documentees y sont,
chacune portee par un assistant ou exemptee avec son motif — une exemption muette est
refusee. Le registre ne recopie pas le Makefile : il declare l'ordre, la nature, la portee
et le pourquoi, et le libelle de chaque etape est lu dans le Makefile au moment de servir.
P83 est ecrite en meme temps que la liste, pas apres, parce qu'une liste qui suit une
autre prend du retard. Onze tests lui presentent des registres faux, un par forme de
retard, et exigent qu'elle les refuse.
Le navigateur ne nomme pas une commande, il nomme une place : la route lance ce que le
registre declare a cet index-la, avec les seules variables declarees. L'index compte, le
premier jour d'un site jouant site-deployer-tout deux fois. Une etape qui ecrit attend que
la precedente ait reussi ; une mesure reste toujours offerte, parce que mesurer apres un
echec est exactement ce qu'on fait ensuite.
Valide : runbooks.py verifier a 0 ecart, make test a 0 echec, les 83 preuves rejouees, et
la console lancee pour de vrai — 17 runbooks servis, six requetes malformees refusees une
a une, une etape de mesure executee de bout en bout avec son journal.
Limite, anterieure a ce travail : P02 (test_ecriture_plan) echoue sur domaines.yml, a
l'identique sur une copie de HEAD. Ajouter ou retirer un domaine public depuis la vue
Domaines leverait a l'enregistrement. Non corrige ici.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:31:16 -04:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
runbooks : la recette tranche, le registre ne se compare plus a lui-meme
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
2026-09-25 17:15:03 -04:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
assistants : la portee se pese a l'etape, pas a la sequence
Mesure sur la console de TechnoLibre, portee tenant : 6 runbooks conduisibles sur 17, et
parmi les onze fermes, locataire-deployer et machine-une — c'est-a-dire le travail
quotidien d'un locataire. La cause : flotte-creer et creer-vm engendrent des VM et
exigent la fabric, et une portee declaree pour toute la sequence faisait basculer avec eux
des etapes voisines qui ne demandent que ce que le locataire possede deja.
Le runbook ne donne plus que le defaut ; l'etape qui exige davantage le declare. Le
locataire conduit sa sequence et bute precisement la ou il faut : sur la machine a
engendrer, pas sur le deploiement qui suit. La page ferme l'etape seule avec sa raison, et
la garde de la route lit la portee de l'etape visee par son index.
Un droit calcule sur l'ensemble se trompe toujours dans le meme sens : il refuse a
quelqu'un ce qu'il a le droit de faire, et le refus parait fonde puisqu'il nomme un vrai
manque. Il a fallu une console de locataire reelle pour le voir — sur le poste, qui porte
les deux liens, les dix-sept sequences s'affichaient conduisibles.
Valide : runbooks.py verifier a 0 ecart, make test a 0 echec (4 tests neufs, dont un qui
nomme le cas exact), P83 verte. P02 reste en echec pour la raison anterieure deja consignee.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 19:55:02 -04:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
assistants : cent trente-deux cibles, et aucune ne disait dans quel ordre
La console offrait des boutons sans sequence. Rien n'y apprenait que site-creer precede
forge-amorcer, que le premier passage de site-deployer-tout s'arrete sur une forge vide
sans que ce soit un echec, ni que rien n'est pret avant valider : cet ordre vivait en
prose dans des documents que la console ne porte pas.
La vue Assistants conduit 17 runbooks et 126 etapes. Les 132 cibles documentees y sont,
chacune portee par un assistant ou exemptee avec son motif — une exemption muette est
refusee. Le registre ne recopie pas le Makefile : il declare l'ordre, la nature, la portee
et le pourquoi, et le libelle de chaque etape est lu dans le Makefile au moment de servir.
P83 est ecrite en meme temps que la liste, pas apres, parce qu'une liste qui suit une
autre prend du retard. Onze tests lui presentent des registres faux, un par forme de
retard, et exigent qu'elle les refuse.
Le navigateur ne nomme pas une commande, il nomme une place : la route lance ce que le
registre declare a cet index-la, avec les seules variables declarees. L'index compte, le
premier jour d'un site jouant site-deployer-tout deux fois. Une etape qui ecrit attend que
la precedente ait reussi ; une mesure reste toujours offerte, parce que mesurer apres un
echec est exactement ce qu'on fait ensuite.
Valide : runbooks.py verifier a 0 ecart, make test a 0 echec, les 83 preuves rejouees, et
la console lancee pour de vrai — 17 runbooks servis, six requetes malformees refusees une
a une, une etape de mesure executee de bout en bout avec son journal.
Limite, anterieure a ce travail : P02 (test_ecriture_plan) echoue sur domaines.yml, a
l'identique sur une copie de HEAD. Ajouter ou retirer un domaine public depuis la vue
Domaines leverait a l'enregistrement. Non corrige ici.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:31:16 -04:00
|
|
|
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)
|