261 lines
12 KiB
Python
261 lines
12 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 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 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, ""),
|
||
|
|
"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], "portee": rb["portee"], "runbook": rb["titre"]}
|
||
|
|
for etape in etapes:
|
||
|
|
if etape["cible"] == cible:
|
||
|
|
return {**etape, "portee": rb["portee"], "runbook": rb["titre"]}
|
||
|
|
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 {}
|
||
|
|
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 etape.get("nature") not in NATURES:
|
||
|
|
ecarts.append(f"{rid} → {cible} : nature « {etape.get('nature')} » hors "
|
||
|
|
f"vocabulaire ({', '.join(sorted(NATURES))}).")
|
||
|
|
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")
|
||
|
|
args = parser.parse_args()
|
||
|
|
|
||
|
|
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":
|
||
|
|
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 ""
|
||
|
|
print(f" {marque} {i:2}. make {e['cible']}{var}")
|
||
|
|
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())
|