From 3c01ab3ecccef17e9e6dc12d6bb0954d9582f974 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 10:50:12 +0200 Subject: [PATCH 1/4] =?UTF-8?q?docs(ml):=20=C3=A9crit=20ML-START.md=20et?= =?UTF-8?q?=20r=C3=A9pare=20les=20renvois=20cass=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le document était référencé 11 fois, dont 4 depuis le code (config.py, data.py, train.py, score.py, features.py), et n'avait jamais été écrit. Deux chemins contradictoires coexistaient : `../ML-START.md` depuis ml/README.md et docs/architecture/, `docs/ML-START.md` depuis le code. Le chemin retenu est celui du code, majoritaire et le seul qu'un lecteur du module rencontre. Il couvre les trois sections que les renvois annoncent : mécanisme d'accès aux données et pourquoi ce n'est pas l'API, étapes d'un run de scoring, frontière entre FastAPI et LightGBM. Ferme C34 de la grille d'auto-évaluation. --- docs/ML-START.md | 176 ++++++++++++++++++++++++++++++++ docs/architecture/20-backend.md | 2 +- ml/README.md | 2 +- 3 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 docs/ML-START.md diff --git a/docs/ML-START.md b/docs/ML-START.md new file mode 100644 index 0000000..e4d9f93 --- /dev/null +++ b/docs/ML-START.md @@ -0,0 +1,176 @@ +# ML-START : accès aux données, scoring, frontière API et ML + +Document de référence du module `ml/`, cité par le code (`enervision_ml/config.py`, `data.py`, +`train.py`, `score.py`, `features.py`), par l'[ADR 0005](adr/0005-modele-prediction-lightgbm.md) +et par les vues d'architecture. Il répond à trois questions, et à elles seules : + +1. **comment le pipeline accède aux données**, et pourquoi pas par l'API ; +2. **ce que fait un run de scoring**, étape par étape ; +3. **où passe la frontière entre l'API et le ML**, et pourquoi elle est là. + +Le mode d'emploi (installation, commandes, options) est dans [`ml/README.md`](../ml/README.md). +Le choix du modèle est dans l'ADR 0005. Ce document ne les répète pas. + +--- + +## 1. Mécanisme d'accès aux données + +### Deux sources, un seul schéma de sortie + +`enervision_ml.data` expose trois chargeurs qui produisent **exactement les mêmes neuf colonnes** +(`site_id`, `timestamp`, `consumption_kwh`, `temperature_celsius`, `humidity_percent`, +`solar_irradiance_wm2`, `is_working_hours`, `site_type`, `capacity_kw`) : + +| Fonction | Source | Usage | +|---|---|---| +| `load_from_csv(path)` | `ml/data/all_sites_combined.csv` | Chemin de démarrage, tant que la base n'est pas peuplée | +| `load_from_database(connection)` | `reading` joint à `site`, **historique complet** | Entraînement | +| `load_recent_from_database(connection, since=…)` | `reading` joint à `site`, **borné par `since`** | Scoring | + +L'égalité des schémas n'est pas un confort : c'est ce qui permet de valider tout le pipeline sur +CSV, sans base joignable, et d'obtenir le même comportement une fois la base peuplée. Une +divergence entre les deux chemins ne se verrait pas au chargement, elle se verrait en production +sous forme de prédictions silencieusement fausses. + +### Connexion directe à PostgreSQL, pas l'API + +Le pipeline lit `reading` et `site` **en SQL direct**, jamais par `GET /api/v1/readings`. Trois +raisons, à défendre telles quelles : + +- **Volume.** L'entraînement lit l'historique complet d'une hypertable TimescaleDB. Le faire + passer par une API REST paginée, sérialisée en JSON et contrôlée route par route, c'est payer + trois fois pour un `SELECT`. +- **Couplage.** Le pipeline n'est pas un client de l'application, c'est un consommateur du + schéma. Passer par l'API le rendrait dépendant du contrat HTTP, de l'authentification et de la + disponibilité du service, pour lire des données dont il connaît déjà la forme. +- **Droits.** Un rôle de lecture sur deux tables est une surface plus petite qu'un compte + applicatif porteur d'un rôle métier. + +### `ML_DATABASE_URL`, et pourquoi ce n'est pas `DATABASE_URL` + +La chaîne de connexion est lue dans **`ML_DATABASE_URL`**, jamais dans `DATABASE_URL`. Ce n'est +pas une préférence de nommage : `DATABASE_URL` est celle du backend applicatif, **propriétaire du +schéma**, avec les droits d'écriture complets. Réutiliser cette variable par défaut ferait tourner +l'entraînement et le scoring avec ces droits, **en silence**. `enervision_ml.config.database_url()` +lève donc plutôt que de retomber sur une valeur par défaut. + +**Dette assumée, à dire à l'oral et non à masquer** : le rôle PostgreSQL dédié `enervision_ml`, +restreint en lecture sur `reading` et `site`, **n'est pas provisionné**. En développement, +`ML_DATABASE_URL` pointe sur la même base que le backend. La cible est un rôle séparé, cohérente +avec le principe de moindre privilège posé par l'[ADR 0003](adr/0003-autorisation-rbac-a-trois-roles.md). + +### Le seul endroit qui construit les features + +`enervision_ml.features.build_features` est **l'unique** constructeur de features, à +l'entraînement comme au scoring. Le piège que cela évite : si les deux divergent, même d'une +fenêtre de moyenne glissante, le modèle reçoit en service des features qui ne ressemblent plus à +ce qu'il a appris, et ses prédictions se dégradent **sans qu'aucune erreur ne se déclenche**. +Ne jamais réécrire cette logique ailleurs : importer le module. + +Conséquence sur la validation : la coupure entraînement / validation est **chronologique**, jamais +un tirage aléatoire de lignes. Un tirage aléatoire laisserait des lignes de validation voir des +lignes d'entraînement à travers leurs lags et leurs moyennes glissantes, une fuite qui masquerait +un surapprentissage. + +--- + +## 2. Les étapes d'un run de scoring + +`python -m enervision_ml.score` calcule, pour chaque site ou pour un seul avec `--site-id`, la +consommation prévue de **l'heure suivant sa dernière lecture connue**, et écrit une ligne dans +`prediction`. + +| # | Étape | Point de vigilance | +|---|---|---| +| 1 | Charger une **fenêtre récente** de `reading` joint à `site` : 21 jours par défaut | Une marge au-dessus des 168 h qu'exige le lag hebdomadaire. Un `SELECT` non borné sur l'hypertable serait la même erreur que celle corrigée sur `GET /readings` | +| 2 | Ajouter **une ligne future par site**, l'heure suivante, et calculer ses features par `build_features` | La même fonction qu'à l'entraînement, cf. section 1 | +| 3 | Si le **lag de 168 h est absent** (moins d'une semaine d'historique) : écrire `status = "insufficient_data"` | **LightGBM n'est jamais appelé.** Un modèle interrogé sans son lag principal rendrait un nombre, et ce nombre serait faux sans le dire | +| 4 | Sinon : `booster.predict(...)`, puis écrire `status = "available"` et la valeur prévue | | + +### Ce que le run écrit, et ce qu'il n'écrase pas + +La table `prediction` **n'a pas de contrainte d'unicité sur `(site_id, target_at)`** : chaque run +insère une ligne de plus au lieu d'écraser la précédente. C'est délibéré, et c'est ce qui rendra +possible la comparaison prévision contre réalisé, donc la surveillance de dérive (#44, #45), qui +n'existe pas encore. + +Trois contraintes de cohérence sont portées par la base et non par le code applicatif : +`status = 'available'` exige une `predicted_value` et interdit un `failure_reason` ; +`insufficient_data` et `error` exigent l'inverse ; `target_metric` est bornée à +`consumption_kwh` ou `consumption_kw`, et la forme énergie impose une `period_minutes`. + +### `model_reference` est un hachage, pas un nom de fichier + +`train.py` réécrit **toujours le même chemin** (`models/lightgbm-consumption.txt`) à chaque +entraînement. Le nom de fichier ne distinguerait donc pas deux versions du modèle. `prediction` +porte pour cela le **SHA-256 tronqué du fichier modèle**. C'est ce qui permet, devant une +prédiction douteuse, de savoir quel modèle l'a produite. + +### Mode CSV : rien n'est écrit en base + +En `--csv`, le run ne touche pas la base. L'heure future calculée depuis la fin du CSV n'existe +dans aucune base réelle : ce serait inscrire une prévision pour un instant déjà passé. Le mode +sert à valider le pipeline sans base joignable. + +### Limite assumée + +La feature `is_working_hours` de la ligne future est **recopiée** depuis la dernière lecture +réelle, pas recalculée : il n'existe aucune règle d'heures ouvrables dans ce dépôt, elle vit dans +le générateur du jeu de données d'origine. L'approximation n'est fausse qu'aux heures de bascule, +sur une feature parmi une dizaine, pour une prévision à un seul pas. + +--- + +## 3. La frontière entre l'API et le ML + +```mermaid +flowchart LR + subgraph ml["ml/ · projet Python indépendant"] + train["enervision_ml.train
LightGBM + MLflow"] + score["enervision_ml.score
prévision à un pas"] + end + subgraph db["PostgreSQL + TimescaleDB"] + reading[("reading, site")] + prediction[("prediction")] + end + subgraph api["apps/backend · FastAPI"] + route["GET /api/v1/predictions"] + end + + reading -- "SQL direct, ML_DATABASE_URL" --> train + reading -- "fenêtre récente" --> score + train -- "models/*.txt + run MLflow" --> score + score -- "INSERT" --> prediction + prediction -- "lecture seule" --> route +``` + +**La règle, en une phrase : FastAPI ne fait jamais tourner LightGBM.** +`GET /api/v1/predictions` lit la dernière prévision par site dans `prediction`, jamais un recalcul +à la volée. Ce qui en découle, et qui est l'argument à tenir devant le jury : + +- **La latence de l'API ne dépend pas du modèle.** Une route de lecture indexée + (`ix_prediction_site_target`) répond en temps constant, qu'un run de scoring dure une seconde + ou une minute. +- **Le service de production n'embarque ni LightGBM ni MLflow.** `ml/` est un projet Python + séparé, avec son propre `uv.lock`. Le backend n'a aucune raison de porter ces dépendances, ni + leur surface de vulnérabilités, pour un script lancé hors du chemin de requête. +- **Une panne du pipeline dégrade, elle n'interrompt pas.** Si le scoring ne tourne plus, l'API + continue de servir la dernière prévision connue, avec son `created_at` et son + `model_reference`, au lieu de rendre une erreur. +- **Le contrat est la table, pas un appel.** Ce qui traverse la frontière, ce sont des lignes de + `prediction` et leurs contraintes de cohérence, vérifiables en SQL. + +Le corollaire est qu'il n'y a **aucune prévision à la demande** : la fraîcheur d'une prévision est +celle du dernier run de scoring. Tant que l'orchestration Airflow n'existe pas (`etl/airflow/` est +vide), ce run est lancé à la main. C'est la dette la plus visible du module, et elle est portée +par les issues #44 et #45. + +--- + +## Voir aussi + +- [`ml/README.md`](../ml/README.md) : installation, commandes, options, où écrire les tests +- [ADR 0005](adr/0005-modele-prediction-lightgbm.md) : pourquoi LightGBM, et les 6 candidats écartés +- [ADR 0006](adr/0006-moteur-de-regles-dans-le-backend.md) : ce qui consomme les prédictions +- [`architecture/20-backend.md`](architecture/20-backend.md) : le contrat de `GET /predictions` +- [`architecture/40-data.md`](architecture/40-data.md) : le modèle de données diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index c6189a6..3502f02 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -193,7 +193,7 @@ mécanisme que `ReadingRepository.latest_by_site()`. Un site jamais scoré rend plutôt qu'un statut inventé : le domaine `available`/`insufficient_data`/`error` de la contrainte `ck_prediction_status` n'a pas de valeur pour « pas encore de ligne ». L'API ne lance jamais LightGBM elle-même ; elle lit ce que le pipeline de scoring a déjà écrit, cf. -[ML-START.md](../../ML-START.md) section 3. +[ML-START.md](../ML-START.md) section 3. `POST /recommendations/generate` est la seule route d'écriture métier du contrat. Elle applique le moteur de règles d'`app/services/recommendation_rules.py` aux lignes d'`alert`, sans modèle ni diff --git a/ml/README.md b/ml/README.md index c7814fe..21ddd85 100644 --- a/ml/README.md +++ b/ml/README.md @@ -2,7 +2,7 @@ Pipeline d'entrainement du modele de prevision de consommation energetique. Contexte complet : [ADR 0005](../docs/adr/0005-modele-prediction-lightgbm.md) (choix du modele) et -[ML-START.md](../ML-START.md) (mecanisme d'acces aux donnees). +[ML-START.md](../docs/ML-START.md) (mecanisme d'acces aux donnees). | Element | Choix | |--------------|-----------------------------------------------| From f0ad8e99907587a73a0c6252ee2a2ac0226a0d9e Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 10:50:25 +0200 Subject: [PATCH 2/4] ci(security): branche Bandit sur apps/backend et sur le module ML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le pipeline auditait les dépendances (pip-audit, npm audit, Dependabot) mais jamais le code lui-même : aucun SAST, aucun DAST. C'était le seul rouge de BC03 qui se fermait en une étape de workflow. Le job bloque à partir de MEDIUM/MEDIUM, et une seconde passe sans seuil publie les constats LOW sans bloquer : sans elle, un LOW disparaîtrait du journal sans trace. Le périmètre est le code livré (`app`, `enervision_ml`) et non les tests, qui emploient légitimement des secrets factices et des `assert`. Relevé au 21/09 : zéro constat tous niveaux confondus sur 5 904 lignes. Couvre #39. Ferme C18 de la grille d'auto-évaluation. --- .github/workflows/backend.yml | 27 +++++++++++++++++++++++++++ .github/workflows/ml.yml | 24 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 8ad1302..f02eef8 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -140,3 +140,30 @@ jobs: # Piège : sans `shell: bash`, un échec de `uv export` serait masqué par le pipe. shell: bash run: uv export --frozen --no-dev --no-emit-project --no-hashes | uvx pip-audit --requirement /dev/stdin --no-deps + + sast: + name: Analyse statique de sécurité + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/backend + + steps: + - name: Récupère le dépôt + uses: actions/checkout@v4 + + - name: Installe uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: apps/backend/uv.lock + + # Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des + # secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod. + - name: Analyse le code livré (bloquant à partir de MEDIUM) + run: uvx bandit --recursive app --severity-level medium --confidence-level medium + + # Piège : sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace. + - name: Rapport complet, tous niveaux + continue-on-error: true + run: uvx bandit --recursive app diff --git a/.github/workflows/ml.yml b/.github/workflows/ml.yml index b85fec7..4700a97 100644 --- a/.github/workflows/ml.yml +++ b/.github/workflows/ml.yml @@ -57,3 +57,27 @@ jobs: # synthetiques ou un magasin SQLite local jetable (cf. ml/tests/test_train.py). - name: Tests run: uv run pytest + + sast: + name: Analyse statique de sécurité + runs-on: ubuntu-latest + defaults: + run: + working-directory: ml + + steps: + - name: Récupère le dépôt + uses: actions/checkout@v4 + + - name: Installe uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: ml/uv.lock + + - name: Analyse le code livré (bloquant à partir de MEDIUM) + run: uvx bandit --recursive enervision_ml --severity-level medium --confidence-level medium + + - name: Rapport complet, tous niveaux + continue-on-error: true + run: uvx bandit --recursive enervision_ml From 5545c166fd4aad178c4add25b6a6ba14c0bc0f37 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 10:50:25 +0200 Subject: [PATCH 3/4] docs(architecture): ajoute la vue CI/CD et corrige trois affirmations fausses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La documentation du pipeline est explicitement notée par EC03 (C20) et n'existait pas. L'index des vues justifiait son absence par un manque de matière : quatre workflows et quatorze jobs en sont assez. Trois affirmations de 00-vue-ensemble.md étaient devenues fausses, ce qui coûte plus cher qu'une absence puisqu'on les lit et qu'on construit dessus : - la CI/CD y était déclarée `Cible` / `Rien` alors que quatre workflows tournent ; - le flux bout en bout y était `Cible` avec "aucun maillon n'existe, à l'exception de la base", alors que tout le chemin de lecture et deux ingestions existent ; - l'analyse de dépendances y était listée comme absente alors que pip-audit, npm audit et Dependabot sont en place. Seule celle des images manque. Ferme C20 de la grille d'auto-évaluation. --- docs/architecture/00-vue-ensemble.md | 12 +- docs/architecture/50-cicd.md | 169 +++++++++++++++++++++++++++ docs/architecture/README.md | 8 +- 3 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 docs/architecture/50-cicd.md diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 2f54762..480ef5d 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -77,15 +77,17 @@ collecteur ne vient le lire. | Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) | | Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle | | Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) | -| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`. Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Automatisation (Airflow) et surveillance de dérive (EC06, #44/#45) pas encore construites | +| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`. Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Automatisation (Airflow) et surveillance de dérive (EC06, #44/#45) pas encore construites | | Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API | | ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien | -| CI/CD | GitHub Actions | `.github/workflows` | `Cible` | Rien | +| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 4 workflows, 14 jobs : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud. Détail dans [50-cicd.md](50-cicd.md). **Aucun job de déploiement** (#21) | ## Flux bout en bout -Statut : `Cible`. Aucun maillon de cette chaîne n'existe aujourd'hui, à l'exception de la base. +Statut : `En cours`. Tout le chemin de lecture existe (base, API, frontend), ainsi que l'ingestion +par import depuis un CSV historique et depuis l'API Mock. **Le seul maillon absent est +l'orchestration** : Airflow ne tourne pas, l'ingestion et le scoring sont lancés à la main. ```mermaid sequenceDiagram @@ -153,7 +155,9 @@ consolidée. - **TLS, HSTS et CSP** : ils appartiennent au terminateur TLS, qui n'existe pas encore. - **Limitation de débit au frontal** : celle de l'application protège les identifiants, pas l'infrastructure. -- **Analyse de dépendances et de conteneurs** dans la CI, qui relève du chantier CI/CD. +- **Analyse des images de conteneur** dans la CI. Celle des dépendances, elle, est en place + (`pip-audit`, `npm audit`, Dependabot sur 5 écosystèmes), de même que le SAST Bandit. Voir + [50-cicd.md](50-cicd.md). - **Le fichier `environment.ts` de production** pointe encore sur `http://localhost:8000` en HTTP simple : dans cet état, le cookie `Secure` ne sera pas posé. Voir [31-contrat-authentification.md](31-contrat-authentification.md). diff --git a/docs/architecture/50-cicd.md b/docs/architecture/50-cicd.md new file mode 100644 index 0000000..deadf87 --- /dev/null +++ b/docs/architecture/50-cicd.md @@ -0,0 +1,169 @@ +# Intégration et livraison continues + +Ce document décrit la chaîne qui s'exécute entre un `git push` et un merge autorisé : ce qui est +vérifié, ce qui bloque, et ce qui ne l'est pas. + +| Étage | Sert à | Statut | +|---|---|---| +| Intégration continue | Interdire le merge d'un code qui casse la qualité, les tests ou la sécurité | `Fait` | +| Livraison continue | Porter un artefact vérifié jusqu'à la machine de déploiement | `Cible` | + +Le **D** de CI/CD n'existe pas encore : aucun job de déploiement, aucune construction d'image +publiée, aucun environnement GitHub. L'issue #21 le porte. C'est la limite principale de cet +étage, et elle est nommée ici plutôt que découverte en soutenance. + +## Vue d'ensemble + +```mermaid +flowchart TB + push["push ou pull_request"] + + subgraph back["Backend · .github/workflows/backend.yml"] + bv["verification
ruff, mypy, pytest --cov-fail-under=85"] + bi["integration
TimescaleDB réel + alembic upgrade head"] + bd["security-audit
uv export | pip-audit"] + bs["sast
bandit"] + end + + subgraph front["Frontend · frontend.yml"] + fb["build
npm ci, npm run build"] + ft["test
couverture lcov"] + fd["security-audit
npm audit --audit-level=high"] + end + + subgraph mlw["ML · ml.yml"] + mv["verification
ruff, mypy, pytest"] + ms["sast
bandit"] + end + + subgraph sq["SonarQube · sonarqube.yml"] + sb1["build-front / test-front"] + sb2["build-back / test-back"] + sscan["sonarqube
quality gate SonarCloud"] + end + + push --> bv & bi & bd & bs + push --> fb --> ft + push --> fd + push --> mv & ms + push --> sb1 & sb2 --> sscan + sscan -.-> cd["deploy
issue #21"] +``` + +## Déclenchement + +Les quatre workflows se déclenchent sur `push` **et** sur `pull_request`, filtrés par **chemin** : +`backend.yml` sur `apps/backend/**`, `frontend.yml` sur `apps/frontend/**`, `ml.yml` sur `ml/**`, +chacun incluant son propre fichier de workflow dans le filtre pour qu'une modification du pipeline +déclenche le pipeline. + +**Piège à connaître** : il n'y a **aucun filtre de branche**. Une branche de travail déclenche la +CI complète à chaque push, et un merge vers n'importe quelle branche la déclenche aussi. C'est +délibéré pendant le projet (retour au plus tôt, et la CI tournera sur `main` dès la remontée sans +rien changer), mais ce serait à borner sur un dépôt à forte fréquence de push. + +`backend.yml` et `ml.yml` déclarent en plus un groupe de concurrence par référence git avec +`cancel-in-progress`, ce qui annule un run devenu obsolète par un push plus récent. + +## Ce qui bloque un merge + +| Gate | Où | Seuil | Effet d'un échec | +|---|---|---|---| +| Formatage `ruff format --check` | backend, ml | zéro écart | Bloque | +| Analyse statique `ruff check` | backend, ml | zéro constat | Bloque | +| Typage `mypy` | backend (`app`), ml (strict) | zéro erreur | Bloque | +| Tests unitaires `pytest` | backend, ml | **`--cov-fail-under=85`** côté backend | Bloque | +| Tests d'intégration | backend | marqueur `integration`, base réelle | Bloque | +| Audit de dépendances `pip-audit` | backend | sur le **verrou figé** | Bloque | +| Audit de dépendances `npm audit` | frontend | `--audit-level=high` | Bloque | +| **SAST `bandit`** | backend (`app`), ml (`enervision_ml`) | **MEDIUM et au-dessus** | Bloque | +| Quality gate SonarCloud | tout le dépôt | gate par défaut, couverture du **code neuf** | Bloque | +| Build `npm run build` | frontend | compilation | Bloque | + +Deux seuils portent une décision qu'il faut savoir défendre : + +- **`npm audit --audit-level=high`** et non `moderate` : une vulnérabilité modérée dans une + dépendance de développement ne doit pas immobiliser une livraison. Le corollaire est que les + `moderate` sont invisibles en CI, et qu'elles se regardent à la main. +- **Bandit bloque à partir de MEDIUM**, et une seconde passe sans seuil publie les constats LOW + sans bloquer. Sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace. Au + 21/09/2026, les deux modules sont à **zéro constat, tous niveaux confondus**, sur 5 904 lignes + analysées. + +## Le job d'intégration, et pourquoi il ne suffisait pas d'un `postgres` + +`backend.yml` monte un service `timescale/timescaledb-ha:pg17`, **la même image que +`docker-compose.yml`**, et non une image `postgres` nue. La première migration s'arrête +volontairement si l'extension TimescaleDB manque : un écart d'image entre la CI et le poste +rendrait ce job vert sur une base qui n'est pas la nôtre. + +Sur le poste, c'est `db/init/110-test-database.sql` qui pose l'extension. Ce fichier n'est pas +monté dans le service GitHub Actions, d'où l'étape `CREATE EXTENSION IF NOT EXISTS timescaledb` +avant `alembic upgrade head`. + +La couverture est **désactivée** sur ce job (`pytest -m integration --no-cov`) : il ne joue qu'une +partie de la suite, et son taux n'aurait aucun sens face au seuil de 85 %. + +## SonarCloud, et l'incident qui a immobilisé trois PR + +Le workflow `sonarqube.yml` exécute quatre jobs de préparation (`build-front`, `test-front`, +`build-back`, `test-back`) qui produisent chacun un rapport de couverture en artefact, puis un +cinquième job qui les télécharge et lance `SonarSource/sonarqube-scan-action@v8` avec le secret +`SONAR_TOKEN`. Le périmètre est décrit par `sonar-project.properties` à la racine. + +**L'incident, à raconter tel quel.** Les 18 et 19 septembre, trois PR (#103, #105, #107) sont +restées bloquées sur une quality gate rouge annonçant une couverture du code neuf à 0 %, alors que +la couverture globale du backend dépassait 87 %. Le diagnostic était **hors du code de ces PR** : +`sonar.test.inclusions` ne reconnaissait que les fichiers `test_*.py`, si bien que +`tests/api/acces.py`, `tests/factories.py` et les `__init__.py` du dossier de tests étaient +comptés comme **code de production non couvert**. Le motif `tests` sans joker ne désignait par +ailleurs que la racine. + +Deux commits ont corrigé la configuration (`9e6a5c0` classe tout `apps/backend/tests` comme test, +`af2b8cb` déclenche l'analyse quand `sonar-project.properties` change). La gate est verte sur +toutes les PR depuis. Ce qui compte pour la suite : **la cause a été traitée en configuration, pas +contournée** en désactivant la gate ou en excluant les fichiers gênants. + +## Dependabot + +`.github/dependabot.yml` déclare **cinq entrées hebdomadaires groupées** : `npm` sur +`/apps/frontend`, `uv` sur `/apps/backend`, `github-actions` sur `/`, et `docker` sur les deux +dossiers d'application. Les mises à jour arrivent en PR, donc elles traversent les mêmes gates que +n'importe quel changement : une montée de version qui casse les tests ne se merge pas. + +## Stratégie de branche et conventions + +| Règle | Détail | +|---|---| +| Préfixes de branche | `feat/`, `fix/`, `chore/`, `docs/`, `test/` | +| Messages de commit | Conventional Commits | +| Branche d'intégration | `dev` ; `main` est la branche par défaut du dépôt public | +| Revue | Toute PR passe par une revue écrite avant merge | +| ADR | Toute décision structurante porte son ADR dans la même PR | +| Vues d'architecture | Toute PR qui change un composant met à jour sa vue **dans la même PR** | + +## Secrets + +Un seul secret est consommé par la CI : **`SONAR_TOKEN`**, porté par les dépôts GitHub Actions. +Les identifiants de la base du job d'intégration sont des valeurs de test en clair dans le +workflow, ce qui est volontaire : elles ne protègent rien, la base est créée et détruite avec le +run. Aucune clé de déploiement n'existe encore, puisqu'il n'y a pas de déploiement (issue #22). + +## Ce qui manque, et pourquoi + +| Manque | Issue | Conséquence assumée | +|---|---|---| +| Job de déploiement (CD) | #21 | La chaîne s'arrête au merge. Rien ne part vers une machine | +| DAST (OWASP ZAP) | #41 | Aucune vérification sur l'application en fonctionnement, seulement sur le code et les dépendances | +| Tests end to end | #46 | Les parcours utilisateur ne sont pas vérifiés en CI | +| Tests de charge | #47 | Aucun garde-fou de performance | +| Scan d'image de conteneur | aucune | Les `Dockerfile` sont construits en local, pas analysés | + +## Reproduire la CI en local + +`make check` enchaîne formatage, analyse statique, typage et tests du backend, c'est à dire le job +`verification`. `make ml-check` fait la même chose pour le module ML. Les tests d'intégration +demandent une base : `make db-up` puis `uv run pytest -m integration`. + +Le SAST se rejoue à l'identique : `uvx bandit --recursive app --severity-level medium +--confidence-level medium` depuis `apps/backend`. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 78a82c9..b31b71a 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -15,10 +15,12 @@ contredisent, c'est l'ADR qui fait foi et la vue qui est en retard. | [31-contrat-authentification.md](31-contrat-authentification.md) | Ce que le frontend doit savoir pour coder la connexion | | [32-design-systeme-frontend.md](32-design-systeme-frontend.md) | Tokens CSS, composants `ev-*` partagés, règle anti-couleur-en-dur | | [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle | +| [50-cicd.md](50-cicd.md) | Workflows, gates bloquantes, SonarCloud, Dependabot, ce qui manque | -L'observabilité et la CI/CD n'ont pas de document propre : ce sont des sections des documents -ci-dessus, tant que `monitoring/` et `etl/airflow/` ne contiennent que des `.gitkeep`. Elles en -sortiront le jour où elles auront de la matière. Un fichier vide de plus n'aide personne. +La CI/CD a désormais son document : quatre workflows et quatorze jobs, c'est assez de matière pour +qu'une section de plus dans une autre vue devienne illisible. L'observabilité, elle, n'en a +toujours pas : `monitoring/` ne contient que des `.gitkeep`. Elle en sortira le jour où elle aura +de la matière. Un fichier vide de plus n'aide personne. La sécurité applicative, elle, a désormais de la matière : la vue consolidée reste dans [00-vue-ensemble.md](00-vue-ensemble.md), le détail dans [20-backend.md](20-backend.md), la From 62d81e901da58bbca2d343474965fc41fc00f253 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 13:41:17 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix(ci,docs):=20l=C3=A8ve=20les=20points=20?= =?UTF-8?q?de=20revue=20du=20SAST=20et=20de=20la=20vue=20CI/CD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ML-START.md affirmait que l'orchestration Airflow n'existait pas : `ml_score` tourne en `@hourly` depuis l'issue #115, seuls le mode `--csv` et un lancement local restent manuels. 50-cicd.md : Dependabot compte six entrées sur cinq écosystèmes et non cinq entrées, le filtre d'`airflow.yml` couvre aussi `apps/backend/` depuis le DAG `alertes`, et les issues #21 (job de déploiement) et #22 (secrets) sont distinguées au lieu d'être citées l'une pour l'autre. Le `continue-on-error` du second passage Bandit est nommé pour ce qu'il est : le job reste vert même avec un constat LOW. Bandit est épinglé à 1.9.4 dans les deux jobs `sast` : sans épingle, une nouvelle version passe la CI au rouge sans qu'une ligne du dépôt ait changé, et le rejeu à l'identique documenté n'existe pas. Le `cache-dependency-glob` part : `uvx` n'installe pas le projet, le verrou n'alimentait aucune clé de cache. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/backend.yml | 9 ++++---- .github/workflows/ml.yml | 9 ++++---- docs/ML-START.md | 7 +++--- docs/architecture/50-cicd.md | 40 +++++++++++++++++++++++------------ 4 files changed, 39 insertions(+), 26 deletions(-) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index f02eef8..34837cd 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -152,18 +152,17 @@ jobs: - name: Récupère le dépôt uses: actions/checkout@v4 + # Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc + # aucune clé de cache ; la seule roue téléchargée est celle de Bandit. - name: Installe uv uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - cache-dependency-glob: apps/backend/uv.lock # Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des # secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod. - name: Analyse le code livré (bloquant à partir de MEDIUM) - run: uvx bandit --recursive app --severity-level medium --confidence-level medium + run: uvx bandit==1.9.4 --recursive app --severity-level medium --confidence-level medium # Piège : sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace. - name: Rapport complet, tous niveaux continue-on-error: true - run: uvx bandit --recursive app + run: uvx bandit==1.9.4 --recursive app diff --git a/.github/workflows/ml.yml b/.github/workflows/ml.yml index 4700a97..00189e2 100644 --- a/.github/workflows/ml.yml +++ b/.github/workflows/ml.yml @@ -69,15 +69,14 @@ jobs: - name: Récupère le dépôt uses: actions/checkout@v4 + # Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc + # aucune clé de cache ; la seule roue téléchargée est celle de Bandit. - name: Installe uv uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - cache-dependency-glob: ml/uv.lock - name: Analyse le code livré (bloquant à partir de MEDIUM) - run: uvx bandit --recursive enervision_ml --severity-level medium --confidence-level medium + run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium - name: Rapport complet, tous niveaux continue-on-error: true - run: uvx bandit --recursive enervision_ml + run: uvx bandit==1.9.4 --recursive enervision_ml diff --git a/docs/ML-START.md b/docs/ML-START.md index e4d9f93..68518ac 100644 --- a/docs/ML-START.md +++ b/docs/ML-START.md @@ -161,9 +161,10 @@ flowchart LR `prediction` et leurs contraintes de cohérence, vérifiables en SQL. Le corollaire est qu'il n'y a **aucune prévision à la demande** : la fraîcheur d'une prévision est -celle du dernier run de scoring. Tant que l'orchestration Airflow n'existe pas (`etl/airflow/` est -vide), ce run est lancé à la main. C'est la dette la plus visible du module, et elle est portée -par les issues #44 et #45. +celle du dernier run de scoring. Ce run est ordonnancé par Airflow, DAG `ml_score` en `@hourly` +(issue #115) ; seuls le mode `--csv` et un lancement local restent manuels, tout comme +l'entraînement, dont le DAG `ml_train` n'a pas de planification. La dette qui subsiste est la +surveillance de dérive, portée par les issues #44 et #45. --- diff --git a/docs/architecture/50-cicd.md b/docs/architecture/50-cicd.md index 5b307c3..79313a3 100644 --- a/docs/architecture/50-cicd.md +++ b/docs/architecture/50-cicd.md @@ -60,12 +60,17 @@ flowchart TB Les cinq workflows se déclenchent sur `push` **et** sur `pull_request`, filtrés par **chemin** : `backend.yml` sur `apps/backend/**`, `frontend.yml` sur `apps/frontend/**`, `ml.yml` sur `ml/**`, -`airflow.yml` sur `etl/airflow/**` **et sur `ml/**`**, chacun incluant son propre fichier de -workflow dans le filtre pour qu'une modification du pipeline déclenche le pipeline. +`airflow.yml` sur `etl/airflow/**` **plus des chemins de `ml/` et de `apps/backend/`**, chacun +incluant son propre fichier de workflow dans le filtre pour qu'une modification du pipeline +déclenche le pipeline. -Le filtre d'`airflow.yml` mérite un mot : il inclut `ml/pyproject.toml`, `ml/uv.lock` et -`ml/enervision_ml/**` parce que l'image Airflow copie le code et les dépendances du module ML. -Une modification de `ml/` peut donc casser la construction de cette image, et le filtre le voit. +Le filtre d'`airflow.yml` mérite un mot : il inclut `ml/pyproject.toml`, `ml/uv.lock`, +`ml/enervision_ml/**`, `apps/backend/pyproject.toml`, `apps/backend/uv.lock` et +`apps/backend/app/**` parce que l'image Airflow copie le code et les dépendances des deux +modules : celles du ML pour `ml_train`/`ml_score`, celles du backend depuis que le DAG `alertes` +y exécute les commandes de détection ([ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)). +Une modification de l'un ou l'autre peut donc casser la construction de cette image, et le filtre +le voit. **Piège à connaître** : il n'y a **aucun filtre de branche**. Une branche de travail déclenche la CI complète à chaque push, et un merge vers n'importe quelle branche la déclenche aussi. C'est @@ -102,9 +107,14 @@ Deux seuils portent une décision qu'il faut savoir défendre : dépendance de développement ne doit pas immobiliser une livraison. Le corollaire est que les `moderate` sont invisibles en CI, et qu'elles se regardent à la main. - **Bandit bloque à partir de MEDIUM**, et une seconde passe sans seuil publie les constats LOW - sans bloquer. Sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace. Au + sans bloquer. Sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace. Le + revers à connaître : cette seconde étape porte `continue-on-error`, donc le job reste **vert** + même quand elle relève quelque chose ; un LOW ne se voit qu'en ouvrant le journal. Au 21/09/2026, les deux modules sont à **zéro constat, tous niveaux confondus**, sur 5 904 lignes analysées. +- **La version de Bandit est épinglée** (`uvx bandit==1.9.4`) dans les deux jobs. Sans épingle, + une nouvelle version passerait la CI au rouge sans qu'une seule ligne du dépôt ait changé, et + le rejeu à l'identique promis plus bas n'existerait pas. ## Le job d'intégration, et pourquoi il ne suffisait pas d'un `postgres` @@ -142,10 +152,11 @@ contournée** en désactivant la gate ou en excluant les fichiers gênants. ## Dependabot -`.github/dependabot.yml` déclare **cinq entrées hebdomadaires groupées** : `npm` sur -`/apps/frontend`, `uv` sur `/apps/backend`, `github-actions` sur `/`, et `docker` sur les deux -dossiers d'application. Les mises à jour arrivent en PR, donc elles traversent les mêmes gates que -n'importe quel changement : une montée de version qui casse les tests ne se merge pas. +`.github/dependabot.yml` déclare **six entrées hebdomadaires groupées, sur cinq écosystèmes** : +`npm` sur `/apps/frontend`, `uv` sur `/apps/backend`, `github-actions` sur `/`, `docker` sur les +deux dossiers d'application, et `docker-compose` sur `/`. Les mises à jour arrivent en PR, donc +elles traversent les mêmes gates que n'importe quel changement : une montée de version qui casse +les tests ne se merge pas. ## Stratégie de branche et conventions @@ -163,7 +174,9 @@ n'importe quel changement : une montée de version qui casse les tests ne se mer Un seul secret est consommé par la CI : **`SONAR_TOKEN`**, porté par les dépôts GitHub Actions. Les identifiants de la base du job d'intégration sont des valeurs de test en clair dans le workflow, ce qui est volontaire : elles ne protègent rien, la base est créée et détruite avec le -run. Aucune clé de déploiement n'existe encore, puisqu'il n'y a pas de déploiement (issue #22). +run. Aucune clé de déploiement n'existe encore, puisqu'il n'y a pas de déploiement : le job de +déploiement est porté par l'issue #21, les secrets qu'il consommera et leur injection par +l'issue #22. ## Ce qui manque, et pourquoi @@ -181,5 +194,6 @@ run. Aucune clé de déploiement n'existe encore, puisqu'il n'y a pas de déploi `verification`. `make ml-check` fait la même chose pour le module ML. Les tests d'intégration demandent une base : `make db-up` puis `uv run pytest -m integration`. -Le SAST se rejoue à l'identique : `uvx bandit --recursive app --severity-level medium ---confidence-level medium` depuis `apps/backend`. +Le SAST se rejoue à l'identique : `uvx bandit==1.9.4 --recursive app --severity-level medium +--confidence-level medium` depuis `apps/backend`, et la même commande sur `enervision_ml` depuis +`ml`.