[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>
This commit is contained in:
Claude Code DTP Worker
2026-07-30 11:41:43 +00:00
parent 4b0752906d
commit 58ed555db0
19 changed files with 1647 additions and 17 deletions
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""Run-book de déploiement VPS unifié (Sprint 8 · DevOps).
Volet « Deployment production complet » réalisable en-repo. Une couche AU-DESSUS
du run-book RBAC (`rbac/apply_plan`) : celui-ci n'ordonne que les 3 volets RBAC ;
ce module-ci ordonne le déploiement VPS de TOUS les livrables gated du mandat en
un seul plan de phases.
Périmètre PROUVÉ, pas déclaré : l'ensemble des modules à déployer est DÉRIVÉ de
`.gitea/workflows/ci.yml` (réutilise `q4lib.registry.parse_ci` — zéro duplication
· #5), puis mis en correspondance BIJECTIVE avec le `module_phase` du spec. Tout
livrable ajouté au CI sans entrée de phase casse la couverture → génération
refusée (aucune omission silencieuse).
Cible de portage VPS : agent DevOps / ERPNext Backend. Ce worker n'écrit JAMAIS
sur le VPS (CLAUDE.md #8) — il produit en-repo un plan ordonné que l'agent suit.
Produit :
- deploy_runbook.json → plan ordonné de phases (modules gated assignés,
dépendances inter-phases, confirmations préalables
sourcées).
- MANIFEST.json → manifeste agrégé (comptes, preuve de couverture
bijective vs CI, graphe des phases, confirmations
ouvertes).
Sous-commandes :
build [-o OUT] → écrit deploy_runbook.json / MANIFEST.json (après validation)
validate [-o OUT] → (re)génère en mémoire, valide vs deploy.schema.json +
invariants — sort en erreur si un invariant casse.
Sortie déterministe (ordre de phases fixe, listes triées, aucun horodatage).
Anti-invention (#6) : aucun chiffre métier ; les paramètres non confirmés
restent des confirmations sourcées (owner + source), jamais fabriquées.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _HERE)
from deploylib import builder, deps # noqa: E402
_SPEC_PATH = os.path.join(_HERE, "deploy_spec.json")
_SCHEMA_PATH = os.path.join(_HERE, "deploy.schema.json")
_DEFAULT_OUT = os.path.join(_HERE, "out")
def _eprint(*args) -> None:
print(*args, file=sys.stderr)
def _load(path: str) -> dict:
with open(path, encoding="utf-8") as fh:
return json.load(fh)
def _write_json(path: str, data) -> None:
with open(path, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=2)
fh.write("\n")
def _build_bundle() -> dict:
"""Charge le spec, lit la réalité du CI, assemble le run-book."""
spec = _load(_SPEC_PATH)
ci = deps.parse_ci()
return builder.build_runbook(spec, ci)
def validate_bundle(bundle: dict, spec: dict, ci: dict) -> list[str]:
"""Valide le run-book contre deploy.schema.json + invariants de déploiement.
Fonction pure sur (bundle, spec, ci) → les tests injectent des dictionnaires
synthétiques pour prouver le basculement en erreur (tests négatifs).
"""
schema = _load(_SCHEMA_PATH)
errors = list(deps.validate(bundle, schema))
runbook = bundle["runbook"]
manifest = bundle["manifest"]
self_module = spec["self_module"]
# INV1 · phases séquentielles 1..N, ids uniques, alignées sur le spec.
orders = [p["order"] for p in runbook]
if orders != list(range(1, len(runbook) + 1)):
errors.append(f"phases: champ `order` non séquentiel {orders}.")
ids = [p["id"] for p in runbook]
if len(set(ids)) != len(ids):
errors.append(f"phases: ids en double {ids}.")
if ids != [p["id"] for p in spec["phases"]]:
errors.append("phases: ordre/ids divergents du spec.")
# INV2 · graphe inter-phases : toute dépendance existe ET pointe vers une
# phase STRICTEMENT antérieure (déploiement applicable dans l'ordre, sans
# cycle ni renvoi en avant).
order_of = {p["id"]: p["order"] for p in runbook}
for p in runbook:
for dep in p["depends_on"]:
if dep not in order_of:
errors.append(f"phase {p['id']}: dépendance inconnue {dep!r}.")
elif order_of[dep] >= p["order"]:
errors.append(f"phase {p['id']}: dépend d'une phase postérieure {dep!r}.")
# INV3 · COUVERTURE BIJECTIVE (cœur anti-invention · #6) : les modules
# planifiés == les modules gated par le CI (hors self). Aucun oubli, aucune
# invention. On RECALCULE indépendamment depuis le CI, on ne fait pas
# confiance au manifeste.
gated = set(builder.gated_modules(ci, self_module))
mapped = set(spec["module_phase"])
missing = sorted(gated - mapped)
extra = sorted(mapped - gated)
if missing:
errors.append(f"couverture: modules gated non planifiés {missing}.")
if extra:
errors.append(f"couverture: modules planifiés non gated {extra}.")
cov = manifest["coverage"]
if cov["missing_in_map"] != missing or cov["extra_in_map"] != extra:
errors.append("coverage: diagnostic du manifeste ≠ recalcul indépendant.")
if cov["bijective"] != (not missing and not extra):
errors.append("coverage.bijective incohérent avec les écarts calculés.")
# INV4 · séparation des pouvoirs (ISA 315) : le run-book ne se déploie pas
# lui-même et ne se compte pas.
if self_module in mapped:
errors.append(f"SoD: le module {self_module!r} ne doit pas figurer dans module_phase.")
for p in runbook:
if any(m["module"] == self_module for m in p["modules"]):
errors.append(f"SoD: {self_module!r} présent dans la phase {p['id']}.")
# INV5 · chaque module gated est assigné à EXACTEMENT une phase, et chaque
# phase du run-book est non vide (schéma garantit minItems=1, on revérifie
# la partition).
assigned: dict[str, int] = {}
for p in runbook:
for m in p["modules"]:
assigned[m["module"]] = assigned.get(m["module"], 0) + 1
dups = sorted(m for m, n in assigned.items() if n > 1)
if dups:
errors.append(f"partition: modules assignés à plusieurs phases {dups}.")
if set(assigned) != gated:
errors.append("partition: l'union des phases ≠ modules gated.")
# INV6 · toute phase déclare une phase connue pour chaque module (pas de
# `module_phase` pointant vers une phase absente du spec).
if manifest["coverage"]["unknown_phase"]:
errors.append(
f"module_phase: phases inconnues {manifest['coverage']['unknown_phase']}."
)
# INV7 · CONFIRMATIONS sourcées + intègres (anti-invention · #6) :
# - chaque confirmation référencée par une phase existe au catalogue ;
# - aucune entrée de catalogue orpheline (non référencée) ;
# - chaque confirmation porte owner + source NON vides ;
# - AUCUN champ de valeur chiffrée (`valeur`/`value`/`montant`/`taux`) :
# un paramètre non confirmé reste une confirmation, jamais un chiffre.
catalogue = spec["confirmations"]
if manifest["graph"]["unknown_confirmations"]:
errors.append(
f"confirmations inconnues référencées {manifest['graph']['unknown_confirmations']}."
)
if manifest["graph"]["orphan_confirmations"]:
errors.append(
f"confirmations orphelines (non référencées) {manifest['graph']['orphan_confirmations']}."
)
_VALUE_KEYS = {"valeur", "value", "montant", "taux", "amount", "rate"}
for cid, entry in catalogue.items():
if not entry.get("owner"):
errors.append(f"confirmation {cid!r}: owner manquant.")
if not entry.get("source"):
errors.append(f"confirmation {cid!r}: source manquante (anti-invention #6).")
bad = _VALUE_KEYS & set(entry)
if bad:
errors.append(f"confirmation {cid!r}: valeur chiffrée interdite {sorted(bad)} (#6).")
for p in runbook:
for c in p["confirmations"]:
if c["id"] not in catalogue:
errors.append(f"phase {p['id']}: confirmation inconnue {c['id']!r}.")
elif not c["source"] or not c["owner"]:
errors.append(f"phase {p['id']}: confirmation {c['id']!r} non sourcée.")
# INV8 · unicité job CI par module (le parseur ne doit pas mapper 2 jobs sur
# le même module — sinon `ci_job` serait ambigu).
j2p = ci.get("job_to_path", {})
path_jobs: dict[str, list[str]] = {}
for job, path in j2p.items():
path_jobs.setdefault(path, []).append(job)
for path in gated:
jobs = path_jobs.get(path, [])
if len(jobs) != 1:
errors.append(f"module {path!r}: {len(jobs)} job(s) CI (attendu 1) {sorted(jobs)}.")
# INV9 · chaque module du run-book porte un `ci_job` résolu (non null).
for p in runbook:
for m in p["modules"]:
if not m["ci_job"]:
errors.append(f"phase {p['id']}: module {m['module']!r} sans ci_job.")
# INV10 · déterminisme : modules triés par phase, confirmations_ouvertes triée.
for p in runbook:
names = [m["module"] for m in p["modules"]]
if names != sorted(names):
errors.append(f"phase {p['id']}: modules non triés (déterminisme).")
co = manifest["confirmations_ouvertes"]
if co != sorted(co):
errors.append("confirmations_ouvertes non triée (déterminisme).")
# INV11 · cohérence des comptes du manifeste avec le contenu.
c = manifest["counts"]
if c["phases"] != len(runbook):
errors.append("counts.phases ≠ nombre de phases.")
if c["modules"] != len(gated):
errors.append("counts.modules ≠ modules gated.")
if c["confirmations"] != len(catalogue):
errors.append("counts.confirmations ≠ taille du catalogue.")
return errors
def _validate_current() -> tuple[dict, list[str]]:
spec = _load(_SPEC_PATH)
ci = deps.parse_ci()
bundle = builder.build_runbook(spec, ci)
return bundle, validate_bundle(bundle, spec, ci)
def cmd_build(args: argparse.Namespace) -> int:
bundle, errors = _validate_current()
if errors:
_eprint("❌ Run-book invalide — génération refusée (anti-régression) :")
for e in errors:
_eprint(f" - {e}")
return 1
out = os.path.abspath(args.out)
os.makedirs(out, exist_ok=True)
_write_json(os.path.join(out, "deploy_runbook.json"), bundle["runbook"])
_write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"])
m = bundle["manifest"]
c = m["counts"]
print(f"✅ Run-book de déploiement généré dans {out}")
print(
f" deploy_runbook.json : {c['phases']} phases · {c['modules']} modules gated "
f"(couverture bijective vs CI) · MANIFEST.json : {c['confirmations']} confirmations."
)
for p in bundle["runbook"]:
mods = ", ".join(x["module"] for x in p["modules"])
conf = f" · ⚠ {len(p['confirmations'])} conf." if p["confirmations"] else ""
print(f" {p['order']}. [{p['responsable']}] {p['titre']}{conf}")
print(f" modules: {mods}")
if m["confirmations_ouvertes"]:
print(f" Confirmations préalables VPS : {', '.join(m['confirmations_ouvertes'])}")
return 0
def cmd_validate(args: argparse.Namespace) -> int:
bundle, errors = _validate_current()
if errors:
_eprint("❌ Validation KO :")
for e in errors:
_eprint(f" - {e}")
return 1
m = bundle["manifest"]
print(
f"✅ Validation OK — {m['counts']['phases']} phases, couverture bijective "
f"{m['coverage']['mapped_modules_count']}/{m['coverage']['ci_modules_count']} "
f"modules gated, schéma + 11 familles d'invariants verts."
)
return 0
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(
description="Run-book de déploiement VPS unifié (tous livrables gated → 1 plan de phases)."
)
sub = p.add_subparsers(dest="cmd", required=True)
pb = sub.add_parser("build", help="génère deploy_runbook.json / MANIFEST.json")
pb.add_argument("-o", "--out", default=_DEFAULT_OUT, help="dossier de sortie (défaut: ./out)")
pb.set_defaults(func=cmd_build)
pv = sub.add_parser("validate", help="valide le plan (schéma + invariants) sans écrire")
pv.add_argument("-o", "--out", default=_DEFAULT_OUT, help="ignoré (compat)")
pv.set_defaults(func=cmd_validate)
args = p.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())