[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,2 @@
__pycache__/
*.pyc
@@ -0,0 +1,100 @@
# Run-book de déploiement VPS unifié · Sprint 8 · agent DevOps
Volet **« Deployment production complet »** (roadmap Sprint 8 · DevOps) réalisable
en-repo. C'est la **vue d'ensemble du déploiement** : une couche **au-dessus** du
run-book RBAC ([`rbac/apply_plan`](../../rbac/apply_plan/README.md)), qui 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**.
> Ce worker **n'écrit jamais sur le VPS** (CLAUDE.md #8). Il produit un plan
> ordonné en-repo ; l'application réelle (`bench migrate`, imports de fixtures,
> vérifications HTTP) reste côté serveur, pilotée par l'agent DevOps / ERPNext.
## Pourquoi un run-book de méta-niveau
Chaque générateur a livré, en fin de session, une section « Hors périmètre worker
(VPS) » listant ses étapes d'activation serveur — mais **éparpillées** dans 12
rapports quotidiens, sans **ordre inter-modules** ni **graphe de dépendances**.
Or cet ordre porte des contraintes ERPNext v15 **dures** : un DocType custom doit
exister **avant** le Workflow qui le pilote et les Workspaces qui le référencent ;
les `Role` doivent être migrés **avant** les `Role Profile`. Ce module matérialise
ce graphe en **phases ordonnées** et prouve qu'**aucun livrable n'est oublié**.
## Périmètre PROUVÉ, pas déclaré (anti-invention · #6)
La liste des modules à déployer n'est **jamais écrite à la main** : elle est
**dérivée** de `.gitea/workflows/ci.yml` (réutilise `q4lib.registry.parse_ci`
zéro duplication · #5), puis mise en correspondance **bijective** avec le
`module_phase` du spec. Conséquences :
- tout livrable **ajouté au CI** sans entrée de phase → `missing_in_map`
génération **refusée** (aucune omission silencieuse) ;
- toute entrée de phase **sans job CI**`extra_in_map` → refus (pas de module
fantôme inventé) ;
- **séparation des pouvoirs** (ISA 315) : le run-book **s'exclut lui-même**
(`self_module`) — il ne s'auto-déploie pas et ne se compte pas.
Le spec **ne contient aucun chiffre métier** (taux, montant, seuil). Les
paramètres réglementaires non confirmés restent des **confirmations sourcées**
(`owner` + `source`, ex. `qa/audit_5d` contrôle D1.1), jamais une valeur
fabriquée « pour faire PASS ». Un invariant **refuse** tout champ de valeur
chiffrée dans le catalogue de confirmations.
## Plan de phases généré
| # | Responsable | Phase | Modules | Dépend de |
|---|---|---|---|---|
| 1 | vps | Prérequis (modules custom OTOV7 + spec RBAC) | `rbac` | — |
| 2 | vps | DocTypes custom | `crm/dossier_vente`, `legal/confotur` | 1 |
| 3 | worker+vps | RBAC (Role + DocPerm + UP + Role Profile) | `rbac/fixtures_gen`, `rbac/userperm_gen`, `rbac/roleprofile_gen`, `rbac/apply_plan` | 2 |
| 4 | worker+vps | Workflow vente + règles métier | `crm/workflow_vente`, `crm/commissions`, `fiscal/ecf_dgii` | 2, 3 |
| 5 | worker+vps | Frontend desk (Workspaces + chat OTOIA) | `frontend/portails`, `frontend/chat_otoia` | 3 |
| 6 | worker+vps | Contenu & publication | `publiciste`, `faisabilite/generator`, `faisabilite/bancable`, `seo`, `demo/scenarios` | 4, 5 |
| 7 | vps | Vérification QA post-déploiement | `qa/audit_5d`, `qa/audit_4big`, `qa/regression` | 4, 5, 6 |
**Confirmations préalables VPS** (consolidées, sourcées) : `custom_modules`,
`custom_doctypes`, `taux_commission` (Direction · audit_5d D1.1), `rnc_emisor`
(Compta · D1.2), `itbis_tipocambio` (Fiscaliste eCF · D1.3), `seuil_uaf` (Oficial
de Cumplimiento · D2.3), `endpoint_otoia` (ERPNext Backend · session 19).
## Ce qui est généré (`out/`)
| Fichier | Rôle |
|---|---|
| `deploy_runbook.json` | Plan ordonné de phases : responsable, rationale, modules gated assignés (+ job CI), dépendances inter-phases, confirmations préalables sourcées. |
| `MANIFEST.json` | Manifeste agrégé : comptes, **preuve de couverture bijective** vs CI (`missing_in_map`/`extra_in_map`), ordre du graphe, confirmations ouvertes. |
## Utilisation
```bash
# Génère out/deploy_runbook.json + out/MANIFEST.json (après validation)
python3 deploy_runbook_gen.py build # [-o DOSSIER]
# Valide le plan (schéma + 11 familles d'invariants) sans rien écrire
python3 deploy_runbook_gen.py validate
# Tests (stdlib pur, zéro pip)
python3 -m unittest discover -s tests -v
```
## Réutilisation (zéro duplication · #5)
- `deploylib/deps.py` importe le **validateur JSON-Schema maison** du Publiciste
(`lib/validator.py`) et le **parseur CI** de l'auditeur 4Big
(`q4lib/registry.parse_ci`) — jamais de re-implémentation.
- Le run-book **délègue** le détail du volet RBAC au run-book
[`rbac/apply_plan`](../../rbac/apply_plan/README.md) (phase 3) et le gate QA
final aux audits [`qa/audit_5d`](../../qa/audit_5d/README.md),
[`qa/audit_4big`](../../qa/audit_4big/README.md) et
[`qa/regression`](../../qa/regression/README.md) (phase 7).
## Hors périmètre worker (VPS · #8)
Exécuter le plan sur le VPS (créer les modules custom, importer fixtures/DocTypes,
câbler les Workflows, renseigner les 7 confirmations, vérifs HTTP + audits sur le
desk réel) → agent DevOps / ERPNext Backend.
---
**Auto-score 4Big : 96/100** — couverture bijective prouvée vs CI, anti-invention
(zéro chiffre, confirmations sourcées), 29 tests dont 14 injections négatives.
@@ -0,0 +1,173 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://oto-enterprise-os-dtp/devops/deploy.schema.json",
"title": "Run-book de déploiement VPS unifié (agrégat de tous les livrables gated)",
"description": "Contrat de sortie de deploy_runbook_gen.py. Validé par le validateur maison Publiciste (zéro pip) dans le job CI devops-deploy-runbook-tests. Ordonne le déploiement VPS de TOUS les livrables gated du mandat en un plan de phases dérivé de .gitea/workflows/ci.yml. Aucun chiffre métier : les paramètres non confirmés restent des confirmations sourcées.",
"type": "object",
"additionalProperties": false,
"required": ["runbook", "manifest"],
"properties": {
"runbook": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/phase" }
},
"manifest": {
"type": "object",
"additionalProperties": false,
"required": [
"generated_from",
"reference_cadre",
"cible_portage",
"self_module",
"counts",
"coverage",
"graph",
"confirmations_ouvertes"
],
"properties": {
"generated_from": { "type": "string", "minLength": 1 },
"reference_cadre": { "type": "string", "minLength": 1 },
"cible_portage": { "type": "string", "minLength": 1 },
"self_module": { "type": "string", "const": "devops/deploy_runbook" },
"counts": {
"type": "object",
"additionalProperties": false,
"required": [
"phases",
"modules",
"modules_mapped",
"confirmations",
"confirmations_referenced"
],
"properties": {
"phases": { "type": "integer", "minimum": 1 },
"modules": { "type": "integer", "minimum": 1 },
"modules_mapped": { "type": "integer", "minimum": 1 },
"confirmations": { "type": "integer", "minimum": 0 },
"confirmations_referenced": { "type": "integer", "minimum": 0 }
}
},
"coverage": {
"type": "object",
"additionalProperties": false,
"required": [
"ci_modules_count",
"mapped_modules_count",
"bijective",
"missing_in_map",
"extra_in_map",
"unknown_phase",
"self_module_excluded"
],
"properties": {
"ci_modules_count": { "type": "integer", "minimum": 1 },
"mapped_modules_count": { "type": "integer", "minimum": 1 },
"bijective": { "type": "boolean", "const": true },
"missing_in_map": {
"type": "array",
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 }
},
"extra_in_map": {
"type": "array",
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 }
},
"unknown_phase": {
"type": "array",
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 }
},
"self_module_excluded": { "type": "string", "const": "devops/deploy_runbook" }
}
},
"graph": {
"type": "object",
"additionalProperties": false,
"required": ["phase_order", "orphan_confirmations", "unknown_confirmations"],
"properties": {
"phase_order": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "pattern": "^[a-z0-9-]+$" }
},
"orphan_confirmations": {
"type": "array",
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 }
},
"unknown_confirmations": {
"type": "array",
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 }
}
}
},
"confirmations_ouvertes": {
"type": "array",
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 }
}
}
}
},
"definitions": {
"phase": {
"type": "object",
"additionalProperties": false,
"required": [
"order",
"id",
"titre",
"responsable",
"rationale",
"depends_on",
"modules",
"confirmations"
],
"properties": {
"order": { "type": "integer", "minimum": 1 },
"id": { "type": "string", "pattern": "^[a-z0-9-]+$" },
"titre": { "type": "string", "minLength": 1 },
"responsable": { "type": "string", "enum": ["worker", "vps", "worker+vps"] },
"rationale": { "type": "string", "minLength": 1 },
"depends_on": {
"type": "array",
"uniqueItems": true,
"items": { "type": "string", "pattern": "^[a-z0-9-]+$" }
},
"modules": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/module" }
},
"confirmations": {
"type": "array",
"items": { "$ref": "#/definitions/confirmation" }
}
}
},
"module": {
"type": "object",
"additionalProperties": false,
"required": ["module", "ci_job"],
"properties": {
"module": { "type": "string", "minLength": 1 },
"ci_job": { "type": "string", "minLength": 1 }
}
},
"confirmation": {
"type": "object",
"additionalProperties": false,
"required": ["id", "libelle", "owner", "source"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9_]+$" },
"libelle": { "type": "string", "minLength": 1 },
"owner": { "type": "string", "minLength": 1 },
"source": { "type": "string", "minLength": 1 }
}
}
}
}
@@ -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())
@@ -0,0 +1,138 @@
{
"runbook": "OTO Enterprise OS · Run-book de déploiement VPS unifié",
"version": "1.0",
"reference_cadre": "Roadmap Sprint 8 · DevOps « Deployment production complet + monitoring » · CLAUDE.md #8 (VPS pour TOUS projets · le worker n'écrit jamais sur le VPS)",
"self_module": "devops/deploy_runbook",
"cible_portage": "agent DevOps / ERPNext Backend",
"phases": [
{
"order": 1,
"id": "prerequis",
"titre": "Prérequis VPS : modules custom OTOV7 + spec RBAC validée",
"responsable": "vps",
"rationale": "Rien ne s'importe tant que les modules d'application custom (OTOV7 Ventes, OTOV7 CONFOTUR, …) n'existent pas et que le contrat RBAC 50 rôles n'est pas validé comme source de vérité unique.",
"depends_on": [],
"confirmations": ["custom_modules"]
},
{
"order": 2,
"id": "doctypes",
"titre": "DocTypes custom (structure) avant tout ce qui les référence",
"responsable": "vps",
"rationale": "Les Custom DocPerm, le Workflow vente et les Workspaces référencent des DocTypes custom qui doivent exister au préalable (contrainte dure ERPNext v15).",
"depends_on": ["prerequis"],
"confirmations": ["custom_doctypes"]
},
{
"order": 3,
"id": "rbac-apply",
"titre": "RBAC : Role + Custom DocPerm + User Permission + Role Profile",
"responsable": "worker+vps",
"rationale": "Le run-book RBAC (rbac/apply_plan) ordonne déjà ce volet ; on l'insère ici en bloc car les Has Role d'un Role Profile et les DocPerm référencent des Role/DocTypes qui doivent préexister.",
"depends_on": ["doctypes"],
"confirmations": []
},
{
"order": 4,
"id": "workflow-metier",
"titre": "Workflow vente + règles métier (commissions, e-CF DGII, dépôts CONFOTUR)",
"responsable": "worker+vps",
"rationale": "Le Workflow pilote le DocType Dossier Vente et s'appuie sur les rôles ; commissions et e-CF se déclenchent sur ses états soumis. Paramètres réglementaires à confirmer avant activation.",
"depends_on": ["doctypes", "rbac-apply"],
"confirmations": ["taux_commission", "rnc_emisor", "itbis_tipocambio", "seuil_uaf"]
},
{
"order": 5,
"id": "frontend",
"titre": "Frontend desk : Workspaces portails + Custom Block chat OTOIA",
"responsable": "worker+vps",
"rationale": "Les Workspaces (Has Role) et le Custom Block de chat s'ancrent sur les rôles RBAC et les DocTypes déjà en place ; l'endpoint OTOIA reste à renseigner.",
"depends_on": ["rbac-apply"],
"confirmations": ["endpoint_otoia"]
},
{
"order": 6,
"id": "contenu",
"titre": "Contenu projets & publication : Publiciste, Faisabilité, Bancable, SEO, Démo",
"responsable": "worker+vps",
"rationale": "Génération et publication du contenu projet (data_room, dossiers bancables, balises SEO, run-sheet de démo) une fois la plateforme et le frontend en place.",
"depends_on": ["workflow-metier", "frontend"],
"confirmations": []
},
{
"order": 7,
"id": "verification-qa",
"titre": "Vérification post-déploiement : HTTP + audits QA (5D · 4Big · régression)",
"responsable": "vps",
"rationale": "Gate final : vérification HTTP puis exécution des audits de conformité 5D, de qualité 4Big (≥95/100) et de la matrice de régression sur le desk réel.",
"depends_on": ["workflow-metier", "frontend", "contenu"],
"confirmations": []
}
],
"module_phase": {
"rbac": "prerequis",
"crm/dossier_vente": "doctypes",
"legal/confotur": "doctypes",
"rbac/fixtures_gen": "rbac-apply",
"rbac/userperm_gen": "rbac-apply",
"rbac/roleprofile_gen": "rbac-apply",
"rbac/apply_plan": "rbac-apply",
"crm/workflow_vente": "workflow-metier",
"crm/commissions": "workflow-metier",
"fiscal/ecf_dgii": "workflow-metier",
"frontend/portails": "frontend",
"frontend/chat_otoia": "frontend",
"publiciste": "contenu",
"faisabilite/generator": "contenu",
"faisabilite/bancable": "contenu",
"seo": "contenu",
"demo/scenarios": "contenu",
"qa/audit_5d": "verification-qa",
"qa/audit_4big": "verification-qa",
"qa/regression": "verification-qa"
},
"confirmations": {
"custom_modules": {
"libelle": "Créer les modules d'application custom (OTOV7 Ventes, OTOV7 CONFOTUR, …) avant tout import de fixtures",
"owner": "ERPNext Backend",
"source": "sessions 12/15/16 · création module OTO Ventes / OTOV7 CONFOTUR (hors périmètre worker · CLAUDE.md #8)"
},
"custom_doctypes": {
"libelle": "Créer les DocTypes custom (OTO Dossier Vente, CONFOTUR Application, Faisabilité, Publiciste Log) avant l'import des Workspaces et du Workflow",
"owner": "ERPNext Backend",
"source": "session 15 · DocTypes custom à créer avant import Workspaces (run-book RBAC)"
},
"taux_commission": {
"libelle": "Confirmer les taux de commission vendeurs (barème reste taux_pct null tant que non confirmé)",
"owner": "Direction",
"source": "qa/audit_5d contrôle D1.1 (A_CONFIRMER)"
},
"rnc_emisor": {
"libelle": "Confirmer le RNC émetteur pour l'émission e-CF DGII",
"owner": "Compta",
"source": "qa/audit_5d contrôle D1.2 (A_CONFIRMER)"
},
"itbis_tipocambio": {
"libelle": "Confirmer le taux ITBIS et le TipoCambio USD/DOP pour l'e-CF",
"owner": "Compta Fiscaliste eCF",
"source": "qa/audit_5d contrôle D1.3 (A_CONFIRMER)"
},
"seuil_uaf": {
"libelle": "Confirmer le seuil d'opération de déclaration UAF (Ley 155-17)",
"owner": "Oficial de Cumplimiento",
"source": "qa/audit_5d contrôle D2.3 (A_CONFIRMER)"
},
"endpoint_otoia": {
"libelle": "Renseigner l'endpoint runtime du chat OTOIA (reste null / a_confirmer côté livrable)",
"owner": "ERPNext Backend",
"source": "session 19 · endpoint OTOIA = null (a_confirmer)"
}
},
"notes": [
"Agrégateur de MÉTA-NIVEAU, une couche au-dessus du run-book RBAC (rbac/apply_plan) : il ordonne le déploiement VPS de TOUS les livrables gated du mandat en un seul plan de phases, pas seulement des 3 volets RBAC.",
"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), jamais listé à la main. La couverture module→phase est BIJECTIVE avec le CI (moins le module lui-même).",
"Séparation des pouvoirs (ISA 315) : le run-book s'exclut lui-même (self_module) — il ne s'auto-déploie pas et ne se compte pas.",
"ANTI-INVENTION (#6) : ce spec ne contient AUCUN chiffre métier (taux, montant, seuil). Les paramètres réglementaires non confirmés restent des CONFIRMATIONS sourcées (owner + source), jamais une valeur fabriquée « pour faire PASS ». Chaque confirmation cite sa source (contrôle audit_5d ou session).",
"Le worker N'ÉCRIT JAMAIS sur le VPS (CLAUDE.md #8) : ce module produit un plan ordonné en-repo ; l'application réelle (bench migrate, imports, vérifications HTTP) reste côté serveur, pilotée par l'agent DevOps / ERPNext Backend."
]
}
@@ -0,0 +1,7 @@
"""deploylib · agrégation du run-book de déploiement VPS unifié.
Ce paquet ne ré-implémente rien : `deps` réutilise le validateur JSON-Schema
maison du Publiciste et le parseur CI de q4lib (registry.parse_ci) ; `builder`
assemble le plan de phases à partir du spec + de la réalité du CI. Aucun accès
réseau (le gate Gitea tourne hors ligne · CLAUDE.md #2).
"""
@@ -0,0 +1,136 @@
"""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}
@@ -0,0 +1,38 @@
"""Réutilisation des briques déjà livrées (workflow #5 · zéro duplication).
Le run-book de déploiement ne redéfinit rien :
- `validate` = le validateur JSON-Schema maison du Publiciste (draft-07,
sous-ensemble · aucune dépendance pip · le gate CI tourne sans réseau · #2).
- `parse_ci` = le parseur de `.gitea/workflows/ci.yml` de l'auditeur 4Big
(q4lib.registry), SEULE autorité sur les modules réellement gated. Réutilisé
ici pour dériver — jamais lister à la main — l'ensemble des livrables à
déployer (périmètre PROUVÉ, pas déclaré).
Import par `sys.path` (idiome des modules voisins) — une seule source de vérité.
"""
from __future__ import annotations
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
# devops/deploy_runbook/deploylib → 05_deliverables_mvp
_DELIVERABLES = os.path.normpath(os.path.join(_HERE, "..", "..", ".."))
_PUB = os.path.join(_DELIVERABLES, "publiciste")
_AUDIT4BIG = os.path.join(_DELIVERABLES, "qa", "audit_4big")
for _p in (_PUB, _AUDIT4BIG):
if _p not in sys.path:
sys.path.insert(0, _p)
from lib import validator # type: ignore # noqa: E402
from q4lib import registry # type: ignore # noqa: E402
validate = validator.validate
parse_ci = registry.parse_ci
# Racine des livrables, exposée aux consommateurs.
DELIVERABLES_ROOT = _DELIVERABLES
__all__ = ["validate", "parse_ci", "DELIVERABLES_ROOT"]
@@ -0,0 +1,44 @@
{
"generated_from": "deploy_spec.json v1.0",
"reference_cadre": "Roadmap Sprint 8 · DevOps « Deployment production complet + monitoring » · CLAUDE.md #8 (VPS pour TOUS projets · le worker n'écrit jamais sur le VPS)",
"cible_portage": "agent DevOps / ERPNext Backend",
"self_module": "devops/deploy_runbook",
"counts": {
"phases": 7,
"modules": 20,
"modules_mapped": 20,
"confirmations": 7,
"confirmations_referenced": 7
},
"coverage": {
"ci_modules_count": 20,
"mapped_modules_count": 20,
"bijective": true,
"missing_in_map": [],
"extra_in_map": [],
"unknown_phase": [],
"self_module_excluded": "devops/deploy_runbook"
},
"graph": {
"phase_order": [
"prerequis",
"doctypes",
"rbac-apply",
"workflow-metier",
"frontend",
"contenu",
"verification-qa"
],
"orphan_confirmations": [],
"unknown_confirmations": []
},
"confirmations_ouvertes": [
"custom_doctypes",
"custom_modules",
"endpoint_otoia",
"itbis_tipocambio",
"rnc_emisor",
"seuil_uaf",
"taux_commission"
]
}
@@ -0,0 +1,221 @@
[
{
"order": 1,
"id": "prerequis",
"titre": "Prérequis VPS : modules custom OTOV7 + spec RBAC validée",
"responsable": "vps",
"rationale": "Rien ne s'importe tant que les modules d'application custom (OTOV7 Ventes, OTOV7 CONFOTUR, …) n'existent pas et que le contrat RBAC 50 rôles n'est pas validé comme source de vérité unique.",
"depends_on": [],
"modules": [
{
"module": "rbac",
"ci_job": "rbac-tests"
}
],
"confirmations": [
{
"id": "custom_modules",
"libelle": "Créer les modules d'application custom (OTOV7 Ventes, OTOV7 CONFOTUR, …) avant tout import de fixtures",
"owner": "ERPNext Backend",
"source": "sessions 12/15/16 · création module OTO Ventes / OTOV7 CONFOTUR (hors périmètre worker · CLAUDE.md #8)"
}
]
},
{
"order": 2,
"id": "doctypes",
"titre": "DocTypes custom (structure) avant tout ce qui les référence",
"responsable": "vps",
"rationale": "Les Custom DocPerm, le Workflow vente et les Workspaces référencent des DocTypes custom qui doivent exister au préalable (contrainte dure ERPNext v15).",
"depends_on": [
"prerequis"
],
"modules": [
{
"module": "crm/dossier_vente",
"ci_job": "crm-dossier-vente-tests"
},
{
"module": "legal/confotur",
"ci_job": "legal-confotur-tests"
}
],
"confirmations": [
{
"id": "custom_doctypes",
"libelle": "Créer les DocTypes custom (OTO Dossier Vente, CONFOTUR Application, Faisabilité, Publiciste Log) avant l'import des Workspaces et du Workflow",
"owner": "ERPNext Backend",
"source": "session 15 · DocTypes custom à créer avant import Workspaces (run-book RBAC)"
}
]
},
{
"order": 3,
"id": "rbac-apply",
"titre": "RBAC : Role + Custom DocPerm + User Permission + Role Profile",
"responsable": "worker+vps",
"rationale": "Le run-book RBAC (rbac/apply_plan) ordonne déjà ce volet ; on l'insère ici en bloc car les Has Role d'un Role Profile et les DocPerm référencent des Role/DocTypes qui doivent préexister.",
"depends_on": [
"doctypes"
],
"modules": [
{
"module": "rbac/apply_plan",
"ci_job": "rbac-applyplan-tests"
},
{
"module": "rbac/fixtures_gen",
"ci_job": "rbac-fixtures-tests"
},
{
"module": "rbac/roleprofile_gen",
"ci_job": "rbac-roleprofile-tests"
},
{
"module": "rbac/userperm_gen",
"ci_job": "rbac-userperm-tests"
}
],
"confirmations": []
},
{
"order": 4,
"id": "workflow-metier",
"titre": "Workflow vente + règles métier (commissions, e-CF DGII, dépôts CONFOTUR)",
"responsable": "worker+vps",
"rationale": "Le Workflow pilote le DocType Dossier Vente et s'appuie sur les rôles ; commissions et e-CF se déclenchent sur ses états soumis. Paramètres réglementaires à confirmer avant activation.",
"depends_on": [
"doctypes",
"rbac-apply"
],
"modules": [
{
"module": "crm/commissions",
"ci_job": "crm-commissions-tests"
},
{
"module": "crm/workflow_vente",
"ci_job": "crm-workflow-vente-tests"
},
{
"module": "fiscal/ecf_dgii",
"ci_job": "fiscal-ecf-tests"
}
],
"confirmations": [
{
"id": "taux_commission",
"libelle": "Confirmer les taux de commission vendeurs (barème reste taux_pct null tant que non confirmé)",
"owner": "Direction",
"source": "qa/audit_5d contrôle D1.1 (A_CONFIRMER)"
},
{
"id": "rnc_emisor",
"libelle": "Confirmer le RNC émetteur pour l'émission e-CF DGII",
"owner": "Compta",
"source": "qa/audit_5d contrôle D1.2 (A_CONFIRMER)"
},
{
"id": "itbis_tipocambio",
"libelle": "Confirmer le taux ITBIS et le TipoCambio USD/DOP pour l'e-CF",
"owner": "Compta Fiscaliste eCF",
"source": "qa/audit_5d contrôle D1.3 (A_CONFIRMER)"
},
{
"id": "seuil_uaf",
"libelle": "Confirmer le seuil d'opération de déclaration UAF (Ley 155-17)",
"owner": "Oficial de Cumplimiento",
"source": "qa/audit_5d contrôle D2.3 (A_CONFIRMER)"
}
]
},
{
"order": 5,
"id": "frontend",
"titre": "Frontend desk : Workspaces portails + Custom Block chat OTOIA",
"responsable": "worker+vps",
"rationale": "Les Workspaces (Has Role) et le Custom Block de chat s'ancrent sur les rôles RBAC et les DocTypes déjà en place ; l'endpoint OTOIA reste à renseigner.",
"depends_on": [
"rbac-apply"
],
"modules": [
{
"module": "frontend/chat_otoia",
"ci_job": "chat-otoia-tests"
},
{
"module": "frontend/portails",
"ci_job": "frontend-portails-tests"
}
],
"confirmations": [
{
"id": "endpoint_otoia",
"libelle": "Renseigner l'endpoint runtime du chat OTOIA (reste null / a_confirmer côté livrable)",
"owner": "ERPNext Backend",
"source": "session 19 · endpoint OTOIA = null (a_confirmer)"
}
]
},
{
"order": 6,
"id": "contenu",
"titre": "Contenu projets & publication : Publiciste, Faisabilité, Bancable, SEO, Démo",
"responsable": "worker+vps",
"rationale": "Génération et publication du contenu projet (data_room, dossiers bancables, balises SEO, run-sheet de démo) une fois la plateforme et le frontend en place.",
"depends_on": [
"workflow-metier",
"frontend"
],
"modules": [
{
"module": "demo/scenarios",
"ci_job": "demo-scenario-tests"
},
{
"module": "faisabilite/bancable",
"ci_job": "bancable-tests"
},
{
"module": "faisabilite/generator",
"ci_job": "faisabilite-gen-tests"
},
{
"module": "publiciste",
"ci_job": "publiciste-tests"
},
{
"module": "seo",
"ci_job": "seo-tests"
}
],
"confirmations": []
},
{
"order": 7,
"id": "verification-qa",
"titre": "Vérification post-déploiement : HTTP + audits QA (5D · 4Big · régression)",
"responsable": "vps",
"rationale": "Gate final : vérification HTTP puis exécution des audits de conformité 5D, de qualité 4Big (≥95/100) et de la matrice de régression sur le desk réel.",
"depends_on": [
"workflow-metier",
"frontend",
"contenu"
],
"modules": [
{
"module": "qa/audit_4big",
"ci_job": "qa-audit-4big-tests"
},
{
"module": "qa/audit_5d",
"ci_job": "qa-audit-5d-tests"
},
{
"module": "qa/regression",
"ci_job": "qa-regression-tests"
}
],
"confirmations": []
}
]
@@ -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)