[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,259 @@
"""Tests du run-book de déploiement VPS unifié (stdlib pur · zéro pip).
Couvre : le build réel (couverture bijective vs CI Gitea), la conformité au
schéma, les 11 familles d'invariants, le déterminisme, et une batterie
d'injections négatives (chaque invariant DOIT rougir quand on le viole).
"""
from __future__ import annotations
import copy
import json
import os
import sys
import unittest
_HERE = os.path.dirname(os.path.abspath(__file__))
_MOD = os.path.normpath(os.path.join(_HERE, ".."))
sys.path.insert(0, _MOD)
import deploy_runbook_gen as gen # noqa: E402
from deploylib import builder, deps # noqa: E402
def _spec() -> dict:
return gen._load(gen._SPEC_PATH)
def _ci() -> dict:
return deps.parse_ci()
def _synthetic_ci(paths, self_module="devops/deploy_runbook") -> dict:
"""CI factice : un job par module + le job du run-book lui-même."""
j2p = {f"{p.replace('/', '-')}-tests": p for p in paths}
j2p["devops-deploy-runbook-tests"] = self_module
return {"job_to_path": j2p, "gate_needs": list(j2p)}
class TestRealBuild(unittest.TestCase):
"""Build réel contre le CI et le spec du dépôt."""
def setUp(self):
self.spec = _spec()
self.ci = _ci()
self.bundle = builder.build_runbook(self.spec, self.ci)
def test_build_validates_clean(self):
errors = gen.validate_bundle(self.bundle, self.spec, self.ci)
self.assertEqual(errors, [], f"invariants rouges: {errors}")
def test_coverage_bijective(self):
cov = self.bundle["manifest"]["coverage"]
self.assertTrue(cov["bijective"])
self.assertEqual(cov["missing_in_map"], [])
self.assertEqual(cov["extra_in_map"], [])
self.assertEqual(cov["unknown_phase"], [])
def test_every_gated_module_assigned_once(self):
gated = set(builder.gated_modules(self.ci, self.spec["self_module"]))
assigned = [m["module"] for p in self.bundle["runbook"] for m in p["modules"]]
self.assertEqual(sorted(assigned), sorted(gated))
self.assertEqual(len(assigned), len(set(assigned)), "un module dans 2 phases")
def test_self_module_excluded(self):
self_mod = self.spec["self_module"]
self.assertNotIn(self_mod, self.spec["module_phase"])
allmods = [m["module"] for p in self.bundle["runbook"] for m in p["modules"]]
self.assertNotIn(self_mod, allmods)
def test_phases_sequential(self):
orders = [p["order"] for p in self.bundle["runbook"]]
self.assertEqual(orders, list(range(1, len(orders) + 1)))
def test_dependencies_point_backward(self):
order_of = {p["id"]: p["order"] for p in self.bundle["runbook"]}
for p in self.bundle["runbook"]:
for dep in p["depends_on"]:
self.assertIn(dep, order_of)
self.assertLess(order_of[dep], p["order"])
def test_every_module_has_ci_job(self):
for p in self.bundle["runbook"]:
for m in p["modules"]:
self.assertTrue(m["ci_job"], f"{m['module']} sans ci_job")
def test_confirmations_sourced(self):
for cid, entry in self.spec["confirmations"].items():
self.assertTrue(entry.get("owner"), f"{cid} sans owner")
self.assertTrue(entry.get("source"), f"{cid} sans source")
def test_no_orphan_confirmation(self):
self.assertEqual(self.bundle["manifest"]["graph"]["orphan_confirmations"], [])
self.assertEqual(self.bundle["manifest"]["graph"]["unknown_confirmations"], [])
def test_spec_has_no_business_number(self):
# Anti-invention (#6) : aucun champ de valeur chiffrée dans le catalogue.
forbidden = {"valeur", "value", "montant", "taux", "amount", "rate"}
for entry in self.spec["confirmations"].values():
self.assertEqual(forbidden & set(entry), set())
def test_counts_consistent(self):
c = self.bundle["manifest"]["counts"]
gated = builder.gated_modules(self.ci, self.spec["self_module"])
self.assertEqual(c["phases"], len(self.bundle["runbook"]))
self.assertEqual(c["modules"], len(gated))
self.assertEqual(c["confirmations"], len(self.spec["confirmations"]))
def test_deterministic(self):
again = builder.build_runbook(self.spec, self.ci)
self.assertEqual(
json.dumps(self.bundle, sort_keys=True),
json.dumps(again, sort_keys=True),
)
def test_modules_sorted_in_each_phase(self):
for p in self.bundle["runbook"]:
names = [m["module"] for m in p["modules"]]
self.assertEqual(names, sorted(names))
class TestSchema(unittest.TestCase):
def test_output_matches_schema(self):
bundle = builder.build_runbook(_spec(), _ci())
schema = gen._load(gen._SCHEMA_PATH)
self.assertEqual(list(deps.validate(bundle, schema)), [])
def test_written_artifacts_are_valid_json(self):
# Le hand-off commité doit être lisible (critère HANDOFF de l'audit 4Big).
out = os.path.join(_MOD, "out")
for name in ("deploy_runbook.json", "MANIFEST.json"):
path = os.path.join(out, name)
if os.path.isfile(path):
with open(path, encoding="utf-8") as fh:
self.assertNotIn(json.load(fh), (None, {}, [], ""))
class TestNegativeInjections(unittest.TestCase):
"""Chaque invariant doit rougir quand on viole sa condition."""
def _errors(self, spec, ci):
bundle = builder.build_runbook(spec, ci)
return gen.validate_bundle(bundle, spec, ci)
def _base(self):
"""Spec minimal cohérent + CI synthétique assorti (2 modules, 1 phase)."""
spec = {
"version": "1.0",
"reference_cadre": "test",
"cible_portage": "test",
"self_module": "devops/deploy_runbook",
"phases": [
{"order": 1, "id": "p1", "titre": "P1", "responsable": "vps",
"rationale": "r", "depends_on": [], "confirmations": ["c1"]},
],
"module_phase": {"a/one": "p1", "b/two": "p1"},
"confirmations": {
"c1": {"libelle": "L", "owner": "O", "source": "S"},
},
}
ci = _synthetic_ci(["a/one", "b/two"])
return spec, ci
def test_base_is_clean(self):
spec, ci = self._base()
self.assertEqual(self._errors(spec, ci), [])
def test_missing_module_flagged(self):
spec, ci = self._base()
del spec["module_phase"]["b/two"] # gated mais non planifié
errs = self._errors(spec, ci)
self.assertTrue(any("non planifiés" in e for e in errs), errs)
def test_extra_module_flagged(self):
spec, ci = self._base()
spec["module_phase"]["z/fake"] = "p1" # planifié mais non gated
errs = self._errors(spec, ci)
self.assertTrue(any("non gated" in e for e in errs), errs)
def test_self_in_map_flagged(self):
spec, ci = self._base()
spec["module_phase"]["devops/deploy_runbook"] = "p1"
errs = self._errors(spec, ci)
self.assertTrue(any("SoD" in e for e in errs), errs)
def test_unknown_phase_flagged(self):
spec, ci = self._base()
spec["module_phase"]["a/one"] = "ghost"
errs = self._errors(spec, ci)
self.assertTrue(any("phase" in e.lower() for e in errs), errs)
def test_forward_dependency_flagged(self):
spec, ci = self._base()
spec["phases"].append(
{"order": 2, "id": "p2", "titre": "P2", "responsable": "vps",
"rationale": "r", "depends_on": [], "confirmations": []}
)
# p1 dépend de p2 (postérieure) → renvoi en avant
spec["phases"][0]["depends_on"] = ["p2"]
spec["module_phase"]["b/two"] = "p2"
errs = self._errors(spec, ci)
self.assertTrue(any("postérieure" in e for e in errs), errs)
def test_unknown_dependency_flagged(self):
spec, ci = self._base()
spec["phases"][0]["depends_on"] = ["nope"]
errs = self._errors(spec, ci)
self.assertTrue(any("dépendance inconnue" in e for e in errs), errs)
def test_confirmation_without_source_flagged(self):
spec, ci = self._base()
spec["confirmations"]["c1"]["source"] = ""
errs = self._errors(spec, ci)
self.assertTrue(any("source" in e.lower() for e in errs), errs)
def test_confirmation_without_owner_flagged(self):
spec, ci = self._base()
spec["confirmations"]["c1"]["owner"] = ""
errs = self._errors(spec, ci)
self.assertTrue(any("owner" in e for e in errs), errs)
def test_orphan_confirmation_flagged(self):
spec, ci = self._base()
spec["confirmations"]["c2"] = {"libelle": "L", "owner": "O", "source": "S"}
errs = self._errors(spec, ci)
self.assertTrue(any("orphelin" in e for e in errs), errs)
def test_unknown_confirmation_ref_flagged(self):
spec, ci = self._base()
spec["phases"][0]["confirmations"] = ["c1", "cX"]
errs = self._errors(spec, ci)
self.assertTrue(any("cX" in e or "inconnue" in e for e in errs), errs)
def test_numeric_value_in_confirmation_flagged(self):
spec, ci = self._base()
spec["confirmations"]["c1"]["taux"] = 3.0 # chiffre fabriqué interdit
errs = self._errors(spec, ci)
self.assertTrue(any("chiffrée interdite" in e for e in errs), errs)
def test_duplicate_ci_job_flagged(self):
spec, ci = self._base()
ci["job_to_path"]["a-one-bis-tests"] = "a/one" # 2 jobs → 1 module
errs = self._errors(spec, ci)
self.assertTrue(any("job(s) CI" in e for e in errs), errs)
def test_empty_phase_when_module_removed(self):
# Retirer les modules d'une phase la rend vide → le schéma (minItems=1)
# ou la partition doit rougir.
spec, ci = self._base()
spec["phases"].append(
{"order": 2, "id": "p2", "titre": "P2", "responsable": "vps",
"rationale": "r", "depends_on": ["p1"], "confirmations": []}
)
# p2 n'a aucun module assigné → phase vide
errs = self._errors(spec, ci)
self.assertTrue(errs, "une phase vide doit être signalée")
if __name__ == "__main__":
unittest.main(verbosity=2)