docs: décrit la CI unifiée, l'e2e, la charge et la supervision

- ADR 0014 : un pipeline CI unique, « CI ok » seul check à exiger, déploiement du commit testé.
- ADR 0015 : e2e et charge contre la stack Compose déployée, hypothèses et seuils de k6.
- ADR 0016 : supervision en profil Compose, active en prod, rôle en lecture seule.
- Nouvelle vue 60-observabilite.md ; 00-vue-ensemble, 10-infra, 20-backend et 50-cicd mis à
  jour (monitoring passé à Fait, nouveau graphe de CI, gates, ports).
- README racine et guide de tests du frontend : où sont l'e2e, la charge et la supervision.
This commit is contained in:
Johan LEROY
2026-09-23 09:46:12 +02:00
parent dc952d13aa
commit 0284cc0cd8
12 changed files with 519 additions and 125 deletions
@@ -0,0 +1,75 @@
# 0014 - Un pipeline CI unique appelle les workflows de composant et conditionne le déploiement
- Statut : accepté
- Date : 2026-09-23
- Complète : [0009](0009-deux-environnements-compose-sur-la-vm-eni.md), qui reste en vigueur
## Contexte
Au 22/09, huit workflows se déclenchaient chacun de leur côté, et l'audit y a relevé :
- **Double exécution.** Chaque workflow partait sur `push` (toutes branches) **et** sur
`pull_request`. Un commit poussé sur une branche de PR jouait donc toute la CI deux fois,
à la même minute (constaté dans l'historique des runs de `test/integration-api-db-ml`).
- **Sonar refaisait tout.** `sonarqube.yml` reconstruisait le frontend et retestait frontend,
backend et ML pour produire ses rapports de couverture, en double exact de `frontend.yml`,
`backend.yml` et `ml.yml`. Son test backend tournait sans `uv sync`. Il n'avait ni
`permissions` ni `concurrency`.
- **Déploiement non conditionné.** `deploy.yml` partait à chaque push sur `dev` ou `main`,
que la CI du commit soit verte ou non, et déployait la pointe de branche du moment plutôt
que le commit poussé.
- **Erreurs silencieuses et hygiène.**
- `npm test --watch=false --code-coverage` : npm garde ces options pour lui, `ng test` ne
les reçoit jamais, et la CI ne tenait que par les réglages d'`angular.json`.
- `uv sync --frozen` ne vérifie pas que `uv.lock` suit `pyproject.toml`.
- Plusieurs actions tierces étaient épinglées par tag, contrairement à la règle Sonar
`githubactions:S7637`.
- Aucun job n'avait de `timeout-minutes` (360 minutes par défaut).
## Décision
**`ci.yml` est le seul workflow déclenché par `pull_request` et par les push sur `dev` et
`main`.** Les workflows de composant (`backend`, `frontend`, `ml`, `airflow`, `infra`, `e2e`)
passent en `workflow_call` et n'ont plus de déclencheur propre.
1. **`changes`.** Un job initial calcule, par `dorny/paths-filter` épinglé sur un SHA, les
composants touchés par la PR, et chaque composant n'est appelé que si son filtre vaut vrai.
Sur un push vers `dev` ou `main`, tous les filtres valent vrai : l'analyse Sonar reste
complète sur les branches longues, et paths-filter ne compare pas à la base de fusion avec
`main`, qui a 80 commits de retard.
2. **`sonar`.** Il ne reconstruit ni ne reteste plus rien : il télécharge, dans le même run, les
couvertures versées par les jobs `verification` des composants.
3. **`CI ok`.** Le job agrège le résultat de tous les autres. Il tourne toujours (`if:
always()`) et échoue dès qu'un job est en `failure` ou `cancelled`. **C'est le seul check à
exiger dans les règles de branche** : un composant sauté par son filtre ne publie aucun check
interne, qui resterait « en attente » s'il était exigé.
4. **`deploy`.** Il appelle `deploy.yml`, sur les seuls push, et seulement si `CI ok` a réussi.
`deploy.yml` aligne le dossier de l'environnement sur `GITHUB_SHA`, le commit testé.
`deploy.yml` n'a toujours **aucun déclencheur `pull_request`** : il n'accepte que
`workflow_call` et `workflow_dispatch`, dans l'esprit de l'ADR 0009.
## Alternatives écartées
| Écartée | Raison |
|---|---|
| Garder huit workflows et restreindre seulement `push` à `dev` et `main` | Supprime la double exécution, pas le doublon Sonar : il faudrait toujours rejouer les tests pour que Sonar ait ses couvertures, les artefacts ne passant pas d'un workflow à l'autre. Et rien n'empêche un déploiement rouge. |
| Déclencher le déploiement par `workflow_run` | `workflow_run` joue toujours le fichier de la branche par défaut, `main`, en retard de 80 commits : la recette ne se serait plus déployée avant la prochaine remontée vers `main`, sans erreur visible. |
| `alls-green` ou une action tierce d'agrégation | Dix lignes de shell sur `toJSON(needs.*.result)` font le même travail, sans dépendance de plus à épingler. |
| Cache de couches Docker (`bake-action`, `type=gha`) pour l'e2e | Quatre pièges (noms d'image, cibles Compose, buildx, `load`) pour deux à quatre minutes gagnées. Reporté après le rendu. |
## Conséquences
- Une PR ne joue que ce qu'elle touche. Une PR de documentation ne joue que `changes` et
`CI ok`.
- Les checks s'appellent désormais « Backend / Lint, typage et tests », etc. Au 23/09, ni `dev`
ni `main` n'ont de règle de protection : à la première, exiger **« CI ok »** et rien d'autre.
- Modifier `ci.yml` rejoue toute la CI sur la PR (filtre `ci`).
- Le job `deploy` reste en file tant que le runner `eni-g3` n'est pas enregistré sur la VM,
comme avant. Le groupe de concurrence par SHA des push l'empêche de bloquer les runs suivants.
- La sécurité du runner auto-hébergé ne repose pas sur l'absence de `pull_request` dans
`deploy.yml`. Une PR de fork peut ajouter son propre workflow. Ce qui protège le runner :
- l'approbation obligatoire des workflows de tous les contributeurs externes ;
- les règles de branche des environnements `rec` (`dev`) et `prod` (`main` et un relecteur).
Ces deux réglages restent à poser par l'administratrice du dépôt.
@@ -0,0 +1,70 @@
# 0015 - Les tests de bout en bout et de charge visent la stack Compose déployée
- Statut : accepté
- Date : 2026-09-23
## Contexte
Les issues #46 (Playwright) et #47 (k6) demandent des preuves de robustesse pour EC03 et EC04.
Rien ne vérifiait un parcours utilisateur complet : les tests du frontend simulent l'API, ceux
du backend n'ouvrent pas de navigateur. Rien ne mesurait non plus l'API sous charge, et le
dépôt ne chiffre aucun temps de réponse ni aucun volume d'utilisateurs.
Trois contraintes du système pèsent sur la manière de tester :
- **La session tient dans un cookie de refresh HttpOnly qui tourne à chaque usage.** Rejouer un
cookie déjà servi révoque toute la famille de session (ADR 0002).
- **Le cookie n'est `__Secure-` et `Secure` que hors `local`, derrière le proxy TLS.** Tester
contre `ng serve` ne dit rien de ce que voit un navigateur en prod (ADR 0007).
- **nginx limite chaque adresse IP** à 20 req/s sur l'API, avec une rafale de 40, et à 30
connexions par minute, avec une rafale de 20 (ADR 0007). Tout le trafic d'un tir parti d'une
seule machine partage la même adresse.
## Décision
**Playwright joue contre la stack de prod** (`docker-compose.yml` et
`docker-compose.prod.yml`), sur `https://localhost` avec un certificat auto-signé.
- **En CI**, le workflow `e2e.yml` démarre `db`, `mailpit`, `backend`, `frontend` et `proxy`,
sème `db/seeds/demo.sql` et crée les comptes par `scripts/comptes-test.sh`.
- **Sur le poste**, la même suite vise `make dev` (`http://localhost:4200`).
- **Écriture des tests**, imposée par la rotation du refresh et par la zone `auth` :
- un seul worker ;
- une session par fichier, sans `storageState` partagé ;
- chaque parcours qui consomme un compte le crée lui-même.
**k6 tourne en service Compose (profil `load`) sur le réseau du projet et vise `backend:8000`**,
pour mesurer l'API et non la limite de nginx. Un seul scénario, `limitation-debit.js`, passe par
`https://proxy`, pour vérifier que la limite tient : des 429, jamais de 5xx.
**Hypothèses et seuils**, faute d'exigence chiffrée :
| Hypothèse ou seuil | Valeur |
|---|---|
| Utilisateurs simultanés | 50 : 40 sur le tableau de bord, qui interroge `/stats/summary` toutes les 10 s et `/alerts` toutes les 60 s ; 10 qui explorent les sites |
| Lectures, p95 | < 500 ms |
| Lectures, p99 | < 1 s |
| Échecs HTTP | < 1 % |
| Vérifications réussies | > 99 % |
**En CI de PR** : Playwright, le tir `smoke` (une minute) et `limitation-debit`. La charge
nominale et le stress se lancent à la main (`make load-test`, `make load-stress`), en recette,
parce que rec et prod partagent la VM (ADR 0009).
## Alternatives écartées
| Écartée | Raison |
|---|---|
| Playwright contre `ng serve` en CI | Pas de TLS, pas de cookie `__Secure-`, pas de CSP ni de limitation : le parcours testé ne serait pas celui des utilisateurs. |
| `storageState` partagé entre fichiers | Chaque fichier rejouerait le même cookie de refresh ; le second usage révoque la famille, et la suite échoue de façon intermittente selon l'ordre. |
| k6 depuis le runner, à travers le proxy | Au-delà de 20 req/s, on mesure nginx. Relever la limite pour le tir, ce serait tester une configuration qui n'est pas celle de la prod. |
| Tir de charge complet à chaque PR | Huit minutes de plus par PR, sur un runner partagé dont les performances varient d'un run à l'autre : un seuil franchi n'y voudrait rien dire. |
| Un workflow k6 en `workflow_dispatch` contre la recette | La recette partage la VM avec la prod ; un tir déclenché d'un clic ralentirait la prod sans que personne soit prévenu. La cible Makefile, lancée sur la VM, garde un humain dans la boucle. |
## Conséquences
- La CI construit enfin les images backend et frontend avant le déploiement, par le job E2E.
- `db/seeds/demo.sql` et `scripts/comptes-test.sh` deviennent le jeu commun de la CI, repris par
le DAST. Les deux sont réservés aux bases jetables.
- Les seuils de k6 sont des hypothèses de l'équipe : à réviser dès qu'un besoin chiffré existe.
- L'API expose des seaux de latence fins autour de 500 ms, pour que Grafana lise le même seuil
que k6 (ADR 0016).
@@ -0,0 +1,61 @@
# 0016 - La supervision vit dans un profil Compose, active en prod
- Statut : accepté
- Date : 2026-09-23
## Contexte
L'API expose `/metrics` au format Prometheus depuis le début, et `monitoring/` ne contenait que
des `.gitkeep` : aucun collecteur, aucun tableau de bord, aucune alerte (issue #26). La VM ENI
porte la recette et la prod, deux piles complètes, sur 8 Go de mémoire (ADR 0009).
## Décision
**Prometheus, Alertmanager, Grafana et trois exporteurs** sont des services de
`docker-compose.yml` sous le profil `monitoring` : postgres-exporter, node-exporter et cAdvisor.
- **En prod**, `COMPOSE_PROFILES=monitoring` dans le `.env` : `make stack-up`, donc chaque
déploiement, les démarre avec le reste.
- **En recette et sur le poste**, ils se lancent à la demande (`make monitoring-up`, en
`--no-deps`). La recette ne paie rien tant qu'on ne les lance pas.
- **Mémoire.** Chaque service a un `mem_limit`, pour environ 700 Mo au total.
- **Accès.** Les interfaces n'écoutent que sur `127.0.0.1` et se consultent par tunnel SSH,
comme Airflow. Rien ne passe par le proxy : Grafana derrière nginx exigerait sa propre
authentification forte, et rendrait `/metrics` joignable à un routage près (ADR 0007).
- **Sécurité.**
- **Jeton.** Prometheus présente sur `/metrics` le jeton `APP_METRICS_TOKEN`, passé en
secret Compose. Il est exigé dès que la supervision tourne.
- **Lecture de la base.** Grafana et l'exportateur lisent la base par un rôle `supervision`
en lecture seule, limité aux tables métier (`db/roles/supervision.sql`). Ils n'ont
jamais accès à `app_user`, aux jetons ni à l'audit.
- **Alertes.**
- Neuf règles couvrent l'API, la base, l'hôte et les cibles, chacune avec un cas de test
joué par `promtool test rules` en CI.
- Alertmanager les envoie par courriel à Mailpit, le seul SMTP de la stack.
- **Dérive du modèle.** Elle s'affiche dans Grafana par une lecture SQL de `drift_report`.
L'ADR 0013 a écarté une jauge Prometheus calculée par un traitement par lot, pas la lecture
de sa table.
## Alternatives écartées
| Écartée | Raison |
|---|---|
| Supervision démarrée dans les deux environnements | Double la mémoire consommée sur une VM déjà serrée, pour des tableaux de recette que personne ne regarde. |
| Une pile de supervision partagée, troisième projet Compose | Elle devrait rejoindre les réseaux des deux projets, par des réseaux externes à déclarer sur la VM : plus de pièces, et un couplage entre environnements que l'ADR 0009 évite. |
| Publier Grafana derrière le proxy | Une interface d'administration de plus exposée au réseau de l'école, et un pas de plus vers une publication accidentelle de `/metrics`. |
| Grafana avec le compte applicatif de la base | Le compte applicatif écrit partout, y compris dans `app_user`. Une requête libre dans Grafana y aurait accès. |
| Une jauge de fraîcheur des relevés calculée par l'API au moment du scrape | Une requête SQL dans un collecteur synchrone, à chaque scrape. La même information se lit directement dans TimescaleDB depuis Grafana. |
## Conséquences
- **Secrets.** `.env.example` gagne `COMPOSE_PROFILES`, `APP_METRICS_TOKEN`,
`GRAFANA_ADMIN_PASSWORD`, `SUPERVISION_DB_PASSWORD` et les ports.
`scripts/provision-host.sh` génère ces secrets pour un nouvel environnement. Le `.env` d'un
environnement déjà provisionné n'est jamais réécrit : il faut les y ajouter à la main.
`make stack-up` refuse de démarrer si le profil est actif et qu'un secret manque.
- **Instrumentation.** L'API ne compte plus les sondes de santé dans ses métriques, et chaque
application a son propre registre Prometheus.
- **cAdvisor tourne en `privileged`**, avec des montages en lecture seule et sans port publié.
C'est le prix de la mémoire par conteneur, l'indicateur qui compte le plus sur une VM partagée.
- **Données non couvertes.** L'ingestion et les DAG Airflow n'ont pas encore de métriques
(StatsD ou OpenTelemetry). Le tableau « Données » les supplée en lisant `reading`.