Merge remote-tracking branch 'origin/dev' into feat/reconciliation-dag

This commit is contained in:
Dorian
2026-09-23 14:55:19 +02:00
96 changed files with 5802 additions and 752 deletions
+13 -10
View File
@@ -51,8 +51,8 @@ flowchart TB
api["API FastAPI<br/>apps/backend"]
db[("PostgreSQL 17<br/>TimescaleDB")]
airflow["Airflow<br/>etl/airflow"]
prom["Prometheus"]
grafana["Grafana"]
prom["Prometheus<br/>profil monitoring"]
grafana["Grafana<br/>profil monitoring"]
end
navigateur --> proxy
@@ -61,9 +61,9 @@ flowchart TB
front -.-> api
api --> db
airflow --> db
prom -.-> api
grafana -.-> db
grafana -.-> prom
prom --> api
grafana --> db
grafana --> prom
```
Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API, mais un
@@ -82,8 +82,11 @@ CSV plutôt que de laisser les deux sources dupliquer silencieusement un même i
pipeline ML déduplique par construction (`DISTINCT ON`, source `csv` préférée) au cas où un
recouvrement se produirait malgré tout — voir [40-data.md](40-data.md).
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
collecteur ne vient le lire.
Les liens de la supervision sont en trait plein depuis le 23/09 (issue #26) : Prometheus scrute
`/metrics` avec un jeton, Grafana lit Prometheus et, par un rôle en lecture seule, les tables
métier de TimescaleDB. Ils tournent en prod sous le profil Compose `monitoring`, à la demande
ailleurs ([ADR 0016](../adr/0016-supervision-en-profil-compose.md),
[60-observabilite.md](60-observabilite.md)).
## État de la stack
@@ -94,9 +97,9 @@ collecteur ne vient le lire.
| 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`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive livrée côté backend (`app.monitoring.drift`, table `drift_report`, `GET /monitoring/drift`, DAG `derive`), voir [ADR 0013](../adr/0013-surveillance-de-derive-dans-le-backend.md) |
| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Provisionnement de la VM par Terraform, qui installe Docker, prépare les deux environnements et enregistre le runner, jamais appliqué ([ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Fait` | Profil Compose `monitoring`, actif en prod : Prometheus et trois exporteurs (PostgreSQL, hôte, conteneurs), neuf règles d'alerte testées par `promtool`, Alertmanager vers Mailpit, trois tableaux de bord Grafana provisionnés. Voir [60-observabilite.md](60-observabilite.md) |
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver et scheduler avec LocalExecutor via Docker Compose, sur une base PostgreSQL dédiée. Six DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes`, `historical_import`, `mock_api_import` et `derive` (quotidien, surveillance de dérive). L'import historique reste manuel et l'import API Mock s'exécute chaque heure. Réconciliation entre les deux sources (issue #15) : trou temporel accepté, recouvrement refusé à l'ingestion et dédupliqué en défense côté ML, voir [40-data.md](40-data.md). |
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 7 workflows, 19 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, intégrité des DAGs Airflow, formatage et validation du Terraform. Déploiement continu vers la VM ENI écrit par `deploy.yml`, `dev` en recette et `main` en production après approbation ([ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)), mais jamais exécuté : la machine n'est pas provisionnée et le runner n'y est pas enregistré. Détail dans [50-cicd.md](50-cicd.md) |
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | Un orchestrateur `ci.yml` qui n'appelle que les composants modifiés ([ADR 0014](../adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md)) : 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, intégrité des DAGs Airflow, Terraform, Compose et supervision, parcours Playwright et tirs k6 contre la stack de prod ([ADR 0015](../adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md)). Déploiement vers la VM ENI par `deploy.yml`, appelé une fois « CI ok » vert, `dev` en recette et `main` en production après approbation ([ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)), mais jamais exécuté : le runner n'est pas enregistré sur la machine. Détail dans [50-cicd.md](50-cicd.md) |
## Flux bout en bout
@@ -155,7 +158,7 @@ consolidée.
- **Caviardage des journaux** : jetons, empreintes Argon2, mots de passe et cookies sont
expurgés avant écriture.
- **Documentation interactive fermée** en préproduction et en production, `/metrics` derrière un
jeton facultatif, sonde de disponibilité qui ne publie plus la version de TimescaleDB.
jeton, exigé dès que la supervision tourne, sonde de disponibilité qui ne publie plus la version de TimescaleDB.
- **CI backend bloquante** : format, lint, typage strict et tests avec seuil de couverture.
- **Conteneur backend non-root**, déclaré dans `apps/backend/Dockerfile`.
- **Terminaison TLS au frontal** : un reverse proxy Nginx est le seul service publié, il redirige
+38 -21
View File
@@ -37,6 +37,8 @@ flowchart TB
|---|---|---|
| `db` | `timescale/timescaledb-ha:pg17` | Publié sur **5433** côté hôte, 5432 souvent déjà pris. `healthcheck` `pg_isready`, 12 tentatives, `start_period` 40s |
| `backend` | Construite depuis `apps/backend` | `depends_on: db, condition: service_healthy`. **N'embarque pas le source** : toute modification impose `docker compose up -d --build backend` |
| `prometheus`, `alertmanager`, `grafana`, exporteurs | Images épinglées par tag | Profil `monitoring`, jamais démarrés par `make dev`. `make monitoring-up` les lance en `--no-deps`. Voir [60-observabilite.md](60-observabilite.md) |
| `k6` | `grafana/k6` | Profil `load`, lancé par `make load-*` le temps d'un tir, sur le réseau du projet. Voir [`tests/load/README.md`](../../tests/load/README.md) |
**La boucle de développement n'utilise pas le service `backend`.** `make db-up` puis `make dev` :
seule la base tourne en conteneur, l'API et `ng serve` tournent sur le poste avec le rechargement
@@ -201,11 +203,12 @@ flowchart LR
navigateur["Navigateur"]
subgraph machine["Machine on-premise"]
proxy["service proxy<br/>nginx:1.28-alpine<br/>:80 et :443"]
proxy["service proxy<br/>nginx:1.31-alpine<br/>:80 et :443"]
front["service frontend<br/>nginx statique :3000"]
api["service backend<br/>uvicorn :8000"]
db[("service db<br/>:5432")]
mail["service mailpit"]
sup["profil monitoring<br/>Prometheus, Alertmanager, Grafana"]
end
navigateur -->|"HTTPS"| proxy
@@ -213,6 +216,9 @@ flowchart LR
proxy -->|"/api/"| api
api --> db
api --> mail
sup -->|"/metrics, jeton"| api
sup -->|"rôle supervision, lecture seule"| db
sup -->|"alertes par courriel"| mail
```
Le proxy est **le seul service à publier des ports** sur le réseau. Backend et frontend ne sont
@@ -227,27 +233,36 @@ Deux conséquences se propagent jusqu'à l'application, et elles ne se devinent
- `APP_TRUST_PROXY_HEADERS` passe à vrai en même temps, sinon la limitation de débit par IP
compte sur l'IP du proxy et devient globale.
### Deux environnements sur la même machine
### Trois environnements sur la même machine
Statut : `En cours`, la machine n'étant pas encore provisionnée. Décision et motifs dans
l'[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md).
La VM `eadl-2025-nantes-g3` portera la recette et la production, chacune dans son clone du dépôt,
son `.env` et son projet Compose. Le nom de projet préfixe volumes, réseau et conteneurs : rien
n'est partagé. `scripts/provision-host.sh` prépare les deux dossiers, génère les secrets et les
certificats, et ne démarre rien.
Statut : `En cours`. Décision et motifs dans
l'[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md), étendue à un troisième
environnement par l'[ADR 0017](../adr/0017-environnement-dev-a-la-demande.md) ; noms,
certificats et frontal sans port dans l'[ADR 0018](../adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md).
La VM `eadl-2025-nantes-g3` porte le développement, la recette et la production, chacun dans son
clone du dépôt, son `.env` et son projet Compose. Le nom de projet préfixe volumes, réseau et
conteneurs : rien n'est partagé. `scripts/provision-host.sh` prépare les trois dossiers, génère
les secrets et les certificats, et ne démarre rien.
| | Recette | Production |
|---|---|---|
| Branche, environnement GitHub | `dev`, `rec` | `main`, `prod` |
| Dossier, projet Compose | `/srv/enervision/rec`, `enervision-rec` | `/srv/enervision/prod`, `enervision-prod` |
| URL | `https://rec.enervision.local:8443` | `https://enervision.local` |
| Proxy HTTP, HTTPS | `127.0.0.1:8081`, `8443` | `80`, `443` |
| PostgreSQL, Mailpit, Airflow, sur `127.0.0.1` | `5434`, `8026`, `8082` | `5433`, `8025`, `8080` |
| | Développement | Recette | Production |
|---|---|---|---|
| Branche, environnement GitHub | toute branche lancée à la main, `dev` | `dev`, `rec` | `main`, `prod` |
| Dossier, projet Compose | `/srv/enervision/dev`, `enervision-dev` | `/srv/enervision/rec`, `enervision-rec` | `/srv/enervision/prod`, `enervision-prod` |
| URL | `https://dev.enervision-g3.dynv6.net` | `https://rec.enervision-g3.dynv6.net` | `https://prod.enervision-g3.dynv6.net` |
| Proxy HTTP, HTTPS, PROXY protocol, sur `127.0.0.1` | `8083`, `9443`, `9444` | `8081`, `8443`, `8444` | `10080`, `10443`, `10444` |
| PostgreSQL, Mailpit, Airflow, sur `127.0.0.1` | `5435`, `8027`, `8084` | `5434`, `8026`, `8082` | `5433`, `8025`, `8080` |
| Supervision (profil `monitoring`) | à la demande, `make monitoring-up` | à la demande, `make monitoring-up` | active, `COMPOSE_PROFILES=monitoring` |
| Grafana, Prometheus, Alertmanager, sur `127.0.0.1` | `3003`, `9092`, `9095` | `3002`, `9091`, `9094` | `3001`, `9090`, `9093` |
Les deux noms d'hôte visent la même IP, à déclarer dans le `/etc/hosts` des postes. Deux noms
distincts sont nécessaires : le cookie `__Secure-ev_refresh` est posé par hôte, pas par port.
La redirection HTTP de la recette est ramenée sur la boucle locale parce que la configuration
Nginx renvoie vers `https://$host` sans port, c'est-à-dire vers la production.
Les trois noms sont publics chez dynv6 et visent l'IP privée de la VM : rien à déclarer sur
les postes du réseau de l'école, et rien n'est joignable hors de ce réseau. Trois noms distincts
sont nécessaires : le cookie `__Secure-ev_refresh` est posé par hôte, pas par port.
Aucune stack ne publie hors de la boucle locale. Le frontal `infra/front`, sur le réseau de
l'hôte, écoute 80 et 443 : il redirige le premier, et aiguille le second d'après le nom demandé
(SNI) vers l'écouteur PROXY protocol de la stack visée, sans déchiffrer le TLS. Chaque stack
garde son certificat Let's Encrypt, obtenu par défi DNS-01 (`make tls-dns01`) et renouvelé à
chaque déploiement ainsi que chaque nuit par `/etc/cron.d/enervision-tls`.
Le déploiement est décrit dans [50-cicd.md](50-cicd.md) : un runner GitHub Actions installé sur
la VM aligne le dossier sur la branche poussée et lance `make stack-up`.
@@ -267,7 +282,7 @@ sequenceDiagram
TF->>VM: SSH, get.docker.com puis docker compose version
TF->>VM: copie et exécute scripts/provision-host.sh
VM->>VM: deux clones, deux .env, deux certificats
VM->>VM: trois clones, trois .env, trois certificats
TF->>VM: installe actions-runner, config.sh, svc.sh
VM->>GH: le runner s'enregistre avec le label eni-g3
```
@@ -353,11 +368,13 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
| API | `8000` | Identique en conteneur et hors conteneur |
| Frontend, `ng serve` | `4200` | Boucle de développement. Valeur par défaut d'`APP_CORS_ORIGINS` |
| Frontend en conteneur | `3000` | Ce qu'écoute le nginx de l'image, en conteneur comme côté hôte |
| Reverse proxy | `80` et `443` | Les seuls ports publiés par `docker-compose.prod.yml`, via `PROXY_HTTP_PORT` et `PROXY_HTTPS_PORT`. 80 ne sert que la redirection et le défi ACME. La recette publie `8443` et `127.0.0.1:8081` |
| Reverse proxy | `80` et `443`, plus `4443` | Les seuls ports publiés par `docker-compose.prod.yml`, via `PROXY_HTTP_PORT`, `PROXY_HTTPS_PORT` et `PROXY_FRONT_PORT`. 80 ne sert que la redirection et le défi ACME ; 4443 n'accepte que le PROXY protocol du frontal. Sur la VM, tous sur `127.0.0.1` |
| Frontal SNI de la VM | `80` et `443` de l'hôte | `infra/front`, seul composant exposé sur le réseau de l'école (ADR 0018) |
| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable |
| Base applicative | `enervision` | Variable `POSTGRES_DB` |
| Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` |
| Base de métadonnées Airflow | `airflow` | Créée par `db/init/120-airflow-database.sql`, même conteneur `db` |
| Grafana, Prometheus, Alertmanager | `3001`, `9090`, `9093` | Sur `127.0.0.1` seulement, profil `monitoring`. `GRAFANA_PORT`, `PROMETHEUS_PORT`, `ALERTMANAGER_PORT`. 3000 est pris par le frontend |
| API server Airflow | `8080` | `make airflow-up`. Api-server, scheduler et dag-processor ne publient que ce port ; les tâches (`LocalExecutor`) tournent côté scheduler, sans port propre |
## Le trou vers k3s
+12 -2
View File
@@ -103,7 +103,7 @@ démarre ne prouve rien sur la base, la première connexion réelle a lieu au pr
| `APP_LOGIN_MAX_FAILURES_PER_IDENTIFIER` | `50` | Signature d'une attaque distribuée |
| `APP_TRUST_PROXY_HEADERS` | `false` | À vrai derrière un proxy, sinon le compteur par IP devient global |
| `APP_EXPOSE_API_DOCS` | déduit | Faux en `staging` et `prod` si non renseigné |
| `APP_METRICS_TOKEN` | absent | Si présent, `/metrics` exige `Authorization: Bearer` |
| `APP_METRICS_TOKEN` | absent | Si présent et non vide, `/metrics` exige `Authorization: Bearer`. Vide vaut absent |
Cinq gardes refusent de démarrer plutôt que de laisser passer une erreur silencieuse :
secret de moins de 32 caractères ou laissé à sa valeur d'exemple, `debug` en `staging` ou
@@ -440,7 +440,17 @@ Le reste, par ordre de surface :
- Journalisation par `dictConfig` : format console en développement, JSON dès `APP_ENV=prod`.
`sqlalchemy.engine` est forcé à `WARNING` pour ne pas noyer les journaux.
- `/metrics` au format Prometheus. **Aucun collecteur ne le lit** : `monitoring/` est vide.
- `/metrics` au format Prometheus (`prometheus-fastapi-instrumentator`), scruté toutes les 15 s
par Prometheus sous le profil `monitoring` ([60-observabilite.md](60-observabilite.md)).
- **Séries publiées.** `http_requests_total` par route, méthode et classe de statut, et
`http_request_duration_seconds` par route, avec des seaux de 50 ms à 2,5 s autour du seuil
de charge de 500 ms (ADR 0015). Aussi `http_request_duration_highr_seconds`, fin mais sans
libellé de route, et les métriques du processus.
- **Exclusions.** Les sondes `/health/*` et `/metrics` lui-même sont exclus : la sonde Docker
de 30 s fausserait débit et latences.
- **Un registre par application** (`_registre_de_metriques()` dans `main.py`). Le registre
global de `prometheus_client` n'accepte chaque métrique qu'une fois : toute application créée
après la première, dans les tests notamment, ne mesurait rien.
## Tests
+141 -107
View File
@@ -10,8 +10,9 @@ vérifié, ce qui bloque, et ce qui ne l'est pas.
Le **D** de CI/CD est écrit depuis le 21/09 : `deploy.yml` déploie `dev` en recette et `main` en
production sur la VM de l'école, par un runner auto-hébergé (issue #21,
[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)). Il n'a encore rien
déployé : la machine n'est pas provisionnée et le runner n'y est pas enregistré. Statut à
[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)). Depuis le 23/09, il ne part
plus qu'une fois la CI du commit verte ([ADR 0014](../adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md)).
Il n'a encore rien déployé : le runner n'est pas enregistré sur la machine. Statut à
basculer sur `Fait` au premier déploiement vert. Sa limite, nommée ici plutôt que découverte en
soutenance : les images sont construites sur la machine à chaque déploiement, aucun artefact
n'est publié puis promu d'un environnement à l'autre.
@@ -24,95 +25,67 @@ GitHub Actions déploie ; aucun des deux ne fait le travail de l'autre.
## Vue d'ensemble
`ci.yml` est le seul point d'entrée des PR et des push sur `dev` et `main`. Il appelle les
workflows de composant, qui n'ont plus de déclencheur propre, selon les fichiers modifiés
([ADR 0014](../adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md)).
```mermaid
flowchart TB
push["push ou pull_request"]
evt["pull_request, ou push sur dev et main"]
changes["changes<br/>paths-filter : composants touchés"]
subgraph back["Backend · .github/workflows/backend.yml"]
bv["verification<br/>ruff, mypy, pytest --cov-fail-under=85"]
bi["integration<br/>TimescaleDB réel + alembic upgrade head"]
bd["security-audit<br/>uv export | pip-audit"]
bs["sast<br/>bandit"]
subgraph comp["Workflows de composant (workflow_call)"]
back["backend.yml<br/>lint, typage, tests ≥ 85 %, intégration, pip-audit, bandit"]
front["frontend.yml<br/>build et tests, npm audit"]
mlw["ml.yml<br/>lint, typage, tests, ML ↔ DB, chaîne ML → API, bandit"]
afw["airflow.yml<br/>intégrité des DAGs, image"]
infw["infra.yml<br/>Terraform, Compose et supervision, actionlint"]
e2e["e2e.yml<br/>stack de prod, Playwright, k6 smoke et limitation"]
end
subgraph front["Frontend · frontend.yml"]
fb["build<br/>npm ci, npm run build"]
ft["test<br/>couverture lcov"]
fd["security-audit<br/>npm audit --audit-level=high"]
end
sonar["sonar<br/>reprend les couvertures du run"]
ok["CI ok<br/>seul check à exiger"]
dep["deploy.yml<br/>runner eni-g3, rec ou prod"]
subgraph mlw["ML · ml.yml"]
mv["verification<br/>ruff, mypy, pytest"]
ms["sast<br/>bandit"]
end
evt --> changes --> back & front & mlw & afw & infw & e2e
back & front & mlw --> sonar
back & front & mlw & afw & infw & e2e & sonar --> ok
ok -->|"push sur dev ou main"| dep
subgraph afw["Airflow · airflow.yml"]
av["verification<br/>ruff, intégrité des DAGs"]
ab["image<br/>construction de l'image"]
end
subgraph infw["Infra · infra.yml"]
it["terraform<br/>fmt -check, init et validate par racine"]
end
subgraph sq["SonarQube · sonarqube.yml"]
sb1["build-front / test-front"]
sb2["build-back / test-back"]
sb3["test-ml"]
sscan["sonarqube<br/>quality gate SonarCloud"]
end
push --> bv & bi & bd & bs
push --> fb --> ft
push --> fd
push --> mv & ms
push --> av & ab
push --> it
push --> sb1 & sb2 & sb3 --> sscan
subgraph cd["Déploiement · deploy.yml"]
dep["deploy<br/>runner eni-g3, environnement rec ou prod"]
end
push -->|"push sur dev ou main"| dep
planifie["chaque lundi 3h UTC,<br/>ou à la main"]
subgraph dastw["DAST · dast.yml"]
zscan["zap<br/>seed + scan actif OWASP ZAP"]
end
planifie --> zscan
push -->|"PR sur dast.yml<br/>ou dast-token.sh"| zscan
planifie["chaque lundi 3h UTC, à la main,<br/>ou PR sur ses fichiers"]
dast["dast.yml<br/>seed + scan actif OWASP ZAP"]
planifie --> dast
```
## Déclenchement
Les six workflows hébergés par GitHub qui vérifient le code 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/**`, `infra.yml` sur `infra/terraform/**`, `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.
**Sur une PR**, le job `changes` lit la liste des fichiers modifiés par l'API GitHub
(`dorny/paths-filter`, épinglé sur un SHA) et chaque composant n'est appelé que si son filtre
vaut vrai. Une PR de documentation ne joue que `changes` et `CI ok`. Modifier `ci.yml` rejoue
tout.
`dast.yml` s'en écarte volontairement (détail dans sa propre section plus bas) : aucun
déclenchement sur `push`, seulement `workflow_dispatch`, une planification hebdomadaire, et
`pull_request` restreint à ses deux seuls fichiers. Un scan actif est trop long pour tourner à
chaque commit.
**Sur un push vers `dev` ou `main`**, tous les filtres valent vrai. C'est le moment où l'analyse
Sonar doit couvrir tout le dépôt, et paths-filter comparerait sinon le push à sa base de fusion
avec `main`, en retard de 80 commits. Une branche de travail ne déclenche plus rien par un push :
la CI part de sa PR, une seule fois par commit.
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.
Deux filtres écoutent plus que leur dossier, parce que ce qu'ils testent dépend d'autres modules :
**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.
- `airflow` inclut `ml/pyproject.toml`, `ml/uv.lock`, `ml/enervision_ml/**`,
`apps/backend/pyproject.toml`, `apps/backend/uv.lock` et `apps/backend/app/**`. L'image
Airflow copie le code et les dépendances des deux modules
([ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)), et une modification de l'un
ou de l'autre peut casser sa construction.
- `e2e` inclut le frontend, l'API, ses migrations et son Dockerfile, le proxy, les fichiers
Compose, `db/`, `tests/` et les scripts qu'il appelle : tout ce qui change un parcours.
`backend.yml`, `ml.yml` et `airflow.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.
`dast.yml` reste hors de l'orchestrateur : un scan actif est trop long pour chaque PR. Il se
lance à la main, chaque lundi, et sur une PR qui modifie le scan, son jeu de données ou ses
comptes.
Le groupe de concurrence de `ci.yml` annule le run d'une PR devenu obsolète par un push plus
récent. Pour un push sur `dev` ou `main`, le groupe est le SHA et rien n'est annulé : un run
coupé en plein `make stack-up` laisserait la stack à moitié redémarrée.
**Piège de version** : `etl/airflow` tourne en **Python 3.12** et non 3.14 : c'est l'interpréteur
de l'image `apache/airflow:3.3.2-python3.12` retenue, et les tests d'intégrité doivent tourner sur
@@ -121,35 +94,52 @@ environnement.
## Déploiement
`deploy.yml` est le huitième workflow (`backend`, `frontend`, `ml`, `infra`, `airflow`,
`sonarqube`, `dast`, plus lui-même), et le seul qui ne tourne pas chez GitHub : il s'exécute sur
un runner auto-hébergé installé sur la VM ENI, label `eni-g3`, parce que les runners hébergés ne
joignent pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port
entrant n'est ouvert.
`deploy.yml` est le seul workflow qui ne tourne pas chez GitHub : il s'exécute sur un runner
auto-hébergé installé sur la VM ENI, label `eni-g3`, parce que les runners hébergés ne joignent
pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port entrant
n'est ouvert. Il n'a pas de déclencheur propre en dehors de `workflow_dispatch` : c'est le job
`deploy` de `ci.yml` qui l'appelle, sur un push, une fois « CI ok » vert.
| Événement | Environnement GitHub | Dossier sur la VM | Garde |
|---|---|---|---|
| `push` sur `dev` | `rec` | `/srv/enervision/rec` | aucune : la recette suit `dev` |
| `push` sur `main` | `prod` | `/srv/enervision/prod` | approbation d'un relecteur dans l'environnement `prod`, branche `main` seule autorisée |
| `push` sur `dev`, « CI ok » vert | `rec` | `/srv/enervision/rec` | aucune de plus : la recette suit `dev` |
| `push` sur `main`, « CI ok » vert | `prod` | `/srv/enervision/prod` | approbation d'un relecteur dans l'environnement `prod`, branche `main` seule autorisée |
| `workflow_dispatch` sur toute autre branche | `dev` | `/srv/enervision/dev` | droit d'écriture sur le dépôt, seul à pouvoir lancer un workflow ([ADR 0017](../adr/0017-environnement-dev-a-la-demande.md)) |
Le job aligne le clone sur la branche (`fetch`, `checkout`, `reset --hard`), lance
Le job aligne le clone sur **le commit testé** (`fetch`, `checkout`, `reset --hard $GITHUB_SHA`),
et non sur la pointe de branche du moment, qui a pu avancer pendant la CI. Il lance
`make stack-up`, qui reconstruit les images, redémarre les conteneurs puis applique les
migrations Alembic dans le conteneur backend, et attend jusqu'à trois minutes que
`/api/v1/health/ready` réponde derrière le proxy. Cette sonde ne vérifie que la connexion à la
base et la présence de TimescaleDB : sans la migration, le déploiement serait vert sur une base
sans schéma, et c'est pourquoi `make stack-up` la porte. Un groupe de concurrence par branche,
sans annulation, empêche deux déploiements simultanés du même environnement.
sans schéma, et c'est pourquoi `make stack-up` la porte.
Les CI de deux push rapprochés peuvent finir dans le désordre. Deux gardes empêchent un
environnement de reculer ou de sauter un commit :
- un commit qui **précède** celui déjà déployé depuis la même branche est ignoré, avec une
annotation dans le run. Dans `dev`, une autre branche que celle en place est toujours déployée ;
- les déploiements d'un même environnement passent un par un sous un verrou `flock` posé dans le
clone de la VM, y compris deux branches lancées coup sur coup dans `dev`. Un groupe
`concurrency` ne convenait pas : GitHub n'y garde qu'un job en attente, et un troisième arrivé
l'annule sans erreur.
Le job ne fait pas de `actions/checkout` dans son espace de travail, et c'est voulu : le dossier
de l'environnement est stable, hors du runner, parce que `.env`, certificats et volumes doivent
survivre d'un déploiement à l'autre.
**Piège à connaître.** Un runner auto-hébergé sur un dépôt public exécute ce qu'un workflow lui
envoie, et une PR de fork peut réécrire un workflow. Trois parades, et les trois sont
nécessaires : `deploy.yml` ne se déclenche jamais sur `pull_request` ; le runner tourne sous un
utilisateur dédié membre du groupe `docker`, jamais root ; le dépôt doit exiger une approbation
pour les workflows des PR externes (Settings, Actions, « Require approval for all outside
collaborators »), ce qui reste à activer. Les workflows de CI restent sur `ubuntu-latest`.
envoie, et une PR de fork peut ajouter son propre workflow qui vise le label `eni-g3`. L'absence
de `pull_request` dans `deploy.yml` ne suffit donc pas. Ce qui protège vraiment le runner :
- le dépôt exige l'approbation des workflows de tous les contributeurs externes (Settings,
Actions, « Require approval for all external contributors ») ;
- les environnements `rec` et `prod` n'acceptent que leur branche (`dev`, `main` avec un
relecteur), ce qui bloque un job qui les déclare avant qu'il atteigne le runner ;
- le runner tourne sous un utilisateur dédié membre du groupe `docker`, jamais root.
Les deux réglages de dépôt restent à activer par l'administratrice. Tous les autres workflows
restent sur `ubuntu-latest`.
Cet utilisateur dédié doit posséder `/srv/enervision` : sinon git refuse les deux clones pour
propriété douteuse et le `.env` en `600` lui échappe. `PROPRIETAIRE=<utilisateur du runner>`
@@ -157,7 +147,7 @@ passé à `scripts/provision-host.sh` fixe ce propriétaire.
La machine se prépare avec `scripts/provision-host.sh`, qui vérifie Docker et Compose 2.24.4 ou
plus, clone les deux branches, génère les secrets de chaque `.env` et les certificats
auto-signés, et ne démarre rien. Le détail des deux environnements, ports et noms d'hôte, est
auto-signés, et ne démarre rien. Le détail des trois environnements, ports et noms d'hôte, est
dans [10-infra.md](10-infra.md).
## Ce qui bloque un merge
@@ -179,6 +169,14 @@ dans [10-infra.md](10-infra.md).
| Intégrité des DAGs | airflow | chargement des DAGs sans erreur d'import | Bloque |
| Construction de l'image Airflow | airflow | `docker build` de `etl/airflow/Dockerfile` | Bloque |
| Formatage et validité Terraform | infra | `fmt -check -recursive`, puis `init` et `validate` par racine | Bloque |
| Verrous uv à jour | backend, ml, airflow | `uv sync --locked` : un `uv.lock` qui ne suit plus `pyproject.toml` échoue | Bloque |
| Fichiers Compose | infra | `docker compose config` sur la stack de dev et la stack déployée, tous profils | Bloque |
| Supervision | infra | `promtool check config`, `promtool test rules` (un cas par alerte), `amtool check-config`, JSON des tableaux | Bloque |
| Workflows | infra | `actionlint`, shellcheck compris sur les blocs `run:` | Bloque |
| Parcours de bout en bout | e2e | 18 parcours Playwright contre la stack de prod (proxy TLS) | Bloque |
| Tir k6 de fumée | e2e | p95 < 500 ms et p99 < 1 s sur les lectures, moins de 1 % d'échecs | Bloque |
| Limitation de débit | e2e | k6 par le proxy : des 429 au-delà de 20 req/s, aucune 5xx | Bloque |
| **CI ok** | ci.yml | aucun job en `failure` ou `cancelled` | Bloque, **seul check à exiger** |
Deux seuils portent une décision qu'il faut savoir défendre :
@@ -218,7 +216,7 @@ qu'évite déjà le choix de l'image `timescaledb-ha` plutôt qu'un `postgres` n
donc les deux environnements uv, applique `alembic upgrade head`, puis joue `-m integration` côté
`ml/` et `-m chaine` côté backend.
Conséquence sur le déclenchement : les `paths` de `ml.yml` incluent `apps/backend/alembic/**` et
Conséquence sur le déclenchement : le filtre `ml` de `ci.yml` inclut `apps/backend/alembic/**` et
`apps/backend/app/models/**`. Sans eux, une migration qui renomme une colonne de `reading` ne
déclencherait pas ce job, le SQL brut du pipeline dériverait du schéma, et **rien ne casserait
avant la production**. Le prix est qu'une PR touchant seulement une migration lance aussi le lint
@@ -233,10 +231,18 @@ poste où `ml/` n'est pas installé.
## SonarCloud, et l'incident qui a immobilisé trois PR
Le workflow `sonarqube.yml` exécute cinq jobs de préparation (`build-front`, `test-front`,
`build-back`, `test-back`, `test-ml`) dont les tests produisent chacun un rapport de couverture en
artefact, puis un dernier 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.
Le job `sonar` de `ci.yml` ne reconstruit ni ne reteste rien. Les jobs `verification` de
`backend.yml`, `ml.yml` et `frontend.yml` versent leur rapport de couverture en artefact, et
`sonar` les télécharge dans le même run, un par un (backend et ML nomment tous deux le leur
`coverage.xml`), puis lance `SonarSource/sonarqube-scan-action`, épinglée sur un SHA, avec le
secret `SONAR_TOKEN`. Il ne tourne ni pour Dependabot ni pour une PR de fork, qui n'ont pas ce
secret. Le périmètre est décrit par `sonar-project.properties` à la racine, seul fichier de
configuration Sonar du dépôt.
Jusqu'au 23/09, un `sonarqube.yml` à part rejouait build et tests des trois modules pour produire
ces rapports, en double exact des workflows qui le faisaient déjà. Les exclusions de
`sonar-project.properties` sont aussi passées en globs (`**/tests/**`, `**/alembic/**`) : un
motif sans `**` ne vise que la racine du dépôt.
Le périmètre couvre `apps/frontend`, `apps/backend`, `ml/` et `etl/airflow` (les deux derniers
ajoutés après coup : ils n'étaient pas analysés, une PR qui ne touchait qu'eux ne lançait pas
@@ -261,9 +267,10 @@ contournée** en désactivant la gate ou en excluant les fichiers gênants.
## Dependabot
`.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
`.github/dependabot.yml` déclare **sept entrées hebdomadaires, sur cinq écosystèmes** : `npm`
sur `/apps/frontend` et sur `/tests/e2e`, `uv` sur `/apps/backend`, `github-actions` sur `/`,
`docker` sur les deux dossiers d'application, et `docker-compose` sur `/`, qui suit aussi les
images de supervision et de k6. 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.
@@ -300,10 +307,10 @@ UTC, et sur une PR qui modifie le scan lui-même. Pas à chaque PR : un scan act
minutes.
Le job démarre sur le runner la base (même image TimescaleDB que `docker-compose.yml`, base
jetable), applique les migrations, y sème un site et deux relevés (`db/seeds/` est vide, pas
encore d'outillage de jeu de données pour la CI ; sans données, `GET /sites` rend `[]`, chaque
`/{site_id}` rend 404, et le scan actif ne frappe que des gestionnaires d'erreur), démarre le
backend, puis `scripts/dast-token.sh` crée un compte **`lecteur`** et rend son jeton.
jetable), applique les migrations, y sème `db/seeds/demo.sql` (sans données, `GET /sites` rend
`[]`, chaque `/{site_id}` rend 404, et le scan actif ne frappe que des gestionnaires d'erreur),
démarre le backend, puis `scripts/dast-token.sh` s'appuie sur `scripts/comptes-test.sh` pour
créer les comptes et rend le jeton du **`lecteur`**. Le jeu et les comptes sont ceux de l'e2e.
ZAP charge le contrat `/openapi.json` depuis un fichier (`zap-api-scan.py -f openapi -t
/zap/wrk/openapi.json`) et en importe les 26 opérations **quel que soit le jeton** : c'est le
@@ -392,15 +399,34 @@ pas de TLS, pas de reverse proxy). Il remontera des alertes qui n'existent pas d
(HSTS absent...) et ne dit **rien** des en-têtes ni du TLS que le proxy pose en production. Un
second passage sur la stack complète reste à faire.
## Tests de bout en bout et de charge
Le workflow `e2e.yml` démarre la stack telle qu'elle est déployée, derrière le proxy TLS, sur
`https://localhost` ([ADR 0015](../adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md)) :
1. Il construit et démarre `db`, `mailpit`, `backend`, `frontend` et `proxy` avec
`docker-compose.prod.yml`, sans Airflow. C'est le seul job qui construit les images backend et
frontend avant un déploiement.
2. Il migre la base, pose le rôle `supervision`, sème `db/seeds/demo.sql` et crée les comptes
(`scripts/comptes-test.sh`).
3. Il joue les 18 parcours Playwright de `tests/e2e`, sur un seul worker et avec une session par
fichier. Le rapport HTML et les traces du premier réessai sont versés en artefact.
4. Il lance le tir k6 `smoke`, directement sur `backend:8000`, puis `limitation-debit` par le
proxy. Les synthèses s'affichent dans le résumé du job, et les rapports HTML sont versés en
artefact.
La charge nominale (`make load-test`) et le stress (`make load-stress`) ne tournent pas en CI :
voir `tests/load/README.md`.
## Ce qui manque, et pourquoi
| Manque | Issue | Conséquence assumée |
|---|---|---|
| Images publiées et promues par digest (GHCR) | aucune | Chaque environnement reconstruit ses images : la production n'exécute pas l'artefact validé en recette, mais un second build du même commit |
| DAST bloquant | #41 | Le scan ZAP existe mais ne bloque rien : aucun seuil n'est fixé tant que les alertes du premier passage ne sont pas triées |
| 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 |
| Tir de charge nominal automatisé | #47 | Seul le smoke tourne en CI ; la charge à 50 utilisateurs se lance à la main en recette (`make load-test`), rec et prod partageant la VM |
| Cache de couches Docker en CI | aucune | Le job E2E reconstruit les images backend et frontend à chaque run, deux à quatre minutes de plus |
| Scan d'image de conteneur | aucune | Les images sont construites par le job E2E, pas analysées |
## Reproduire la CI en local
@@ -417,6 +443,14 @@ make ml-test-integration # pipeline ML, marqueur `integration`
make test-chaine # vrais binaires ML puis relecture par l'API, marqueur `chaine`
```
Les autres jobs se rejouent aussi sur le poste :
```bash
make e2e-prepare e2e # parcours Playwright contre `make dev` (tests/e2e/README.md)
make load-smoke K6_EMAIL=... K6_PASSWORD=... # tir k6 d'une minute (tests/load/README.md)
make monitoring-check # promtool, amtool et JSON des tableaux de bord, comme le job Infra
```
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`.
+113
View File
@@ -0,0 +1,113 @@
# Observabilité
Ce document décrit ce qu'on voit du système en fonctionnement : métriques, tableaux de bord,
alertes et journaux. Décision dans l'[ADR 0016](../adr/0016-supervision-en-profil-compose.md),
mode d'emploi dans [`monitoring/README.md`](../../monitoring/README.md).
| Brique | Sert à | Statut |
|---|---|---|
| Métriques de l'API | Débit, erreurs et latences par route | `Fait` |
| Collecte et alertes | Prometheus, neuf règles testées, Alertmanager vers Mailpit | `Fait` |
| Tableaux de bord | Grafana : API, données et modèle, infrastructure | `Fait` |
| Métriques d'Airflow | StatsD ou OpenTelemetry des DAGs | `Cible` |
| Journaux centralisés | Loki ou équivalent | `Cible` |
## Vue d'ensemble
```mermaid
flowchart LR
subgraph projet["Projet Compose de la prod"]
api["backend<br/>/metrics"]
db[("db<br/>TimescaleDB")]
mail["mailpit"]
subgraph sup["Profil monitoring"]
prom["prometheus<br/>15 s, 15 jours"]
am["alertmanager"]
graf["grafana"]
pge["postgres-exporter"]
node["node-exporter"]
cad["cadvisor"]
end
end
hote["Hôte : VM ENI<br/>recette et prod"]
prom -->|"Bearer APP_METRICS_TOKEN"| api
prom --> pge & node & cad
pge -->|"rôle supervision"| db
node -.->|"/proc, /sys"| hote
cad -.->|"cgroups"| hote
prom -->|"règles franchies"| am -->|"SMTP"| mail
graf --> prom
graf -->|"rôle supervision, SQL"| db
```
Tout vit dans le projet Compose de la prod, sur son réseau. La recette n'a pas de supervision
propre. node-exporter et cAdvisor voient pourtant tout l'hôte : la mémoire de la VM et de chaque
conteneur couvre donc aussi la recette, qu'on distingue au préfixe `enervision-rec-`.
## Ce que mesure chaque source
| Source | Métriques utiles | Où les lire |
|---|---|---|
| API (`prometheus-fastapi-instrumentator`) | `http_requests_total` par route et classe de statut, `http_request_duration_seconds` par route (seaux 50 ms à 2,5 s), `http_request_duration_highr_seconds` global, mémoire du processus | Tableau « API » |
| postgres-exporter | `pg_up`, connexions par état, `max_connections`, transactions validées, taille des bases | Tableau « Infrastructure » |
| node-exporter | Processeur, mémoire disponible, espace disque de `/` | Tableau « Infrastructure » |
| cAdvisor | Mémoire (`working_set`) et processeur par conteneur | Tableau « Infrastructure » |
| TimescaleDB, en SQL | Fraîcheur des relevés par site, relevés ingérés par heure, alertes par sévérité, `drift_report` | Tableau « Données et modèle » |
Deux choix de l'instrumentation se lisent dans ces courbes :
- **Les sondes `/health/*` et `/metrics` ne sont pas comptées.** La sonde Docker frappe toutes
les 30 s : incluse, elle ferait baisser la latence moyenne et gonfler le débit d'une API au
repos.
- **Les seaux par route encadrent 500 ms**, seuil de charge de l'ADR 0015. Grafana lit ainsi le
même p95 que k6 pendant un tir.
## Alertes
| Groupe | Alertes | Sévérité |
|---|---|---|
| API | Indisponible 2 min, 5xx au-delà de 5 %, p95 au-delà d'une seconde | critical, critical, warning |
| Base | PostgreSQL injoignable 2 min, connexions au-delà de 80 % | critical, warning |
| Hôte | Mémoire au-delà de 90 %, disque sous 10 %, processeur au-delà de 90 % | warning, critical, warning |
| Supervision | Un exporteur muet 5 min | warning |
- **Tests des règles.** Chaque règle a un cas dans `monitoring/prometheus/tests/`, joué par
`promtool test rules` dans le job Infra de la CI. Une règle qui ne se déclenche plus, ou se
déclenche à tort, casse la CI avant d'atteindre la prod.
- **Envoi.** Alertmanager groupe les alertes par nom et sévérité et les envoie par courriel via
Mailpit, qui les capture sans rien relayer. Un `critical` est rappelé toutes les heures, un
`warning` toutes les douze. Un `critical` masque le `warning` de la même cible.
## Sécurité
- **Aucune interface exposée.** Prometheus, Alertmanager et Grafana n'écoutent que sur
`127.0.0.1`, et rien ne passe par le proxy (ADR 0007). Accès par tunnel SSH.
- **`/metrics` gardé par jeton.** Il n'est pas routé par nginx, et Prometheus y présente
`APP_METRICS_TOKEN`, que l'API exige dès qu'il est posé. Le jeton lui parvient en secret
Compose, jamais en clair dans sa configuration.
- **Base en lecture seule.** Grafana et postgres-exporter lisent la base par le rôle
`supervision`, en lecture seule, limité aux tables métier (`db/roles/supervision.sql`). Ils
n'ont ni `app_user`, ni jetons, ni journal d'audit.
- **Grafana verrouillé.** Il refuse de démarrer sans `GRAFANA_ADMIN_PASSWORD`. Inscription,
accès anonyme et appels sortants (statistiques d'usage, vérification de mises à jour) y sont
désactivés.
- **cAdvisor en `privileged`.** Il tourne ainsi pour lire les cgroups, avec des montages en
lecture seule et sans port publié.
## Journaux
Les journaux restent ceux de Docker : `docker compose logs`, `make stack-logs`,
`make monitoring-logs`. L'API écrit du JSON dès `APP_ENV=prod`, caviardé des jetons et des mots
de passe (voir [20-backend.md](20-backend.md)). Aucune agrégation centralisée n'est en place.
## Ce qui manque
| Manque | Conséquence assumée |
|---|---|
| Métriques d'Airflow (StatsD, OpenTelemetry) | Un DAG qui échoue ne se voit que dans Airflow ; le tableau « Données » le trahit indirectement par des relevés qui vieillissent |
| Alerte sur la fraîcheur des relevés | Visible dans Grafana, mais aucune règle Prometheus ne la porte : il faudrait une métrique calculée par l'API ou un exportateur SQL |
| Journaux centralisés | Un incident se diagnostique conteneur par conteneur |
| Destinataire réel des alertes | Mailpit capture tout : les alertes se lisent dans son interface, elles ne réveillent personne |
+5 -5
View File
@@ -15,12 +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 |
| [50-cicd.md](50-cicd.md) | Orchestrateur `ci.yml`, gates bloquantes, e2e et charge, SonarCloud, Dependabot, ce qui manque |
| [60-observabilite.md](60-observabilite.md) | Métriques, Prometheus, alertes, tableaux de bord Grafana, ce qui manque |
La CI/CD a désormais son document : cinq workflows et seize 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 CI/CD a son document : un orchestrateur et ses workflows de composant, c'est assez de
matière pour qu'une section de plus dans une autre vue devienne illisible. L'observabilité a le
sien depuis l'issue #26, qui lui a donné de la matière : collecte, alertes et tableaux de bord.
L'orchestration Airflow, elle, en a depuis les issues #115 et #116 : trois DAGs, leur image et
leurs contraintes sont décrits dans [10-infra.md](10-infra.md).