Files
oto-enterprise-os-dtp/05_deliverables_mvp/devops/deploy_runbook/deploylib/builder.py
T
Claude Code DTP Worker 58ed555db0 [DTP-Worker] Sprint 8 · Générateur Run-book de déploiement VPS unifié (7 phases · 20 modules gated · couverture bijective vs CI) (DevOps · roadmap L73)
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>
2026-07-30 11:41:43 +00:00

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}