[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:
@@ -0,0 +1,92 @@
|
||||
# Daily report · 2026-07-30 · Session 23 (`20260730_112734`)
|
||||
|
||||
## Tâche
|
||||
|
||||
**Sprint 8 · DevOps** — Générateur du **Run-book de déploiement VPS unifié**
|
||||
(roadmap Sprint 8 · DevOps « **Deployment production complet + monitoring** »,
|
||||
L73). Dernier volet Sprint 8 réalisable en-repo : la matrice de régression (QA,
|
||||
session 22) et les audits qualité/conformité étant livrés, le déploiement réel
|
||||
sur le VPS reste hors périmètre worker (#8), mais **le PLAN de déploiement**
|
||||
ordonné, lui, se produit en-repo.
|
||||
|
||||
## Gap comblé
|
||||
|
||||
Chaque générateur terminait sa session par 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**.
|
||||
Aucun artefact ne consolidait « dans quel ordre déployer les 20 livrables gated,
|
||||
avec quelles confirmations préalables ». C'est le trou que comble ce module.
|
||||
|
||||
## Décision d'architecture
|
||||
|
||||
**Agrégateur de MÉTA-NIVEAU**, une couche **au-dessus** du run-book RBAC
|
||||
(`rbac/apply_plan`, qui n'ordonne que les 3 volets RBAC). Ce module-ci ordonne le
|
||||
déploiement VPS de **TOUS** les livrables gated en **un plan de 7 phases** :
|
||||
1. Prérequis (modules custom OTOV7 + spec RBAC) → 2. DocTypes custom →
|
||||
3. RBAC (Role/DocPerm/UP/Role Profile) → 4. Workflow + règles métier →
|
||||
5. Frontend (Workspaces + chat OTOIA) → 6. Contenu & publication →
|
||||
7. Vérification QA post-déploiement (5D · 4Big · régression).
|
||||
|
||||
Chaque phase déclare son `responsable`, sa `rationale`, ses modules gated
|
||||
assignés, ses dépendances **inter-phases** (graphe acyclique, renvois arrière
|
||||
uniquement) et ses **confirmations préalables** sourcées.
|
||||
|
||||
## Anti-invention (cœur · #6)
|
||||
|
||||
- **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, et mis en correspondance
|
||||
**BIJECTIVE** avec le `module_phase` du spec. Un livrable ajouté au CI sans
|
||||
entrée de phase → `missing_in_map` → génération refusée ; une entrée sans job
|
||||
CI → `extra_in_map` → refus. La validation **recalcule** la couverture depuis
|
||||
le CI (ne fait pas confiance au manifeste).
|
||||
- **Séparation des pouvoirs** (ISA 315) : le run-book s'exclut lui-même
|
||||
(`self_module`) — il ne s'auto-déploie ni ne se compte.
|
||||
- **Zéro chiffre métier** : le spec ne contient aucun taux/montant/seuil ; les
|
||||
paramètres réglementaires non confirmés restent des **confirmations sourcées**
|
||||
(`owner` + `source`), reprises des contrôles `qa/audit_5d` (D1.1 taux commission
|
||||
→ Direction ; D1.2 RNC → Compta ; D1.3 ITBIS/TipoCambio → Fiscaliste eCF ;
|
||||
D2.3 seuil UAF → Oficial de Cumplimiento) et des sessions amont (endpoint OTOIA,
|
||||
DocTypes/modules custom). Un invariant **refuse** tout champ de valeur chiffrée.
|
||||
|
||||
## Fichiers créés — `05_deliverables_mvp/devops/deploy_runbook/`
|
||||
|
||||
- `deploy_spec.json` (7 phases + `module_phase` des 20 modules + catalogue de 7
|
||||
confirmations sourcées · **0 chiffre**)
|
||||
- `deploylib/{__init__,deps,builder}.py` (`deps` réutilise le validateur maison
|
||||
Publiciste **et** `q4lib.registry.parse_ci` ; `builder` PUR et déterministe)
|
||||
- `deploy_runbook_gen.py` (CLI `build`/`validate` · **11 familles d'invariants**)
|
||||
- `deploy.schema.json` (contrat de sortie draft-07)
|
||||
- `out/{deploy_runbook,MANIFEST}.json` (hand-off) · `tests/test_deploy_runbook.py`
|
||||
(**29 tests** dont 14 injections négatives) · `README.md` · `.gitignore`
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
- `.gitea/workflows/ci.yml` : job `devops-deploy-runbook-tests` + ajout au `gate`.
|
||||
- `qa/audit_4big/` : enregistrement du module `devops/deploy_runbook` (couverture
|
||||
bijective **19 → 20 modules** · verdict **PASS 20/20 à 100**) ; note rendue
|
||||
count-agnostique ; `out/` régénéré.
|
||||
- `qa/regression/out/` : plan régénéré (**19 → 20 suites**) — le harnais découvre
|
||||
la nouvelle suite via le CI automatiquement.
|
||||
|
||||
## Résultat
|
||||
|
||||
Run-book `bijective=true` — **7 phases · 20 modules gated couverts sans doublon ·
|
||||
7 confirmations préalables sourcées** · graphe de phases acyclique.
|
||||
|
||||
## Vérifications
|
||||
|
||||
- 29/29 tests module ; 34/34 audit_4big (**PASS 20/20 à 100**) ; tests régression
|
||||
verts.
|
||||
- Régression `run` exhaustive : **20/20 suites vertes · 503 tests passés · 0
|
||||
échec · 0 erreur** (474 → +29).
|
||||
- Gate CI local vert : `guard_constraints` + `validate_json` + `check_docs`
|
||||
(liens internes OK) ; `ci.yml` YAML valide ; build déterministe.
|
||||
|
||||
## Hors périmètre worker (VPS · #8)
|
||||
|
||||
Exécuter le plan sur le VPS (créer les modules/DocTypes custom, importer fixtures
|
||||
et Workflows, renseigner les 7 confirmations, vérifs HTTP + audits sur le desk
|
||||
réel) → agent DevOps / ERPNext Backend.
|
||||
|
||||
**Auto-score 4Big : 96/100.**
|
||||
@@ -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)
|
||||
@@ -4,7 +4,7 @@
|
||||
"audit": "OTO QA · Audit 4Big Qualité",
|
||||
"version": "1.0",
|
||||
"verdict": "PASS",
|
||||
"modules_audited": 19,
|
||||
"modules_audited": 20,
|
||||
"min_score": 100,
|
||||
"pass_score": 95,
|
||||
"coverage_ok": true,
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"pass_score": 95,
|
||||
"coverage": {
|
||||
"ok": true,
|
||||
"ci_modules_count": 19,
|
||||
"registry_modules_count": 19,
|
||||
"ci_modules_count": 20,
|
||||
"registry_modules_count": 20,
|
||||
"missing_in_registry": [],
|
||||
"missing_in_ci": [],
|
||||
"not_in_gate": [],
|
||||
@@ -760,6 +760,48 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "devops-deploy-runbook",
|
||||
"path": "devops/deploy_runbook",
|
||||
"sprint": "S8",
|
||||
"archetype": "generator",
|
||||
"applicable_weight": 100,
|
||||
"earned_weight": 100,
|
||||
"score": 100,
|
||||
"verdict": "PASS",
|
||||
"checks": [
|
||||
{
|
||||
"criterion": "DOC",
|
||||
"weight": 20,
|
||||
"passed": true,
|
||||
"evidence": "README.md (5414 octets)"
|
||||
},
|
||||
{
|
||||
"criterion": "CONTRAT",
|
||||
"weight": 20,
|
||||
"passed": true,
|
||||
"evidence": "deploy.schema.json"
|
||||
},
|
||||
{
|
||||
"criterion": "TESTS",
|
||||
"weight": 25,
|
||||
"passed": true,
|
||||
"evidence": "29 méthodes test_* dans 1 fichier(s) (seuil 8)"
|
||||
},
|
||||
{
|
||||
"criterion": "CLI",
|
||||
"weight": 15,
|
||||
"passed": true,
|
||||
"evidence": "deploy_runbook_gen.py (argparse + __main__)"
|
||||
},
|
||||
{
|
||||
"criterion": "HANDOFF",
|
||||
"weight": 20,
|
||||
"passed": true,
|
||||
"evidence": "MANIFEST + 1 artefact(s) JSON valides"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "qa-regression",
|
||||
"path": "qa/regression",
|
||||
@@ -798,14 +840,14 @@
|
||||
"criterion": "HANDOFF",
|
||||
"weight": 20,
|
||||
"passed": true,
|
||||
"evidence": "MANIFEST + 1 artefact(s) JSON valides"
|
||||
"evidence": "MANIFEST + 2 artefact(s) JSON valides"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"modules": 19,
|
||||
"pass": 19,
|
||||
"modules": 20,
|
||||
"pass": 20,
|
||||
"fail": 0,
|
||||
"min_score": 100,
|
||||
"max_score": 100,
|
||||
@@ -815,6 +857,6 @@
|
||||
"notes": [
|
||||
"Audit de MÉTA-NIVEAU : chaque note de module est CALCULÉE depuis des faits du dépôt (présence README/schéma, nombre de méthodes test_*, entrypoint CLI, intégrité out/) — jamais saisie à la main (#6).",
|
||||
"Un critère non applicable à l'archétype d'un module est EXCLU du dénominateur (pas de pénalité pour un hand-off qu'un module de contrat n'a jamais vocation à produire) ; les poids restants sont renormalisés à 100.",
|
||||
"Le verdict global est PASS si et seulement si les 18 modules atteignent ≥ 95/100 ET la couverture == 100% des livrables gated (hors auditeur)."
|
||||
"Le verdict global est PASS si et seulement si TOUS les modules du registre atteignent ≥ 95/100 ET la couverture == 100% des livrables gated (hors auditeur)."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -79,11 +79,12 @@
|
||||
{ "id": "seo", "path": "seo", "sprint": "S6", "archetype": "generator", "source": "roadmap Sprint 6 · SEO trilingue mots-clés/schema.org/hreflang · ci.yml seo-tests" },
|
||||
{ "id": "frontend-chat-otoia", "path": "frontend/chat_otoia", "sprint": "S6", "archetype": "generator", "source": "roadmap Sprint 6 · Chat OTOIA embarqué (Custom Block) · ci.yml chat-otoia-tests" },
|
||||
{ "id": "demo-scenarios", "path": "demo/scenarios", "sprint": "S7", "archetype": "generator", "source": "roadmap Sprint 7 · Scénarios démo (P07 banquier / P05 client) · ci.yml demo-scenario-tests" },
|
||||
{ "id": "qa-regression", "path": "qa/regression", "sprint": "S8", "archetype": "generator", "source": "roadmap Sprint 8 · Regression tests exhaustifs (matrice méta-niveau) · ci.yml qa-regression-tests" }
|
||||
{ "id": "qa-regression", "path": "qa/regression", "sprint": "S8", "archetype": "generator", "source": "roadmap Sprint 8 · Regression tests exhaustifs (matrice méta-niveau) · ci.yml qa-regression-tests" },
|
||||
{ "id": "devops-deploy-runbook", "path": "devops/deploy_runbook", "sprint": "S8", "archetype": "generator", "source": "roadmap Sprint 8 · DevOps Deployment production complet (run-book VPS unifié) · ci.yml devops-deploy-runbook-tests" }
|
||||
],
|
||||
"notes": [
|
||||
"Audit de MÉTA-NIVEAU : chaque note de module est CALCULÉE depuis des faits du dépôt (présence README/schéma, nombre de méthodes test_*, entrypoint CLI, intégrité out/) — jamais saisie à la main (#6).",
|
||||
"Un critère non applicable à l'archétype d'un module est EXCLU du dénominateur (pas de pénalité pour un hand-off qu'un module de contrat n'a jamais vocation à produire) ; les poids restants sont renormalisés à 100.",
|
||||
"Le verdict global est PASS si et seulement si les 18 modules atteignent ≥ 95/100 ET la couverture == 100% des livrables gated (hors auditeur)."
|
||||
"Le verdict global est PASS si et seulement si TOUS les modules du registre atteignent ≥ 95/100 ET la couverture == 100% des livrables gated (hors auditeur)."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"matrix": "OTO QA · Matrice de régression exhaustive",
|
||||
"version": "1.0",
|
||||
"verdict": "PASS",
|
||||
"suites": 19,
|
||||
"test_methods": 474,
|
||||
"suites": 20,
|
||||
"test_methods": 503,
|
||||
"coverage_ok": true,
|
||||
"artifacts": [
|
||||
"regression_plan.json"
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
],
|
||||
"coverage": {
|
||||
"ok": true,
|
||||
"suites_count": 19,
|
||||
"gated_in_ci": 20,
|
||||
"suites_count": 20,
|
||||
"gated_in_ci": 21,
|
||||
"self_module_excluded": "qa/regression",
|
||||
"self_module_gated": true,
|
||||
"missing_tests_dir": [],
|
||||
@@ -69,6 +69,17 @@
|
||||
"test_files": 1,
|
||||
"test_methods": 32
|
||||
},
|
||||
{
|
||||
"id": "devops.deploy_runbook",
|
||||
"path": "devops/deploy_runbook",
|
||||
"jobs": [
|
||||
"devops-deploy-runbook-tests"
|
||||
],
|
||||
"in_gate": true,
|
||||
"has_tests_dir": true,
|
||||
"test_files": 1,
|
||||
"test_methods": 29
|
||||
},
|
||||
{
|
||||
"id": "faisabilite.bancable",
|
||||
"path": "faisabilite/bancable",
|
||||
@@ -237,9 +248,9 @@
|
||||
],
|
||||
"under_threshold": [],
|
||||
"totals": {
|
||||
"suites": 19,
|
||||
"test_files": 19,
|
||||
"test_methods": 474,
|
||||
"suites": 20,
|
||||
"test_files": 20,
|
||||
"test_methods": 503,
|
||||
"min_methods": 10,
|
||||
"under_threshold": 0
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user