58ed555db0
Agrégateur de méta-niveau au-dessus du run-book RBAC : ordonne le déploiement VPS de TOUS les livrables gated en 7 phases (Prérequis → DocTypes → RBAC → Workflow/métier → Frontend → Contenu → Vérification QA), avec graphe de dépendances inter-phases acyclique et confirmations préalables sourcées. Anti-invention (#6) : périmètre dérivé du CI (parse_ci réutilisé), couverture bijective module→phase (un module gated non planifié OU un module planifié non gated → refus), SoD (auto-exclusion), zéro chiffre métier (confirmations sourcées via audit_5d D1.1/D1.2/D1.3/D2.3 + endpoint OTOIA). Vérifs : 29/29 tests module (dont 14 injections négatives) · audit_4big PASS 20/20 à 100 · régression run 20/20 suites · 503 tests · 0 échec · gate CI local vert. CI job devops-deploy-runbook-tests + gate ; enregistrement audit_4big (19→20) ; plan régression régénéré (19→20 suites). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
137 lines
5.3 KiB
Python
137 lines
5.3 KiB
Python
"""Assemblage du run-book de déploiement VPS à partir du spec + de la réalité CI.
|
|
|
|
Le run-book ordonne le déploiement de TOUS les livrables gated du mandat en un
|
|
plan de phases. La liste des modules à déployer n'est PAS écrite à la main : elle
|
|
est dérivée de `.gitea/workflows/ci.yml` (via q4lib.registry.parse_ci, réutilisé)
|
|
puis mise en correspondance BIJECTIVE avec le `module_phase` du spec.
|
|
|
|
Fonctions PURES (aucune I/O, aucun horodatage) → sortie déterministe, diffable,
|
|
re-générable bit-à-bit. Le CLI leur injecte le spec chargé et le résultat de
|
|
`parse_ci()` ; les tests injectent des dictionnaires synthétiques (facile à
|
|
mettre en défaut → tests négatifs).
|
|
|
|
Anti-invention (#6) : aucun chiffre métier ; les paramètres non confirmés restent
|
|
des CONFIRMATIONS sourcées (owner + source), jamais une valeur fabriquée.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
|
|
def gated_modules(ci: dict, self_module: str) -> list[str]:
|
|
"""Chemins de modules gated par le CI, hors le run-book lui-même (SoD)."""
|
|
paths = set(ci.get("job_to_path", {}).values())
|
|
paths.discard(self_module)
|
|
return sorted(paths)
|
|
|
|
|
|
def _path_to_job(ci: dict) -> dict[str, str]:
|
|
"""Inverse job→path en path→job. Suppose l'unicité (vérifiée en invariant)."""
|
|
out: dict[str, str] = {}
|
|
for job, path in ci.get("job_to_path", {}).items():
|
|
# En cas de collision on garde le job le plus petit (déterministe) ; la
|
|
# collision elle-même est signalée par l'invariant INV9 du CLI.
|
|
if path not in out or job < out[path]:
|
|
out[path] = job
|
|
return out
|
|
|
|
|
|
def build_runbook(spec: dict, ci: dict) -> dict[str, Any]:
|
|
"""Consolide spec + CI en run-book de phases + manifeste agrégé.
|
|
|
|
Ne lève pas sur une incohérence de couverture (module manquant / en trop) :
|
|
elle est REMONTÉE dans `manifest.coverage` pour que le CLI/les tests la
|
|
signalent explicitement (un déploiement ne doit pas oublier silencieusement
|
|
un livrable).
|
|
"""
|
|
self_module = spec["self_module"]
|
|
module_phase = spec["module_phase"]
|
|
phases_meta = spec["phases"]
|
|
catalogue = spec["confirmations"]
|
|
|
|
gated = gated_modules(ci, self_module)
|
|
gated_set = set(gated)
|
|
mapped_set = set(module_phase)
|
|
p2j = _path_to_job(ci)
|
|
|
|
# Couverture bijective : les modules mappés == les modules gated (hors self).
|
|
missing_in_map = sorted(gated_set - mapped_set) # gated mais non planifié
|
|
extra_in_map = sorted(mapped_set - gated_set) # planifié mais non gated
|
|
bijective = not missing_in_map and not extra_in_map
|
|
|
|
phase_ids = [p["id"] for p in phases_meta]
|
|
phase_id_set = set(phase_ids)
|
|
|
|
# Modules dont la phase déclarée n'existe pas dans `phases` (spec incohérent).
|
|
unknown_phase = sorted(
|
|
m for m, ph in module_phase.items() if ph not in phase_id_set
|
|
)
|
|
|
|
# Assemblage du run-book : une phase = métadonnées + modules gated assignés +
|
|
# confirmations résolues depuis le catalogue.
|
|
runbook: list[dict[str, Any]] = []
|
|
for p in phases_meta:
|
|
mods = sorted(
|
|
m for m, ph in module_phase.items()
|
|
if ph == p["id"] and m in gated_set
|
|
)
|
|
modules = [{"module": m, "ci_job": p2j.get(m)} for m in mods]
|
|
confs = [
|
|
{
|
|
"id": cid,
|
|
"libelle": catalogue.get(cid, {}).get("libelle"),
|
|
"owner": catalogue.get(cid, {}).get("owner"),
|
|
"source": catalogue.get(cid, {}).get("source"),
|
|
}
|
|
for cid in p.get("confirmations", [])
|
|
]
|
|
runbook.append({
|
|
"order": p["order"],
|
|
"id": p["id"],
|
|
"titre": p["titre"],
|
|
"responsable": p["responsable"],
|
|
"rationale": p["rationale"],
|
|
"depends_on": list(p.get("depends_on", [])),
|
|
"modules": modules,
|
|
"confirmations": confs,
|
|
})
|
|
|
|
# Confirmations effectivement référencées par ≥1 phase (détection d'orphelin).
|
|
referenced: set[str] = set()
|
|
for p in phases_meta:
|
|
referenced.update(p.get("confirmations", []))
|
|
orphan_confirmations = sorted(set(catalogue) - referenced)
|
|
unknown_confirmations = sorted(referenced - set(catalogue))
|
|
|
|
manifest = {
|
|
"generated_from": f"deploy_spec.json v{spec.get('version')}",
|
|
"reference_cadre": spec.get("reference_cadre"),
|
|
"cible_portage": spec.get("cible_portage"),
|
|
"self_module": self_module,
|
|
"counts": {
|
|
"phases": len(phases_meta),
|
|
"modules": len(gated),
|
|
"modules_mapped": len(mapped_set),
|
|
"confirmations": len(catalogue),
|
|
"confirmations_referenced": len(referenced),
|
|
},
|
|
"coverage": {
|
|
"ci_modules_count": len(gated),
|
|
"mapped_modules_count": len(mapped_set),
|
|
"bijective": bijective,
|
|
"missing_in_map": missing_in_map,
|
|
"extra_in_map": extra_in_map,
|
|
"unknown_phase": unknown_phase,
|
|
"self_module_excluded": self_module,
|
|
},
|
|
"graph": {
|
|
"phase_order": phase_ids,
|
|
"orphan_confirmations": orphan_confirmations,
|
|
"unknown_confirmations": unknown_confirmations,
|
|
},
|
|
"confirmations_ouvertes": sorted(catalogue),
|
|
}
|
|
|
|
return {"runbook": runbook, "manifest": manifest}
|