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
348 lines
17 KiB
Python
348 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""Les runbooks de construction : l'ordre des gestes, et la garde qui l'empeche de mentir.
|
|
|
|
CE QUE CE MODULE RESOUT (2026-09-20). Le Makefile porte 132 cibles documentees. Elles
|
|
disent chacune ce qu'elle fait, aucune ne dit DANS QUEL ORDRE ni POURQUOI MAINTENANT.
|
|
Un exploitant qui decouvre un site n'a donc aucun moyen de savoir que `site-creer` vient
|
|
avant `forge-amorcer`, que le premier passage s'arrete sur une forge vide, ni que rien
|
|
n'est « pret » avant `valider`. Cette connaissance vit dans les documents, en prose, et
|
|
la console ne la porte pas : elle offre des boutons sans sequence.
|
|
|
|
CE QUI EST DECLARE ICI, ET CE QUI NE L'EST PAS. `docs/runbooks-construction.yml` declare
|
|
l'ORDRE, la NATURE (mesure / ecriture / destructif), la PORTEE et le POURQUOI de chaque
|
|
geste. Il ne recopie JAMAIS le libelle d'une cible : celui-ci est lu dans le Makefile au
|
|
moment de servir. Une seconde liste qui recopierait la premiere prendrait du retard sur
|
|
elle — c'est arrive quatre fois dans ce depot en une seule journee, et la regle qui en
|
|
est sortie est d'ecrire la garde EN MEME TEMPS que la seconde liste.
|
|
|
|
LA GARDE, C'EST `verifier()`. Elle refuse quatre etats :
|
|
|
|
- une etape qui vise une cible que le Makefile n'a pas (le registre a pris du retard) ;
|
|
- une cible documentee que nul runbook ne porte et que nul motif n'exempte (la console
|
|
cacherait un pouvoir que le moteur possede) ;
|
|
- une cible a la fois portee et exemptee (deux verites, aucune) ;
|
|
- une valeur hors vocabulaire : nature, portee, ou variable inconnue du catalogue.
|
|
|
|
P83 rejoue cette garde. Sans elle, ce fichier deviendrait une carte perimee — et une
|
|
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
|
|
|
|
import yaml
|
|
|
|
RACINE = Path(__file__).resolve().parent.parent
|
|
REGISTRE = RACINE / "docs/runbooks-construction.yml"
|
|
|
|
# Le vocabulaire ferme du registre. Une valeur hors de ces ensembles est refusee : mieux
|
|
# vaut un registre qui bloque qu'une console qui interprete une nature qu'elle ignore.
|
|
NATURES = {
|
|
# `mesure` n'ecrit rien et peut etre rejouee sans consequence. La console la propose
|
|
# toujours, meme quand l'etape precedente a echoue : mesurer pour comprendre est
|
|
# exactement ce qu'on fait apres un echec.
|
|
"mesure",
|
|
# `ecriture` change l'etat du monde. La console exige que l'etape precedente ait
|
|
# reussi, sinon on batit sur un terrain qu'on n'a pas verifie.
|
|
"ecriture",
|
|
# `destructif` detruit. La console exige une confirmation ecrite, en plus de la
|
|
# variable CONFIRMER que la cible reclame deja d'elle-meme.
|
|
"destructif",
|
|
}
|
|
|
|
# La portee, au sens de `contexte()` dans la console : ce que la MACHINE porte.
|
|
# tenant — un ecosysteme est monte (`instance/`), on a sa voute : on configure.
|
|
# site — une fabric est montee (`underlay.yml`) : on materialise.
|
|
# poste — les deux, l'atelier du mainteneur.
|
|
# toute — ni l'un ni l'autre n'est requis (verifications du depot lui-meme).
|
|
PORTEES = {"tenant", "site", "poste", "toute"}
|
|
|
|
MOTIF_CIBLE = re.compile(r"^([a-z0-9][a-z0-9_-]*):[^#\n]*##\s*(.+?)\s*$", re.MULTILINE)
|
|
|
|
|
|
def cibles_du_makefile(racine: Path = RACINE) -> dict[str, str]:
|
|
"""Les cibles DOCUMENTEES du Makefile, avec leur libelle — la source unique.
|
|
|
|
Une cible sans `##` est un detail d'implementation (`_instance-requise`,
|
|
`ansible-runtime`) : elle n'est pas offerte a l'exploitant, donc elle n'est pas
|
|
attendue dans un runbook.
|
|
"""
|
|
texte = (racine / "Makefile").read_text(encoding="utf-8")
|
|
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():
|
|
raise FileNotFoundError(f"Registre des runbooks introuvable : {chemin}")
|
|
data = yaml.safe_load(chemin.read_text(encoding="utf-8")) or {}
|
|
if not isinstance(data, dict):
|
|
raise ValueError(f"{chemin} ne contient pas une table YAML.")
|
|
return data
|
|
|
|
|
|
def assembler(racine: Path = RACINE, chemin: Path | None = None) -> list[dict]:
|
|
"""Les runbooks, joints au Makefile — la forme que la console sert.
|
|
|
|
Le libelle de chaque etape vient d'`aide`, lue dans le Makefile a CET instant. Le
|
|
registre n'en porte pas de copie, donc il ne peut pas en porter une perimee.
|
|
"""
|
|
data = charger(chemin or REGISTRE)
|
|
aides = cibles_du_makefile(racine)
|
|
catalogue = data.get("variables", {}) or {}
|
|
sortie = []
|
|
for rb in data.get("runbooks", []) or []:
|
|
etapes = []
|
|
for etape in rb.get("etapes", []) or []:
|
|
cible = etape.get("cible", "")
|
|
etapes.append({
|
|
"cible": cible,
|
|
# LE LIBELLE N'EST PAS DANS LE REGISTRE. Il est lu ici, chaque fois.
|
|
"libelle": aides.get(cible, ""),
|
|
# LA PORTEE SE PESE A L'ETAPE, PAS AU RUNBOOK (2026-09-20, mesure sur la
|
|
# console de TechnoLibre). Une seule etape qui materialise faisait basculer
|
|
# toute la sequence en `poste`, et un locataire ne pouvait plus deployer
|
|
# SA PROPRE flotte — exactement son metier. Le runbook donne le defaut ;
|
|
# l'etape qui exige davantage le declare.
|
|
"portee": etape.get("portee", rb.get("portee", "toute")),
|
|
"nature": etape.get("nature", "mesure"),
|
|
"pourquoi": etape.get("pourquoi", ""),
|
|
"duree": etape.get("duree", ""),
|
|
"variables": [
|
|
{"nom": v, **(catalogue.get(v, {}) or {})}
|
|
for v in (etape.get("variables", []) or [])
|
|
],
|
|
"fixes": etape.get("fixes", {}) or {},
|
|
"facultative": bool(etape.get("facultative", False)),
|
|
})
|
|
sortie.append({
|
|
"id": rb.get("id", ""),
|
|
"titre": rb.get("titre", ""),
|
|
"portee": rb.get("portee", "toute"),
|
|
"but": rb.get("but", ""),
|
|
"doc": rb.get("doc", ""),
|
|
"etapes": etapes,
|
|
})
|
|
return sortie
|
|
|
|
|
|
def etape_declaree(runbook_id: str, cible: str, racine: Path = RACINE,
|
|
index: int | None = None) -> dict | None:
|
|
"""L'etape declaree, ou None — LE point de controle du serveur.
|
|
|
|
La console ne lance JAMAIS ce que le navigateur lui nomme : elle lance ce que le
|
|
registre declare a cette place-la. Un navigateur qui reclamerait `raser` dans un
|
|
runbook de mesure n'obtient rien, et c'est le seul verrou qui compte.
|
|
|
|
L'INDEX COMPTE PARCE QU'UNE CIBLE PEUT REVENIR. « Le premier jour d'un site » lance
|
|
`site-deployer-tout` DEUX fois — le premier passage s'arrete sur la forge vide, le
|
|
second doit finir a zero echec. Chercher par nom seul confondrait les deux places.
|
|
Quand l'appelant donne un index, la cible doit s'y trouver : sinon la page et le
|
|
registre ne parlent pas de la meme chose, et on ne lance rien.
|
|
"""
|
|
for rb in assembler(racine):
|
|
if rb["id"] != runbook_id:
|
|
continue
|
|
etapes = rb["etapes"]
|
|
if index is not None:
|
|
if not (0 <= index < len(etapes)) or etapes[index]["cible"] != cible:
|
|
return None
|
|
return {**etapes[index], "runbook": rb["titre"],
|
|
"portee_runbook": rb["portee"]}
|
|
for etape in etapes:
|
|
if etape["cible"] == cible:
|
|
return {**etape, "runbook": rb["titre"],
|
|
"portee_runbook": rb["portee"]}
|
|
return None
|
|
|
|
|
|
def verifier(racine: Path = RACINE, chemin: Path | None = None) -> list[str]:
|
|
"""La garde. Rend la liste des ecarts — vide quand le registre dit vrai."""
|
|
data = charger(chemin or REGISTRE)
|
|
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]] = {}
|
|
|
|
vues: set[str] = set()
|
|
ids: set[str] = set()
|
|
for rb in data.get("runbooks", []) or []:
|
|
rid = rb.get("id", "")
|
|
if not rid:
|
|
ecarts.append("Un runbook n'a pas d'identifiant.")
|
|
continue
|
|
if rid in ids:
|
|
ecarts.append(f"{rid} : deux runbooks portent cet identifiant.")
|
|
ids.add(rid)
|
|
if rb.get("portee") not in PORTEES:
|
|
ecarts.append(f"{rid} : portee « {rb.get('portee')} » hors vocabulaire "
|
|
f"({', '.join(sorted(PORTEES))}).")
|
|
if not rb.get("but"):
|
|
ecarts.append(f"{rid} : aucun but declare — un runbook sans but ne se choisit pas.")
|
|
if not (rb.get("etapes") or []):
|
|
ecarts.append(f"{rid} : aucune etape.")
|
|
for etape in rb.get("etapes", []) or []:
|
|
cible = etape.get("cible", "")
|
|
if cible not in aides:
|
|
ecarts.append(f"{rid} → {cible} : le Makefile ne porte pas cette cible "
|
|
f"(registre en retard, ou cible renommee).")
|
|
vues.add(cible)
|
|
portees_vues.setdefault(cible, set()).add(rb.get("portee", ""))
|
|
if "portee" in etape and etape["portee"] not in PORTEES:
|
|
ecarts.append(f"{rid} → {cible} : portee d'etape « {etape['portee']} » hors "
|
|
f"vocabulaire ({', '.join(sorted(PORTEES))}).")
|
|
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.")
|
|
for v in etape.get("variables", []) or []:
|
|
if v not in catalogue:
|
|
ecarts.append(f"{rid} → {cible} : la variable {v} n'est pas au "
|
|
f"catalogue — la console ne saurait pas quoi demander.")
|
|
|
|
# LA COUVERTURE, DANS LES DEUX SENS.
|
|
for cible in sorted(set(aides) - vues - set(hors)):
|
|
ecarts.append(f"{cible} : cible documentee que nul runbook ne porte et que nul "
|
|
f"motif n'exempte. La console cacherait un pouvoir du moteur.")
|
|
for cible in sorted(vues & set(hors)):
|
|
ecarts.append(f"{cible} : portee par un runbook ET declaree hors assistant — "
|
|
f"deux verites, aucune.")
|
|
for cible in sorted(set(hors) - set(aides)):
|
|
ecarts.append(f"{cible} : exemptee alors que le Makefile ne la porte pas "
|
|
f"(exemption devenue sans objet).")
|
|
for cible, motif in sorted(hors.items()):
|
|
if not str(motif or "").strip():
|
|
ecarts.append(f"{cible} : exemptee sans motif. Une exemption muette est un oubli.")
|
|
return ecarts
|
|
|
|
|
|
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()
|
|
if ecarts:
|
|
print(f"Registre des runbooks : {len(ecarts)} ecart(s).\n")
|
|
for e in ecarts:
|
|
print(f" - {e}")
|
|
return 1
|
|
rbs = assembler()
|
|
etapes = sum(len(r["etapes"]) for r in rbs)
|
|
print(f"Registre des runbooks : {len(rbs)} runbooks, {etapes} etapes, "
|
|
f"{len(cibles_du_makefile())} cibles documentees, aucun ecart.")
|
|
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']}")
|
|
for i, e in enumerate(rb["etapes"], 1):
|
|
marque = {"mesure": " ", "ecriture": "*", "destructif": "!"}[e["nature"]]
|
|
var = (" " + " ".join(f"{v['nom']}=…" for v in e["variables"])) if e["variables"] else ""
|
|
sup = "" if e["portee"] == rb["portee"] else f" [{e['portee']}]"
|
|
print(f" {marque} {i:2}. make {e['cible']}{var}{sup}")
|
|
print(f" {e['libelle']}")
|
|
return 0
|
|
|
|
# couverture
|
|
aides = cibles_du_makefile()
|
|
data = charger()
|
|
vues = {e.get("cible") for rb in data.get("runbooks", []) or []
|
|
for e in rb.get("etapes", []) or []}
|
|
hors = set(data.get("hors_assistant", {}) or {})
|
|
print(f"cibles documentees : {len(aides)}")
|
|
print(f" portees par un runbook : {len(vues & set(aides))}")
|
|
print(f" hors assistant (motivees) : {len(hors & set(aides))}")
|
|
print(f" ni l'un ni l'autre : {len(set(aides) - vues - hors)}")
|
|
for c in sorted(set(aides) - vues - hors):
|
|
print(f" - {c} ({aides[c]})")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|