#!/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())