Merge remote-tracking branch 'origin/dev' into feat/reconciliation-dag
This commit is contained in:
@@ -20,3 +20,6 @@
|
||||
| [0011](adr/0011-enervision-procedure-deploiement.md) | Procédure de déploiement, telle qu'exécutée le 22/09/2026 |
|
||||
| [0012](adr/0012-enervision-deploiement-rec-prod-vm-eni.md) | État de la recette et de la production sur la VM ENI |
|
||||
| [0013](adr/0013-surveillance-de-derive-dans-le-backend.md) | La surveillance de dérive vit dans le backend et écrit sa propre table |
|
||||
| [0014](adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md) | Un pipeline CI unique appelle les workflows de composant et conditionne le déploiement |
|
||||
| [0015](adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md) | Les tests de bout en bout et de charge visent la stack Compose déployée |
|
||||
| [0016](adr/0016-supervision-en-profil-compose.md) | La supervision vit dans un profil Compose, active en prod |
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# 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é, sauf
|
||||
si ce commit précède celui déjà déployé : les CI de deux push peuvent finir dans le désordre,
|
||||
et un environnement ne recule jamais. Les déploiements d'un même environnement passent un par
|
||||
un sous un verrou `flock` sur la VM, et non dans un groupe `concurrency`, où GitHub ne garde
|
||||
qu'un job en attente et annule le précédent quand un troisième arrive.
|
||||
|
||||
`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`.
|
||||
@@ -0,0 +1,57 @@
|
||||
# 0017 - Un troisième environnement, `dev`, déployé à la demande depuis n'importe quelle branche
|
||||
|
||||
- Statut : accepté
|
||||
- Date : 2026-09-23
|
||||
|
||||
## Contexte
|
||||
|
||||
L'[ADR 0009](0009-deux-environnements-compose-sur-la-vm-eni.md) a posé deux environnements sur
|
||||
la VM ENI : la recette suit `dev`, la production suit `main`. Les environnements GitHub en
|
||||
comptent trois, `dev`, `rec` et `prod`, et le troisième ne déployait rien.
|
||||
|
||||
Il manque un endroit où montrer une branche de travail avant son merge : la recette ne doit
|
||||
porter que ce qui est intégré à `dev`, sinon elle cesse d'être une recette. Un
|
||||
`workflow_dispatch` sur une branche de travail envoyait d'ailleurs cette branche dans la
|
||||
recette, puisque tout ce qui n'était pas `main` y partait.
|
||||
|
||||
La VM est passée à 32 Go : une troisième TimescaleDB, réglée à 2 Go comme les deux autres,
|
||||
tient sans peine.
|
||||
|
||||
## Décision
|
||||
|
||||
**Un troisième projet Compose, `enervision-dev`, dans `/srv/enervision/dev`**, bâti exactement
|
||||
comme les deux autres : son clone, son `.env`, son certificat, préparés par
|
||||
`scripts/provision-host.sh`.
|
||||
|
||||
**Déployé à la demande, jamais sur un push.** `deploy.yml` envoie `main` en prod, `dev` en
|
||||
recette, et toute autre branche lancée depuis l'onglet Actions dans `dev`. Seul un membre ayant
|
||||
le droit d'écriture sur le dépôt peut lancer un workflow.
|
||||
|
||||
**Ports décalés d'un cran de plus** : HTTPS `9443`, et sur `127.0.0.1` la redirection HTTP
|
||||
`8083`, PostgreSQL `5435`, Mailpit `8027`, Airflow `8084`. Nom d'hôte `dev.enervision.local`,
|
||||
pour la même raison de cookie que la recette.
|
||||
|
||||
**Le verrou de déploiement suit l'environnement** (un `flock` sur son dossier, ADR 0014), et non
|
||||
plus la branche : deux branches lancées coup sur coup écriraient sinon dans le même dossier en
|
||||
même temps.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **`dev` suit la branche `dev` à chaque push, la recette devient manuelle** : la recette
|
||||
offrirait une version figée au jury, mais la doc CI/CD, l'ADR 0009 et l'habitude de l'équipe
|
||||
basculeraient à deux jours du rendu.
|
||||
- **Un environnement par branche de travail** : un projet Compose et une TimescaleDB par
|
||||
branche, sans mécanisme de nettoyage. La machine ne le porterait pas longtemps.
|
||||
- **Garder `dev` sur les postes seulement** : rien à montrer d'une branche non mergée sans
|
||||
passer par la recette.
|
||||
|
||||
## Conséquences
|
||||
|
||||
- Une branche de travail créée avant ce changement porte l'ancien `deploy.yml` : lancée à la
|
||||
main, elle part encore dans la recette. Limiter l'environnement GitHub `rec` à la branche
|
||||
`dev` ferme ce chemin, réglage que seul un administrateur du dépôt peut poser.
|
||||
- `dev` ne garde aucune donnée d'une branche à l'autre au-delà de ce que ses migrations
|
||||
acceptent : une branche dont les migrations divergent de `dev` peut laisser la base dans un
|
||||
état que la suivante refuse. Recréer le volume, `docker compose down -v`, est alors le remède.
|
||||
- Trois environnements construisent leurs images séparément : l'écart de l'ADR 0009, un même
|
||||
commit construit deux fois, reste ouvert jusqu'au passage à GHCR.
|
||||
@@ -0,0 +1,92 @@
|
||||
# 0018 - Noms publics, certificats Let's Encrypt par DNS-01 et frontal SNI sans port
|
||||
|
||||
- Statut : accepté
|
||||
- Date : 2026-09-23
|
||||
|
||||
## Contexte
|
||||
|
||||
Les trois environnements de la VM ENI ([ADR 0009](0009-deux-environnements-compose-sur-la-vm-eni.md),
|
||||
[ADR 0017](0017-environnement-dev-a-la-demande.md)) répondaient sur `enervision.local`,
|
||||
`rec.enervision.local:8443` et `dev.enervision.local:9443`, avec des certificats auto-signés.
|
||||
Chaque poste devait éditer son `/etc/hosts` et accepter trois avertissements du navigateur :
|
||||
rien de présentable à un jury, et rien d'utilisable par quelqu'un qui n'a pas la main sur son
|
||||
poste.
|
||||
|
||||
Contraintes : la VM n'a qu'une IP privée, `10.101.200.37`, que ni Internet ni Let's Encrypt ne
|
||||
joignent, et le réseau de l'école ne doit pas être touché. Vérifications faites le 23/09 : les
|
||||
résolveurs de l'école rendent bien une adresse privée pour un nom public, la VM sort en HTTPS
|
||||
vers Let's Encrypt et vers l'API de dynv6, mais le filtrage de l'école bloque duckdns.org, site
|
||||
et API, depuis les postes comme depuis la VM.
|
||||
|
||||
## Décision
|
||||
|
||||
**Des noms publics qui visent l'IP privée.** Dans `enervision-g3.dynv6.net`, zone gratuite de
|
||||
dynv6, trois enregistrements A portent `prod.`, `rec.` et `dev.`. `provision-host.sh` les publie
|
||||
par l'API dynv6 : le DNS est décrit par le code comme le reste. La prod n'est pas à la racine de
|
||||
la zone : dynv6 y sert mal un TXT `_acme-challenge`, que l'API ne liste ni ne supprime et qu'un
|
||||
seul de ses trois serveurs renvoie (constaté le 23/09), si bien que son défi DNS-01 échoue. Tout
|
||||
poste du réseau de l'école les résout sans configuration ; hors de ce réseau, l'IP ne mène
|
||||
nulle part.
|
||||
|
||||
**Des certificats Let's Encrypt par défi DNS-01.** Le défi passe par l'API dynv6, qui pose
|
||||
l'enregistrement TXT : Let's Encrypt n'a jamais à joindre la VM. `make tls-dns01` (acme.sh
|
||||
épinglé) le joue dans chaque stack ; il ne renouvelle qu'à échéance, d'où son rejeu à chaque
|
||||
déploiement et chaque nuit par cron. `--dnssleep 90` laisse aux trois serveurs de dynv6 le temps
|
||||
de servir le TXT avant que Let's Encrypt ne le cherche depuis plusieurs réseaux. Un certificat par environnement plutôt qu'un joker : chaque
|
||||
stack garde le sien, et la clé de la prod n'est pas lisible depuis le clone de dev.
|
||||
|
||||
**Un frontal SNI sur 443, le seul composant exposé.** `infra/front`, un nginx sur le réseau de
|
||||
l'hôte, lit le nom demandé dans le ClientHello et relaie le flux TLS intact vers la stack visée,
|
||||
publiée sur la boucle locale. Il ne détient aucun certificat. Le port 80 y redirige vers
|
||||
HTTPS. Les URL perdent leur port.
|
||||
|
||||
**Le PROXY protocol entre frontal et stacks.** Relayé tel quel, le flux arriverait avec l'IP du
|
||||
frontal : `limit_req` et `get_client_ip()` compteraient tous les postes comme un seul, et un
|
||||
utilisateur bloquerait la connexion de tous. Chaque proxy de stack reçoit donc le frontal sur un
|
||||
écouteur dédié, 4443, qui exige l'en-tête PROXY protocol et en tire l'IP du client. Le 443 de
|
||||
la stack reste sans PROXY protocol, pour les postes de développement et la sonde du déploiement.
|
||||
|
||||
**Le fournisseur est un paramètre.** `DNS01_API` et `DNS01_JETON_VAR` nomment le greffon acme.sh,
|
||||
le jeton vit dans `dns.token` quel que soit le fournisseur : passer à un domaine acheté chez
|
||||
Cloudflare ou OVH ne demande que ces deux variables et `domaine`, plus `publier_dns()`.
|
||||
|
||||
**`scripts/provision-host.sh` fait foi pour l'adressage et les secrets.** Un `.env` existant
|
||||
garde ses secrets, reçoit ceux qui lui manquent et voit hôte, ports et profils réalignés sur le
|
||||
tableau du script. C'est ce qui permet de migrer trois `.env` nés avant ce changement, et le
|
||||
clone de la prod, en retard sur `main`, sans dépendre de son `.env.example`.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Garder `/etc/hosts` et l'auto-signé** : trois manipulations par poste et trois
|
||||
avertissements, précisément ce qu'il fallait supprimer.
|
||||
- **DuckDNS** : premier choix, inscription en un clic, mais bloqué par le filtrage de l'école :
|
||||
sans son API, pas de défi DNS-01.
|
||||
- **deSEC (`dedyn.io`)** : joignable et associatif, mais les inscriptions de nouveaux domaines
|
||||
`dedyn.io` étaient fermées le 23/09 ; il reste le bon choix pour un domaine acheté.
|
||||
- **nip.io ou sslip.io** : résolution sans compte, mais aucun moyen d'y obtenir un certificat.
|
||||
- **Services à certificat joker public (traefik.me, local-ip.co)** : leur clé privée est publiée
|
||||
par conception, n'importe qui peut usurper ces noms.
|
||||
- **Tunnel vers Internet (Cloudflare Tunnel, Tailscale Funnel)** : accès depuis l'extérieur,
|
||||
mais l'application serait exposée hors de l'école, décision refusée.
|
||||
- **Autorité de certification interne (mkcert, step-ca)** : chaque poste devrait l'installer.
|
||||
- **Terminaison TLS au frontal** : un seul endroit pour les certificats, mais les stacks
|
||||
recevraient du HTTP clair que leur proxy redirige vers HTTPS, et en-têtes de sécurité comme
|
||||
limitation de débit seraient à déplacer. Le relais SNI ne touche à rien de tout cela.
|
||||
- **Domaine acheté** : plus présentable, mais un achat et un compte de plus pour un bénéfice nul
|
||||
sur l'accès. Seule `DOMAINE` changerait.
|
||||
|
||||
## Conséquences
|
||||
|
||||
- L'objection de l'ADR 0009 à un proxy frontal, qui aurait dû joindre plusieurs réseaux Compose
|
||||
aux services homonymes, tombe : le frontal ne joint que des ports de la boucle locale.
|
||||
- Sans le frontal, plus rien n'est joignable sur la VM. `deploy.yml` le relance à chaque
|
||||
déploiement de la prod, et son `restart: unless-stopped` le ramène après un redémarrage.
|
||||
- Le jeton dynv6 vit dans `/srv/enervision/dns.token`, jamais dans git, GitHub ni le state
|
||||
Terraform ; acme.sh en garde une copie dans `infra/proxy/acme/`, retirée à la lecture des
|
||||
autres comptes. Qui le détient peut repointer les trois noms.
|
||||
- dynv6 devient une dépendance : s'il tombe, les noms cessent de résoudre et les
|
||||
renouvellements échouent. Les certificats valent 90 jours, la marge est large.
|
||||
- Un filtrage de l'école qui viendrait à bloquer dynv6 arrêterait les renouvellements, pas les
|
||||
noms : la résolution passe par les serveurs DNS de l'école, pas par le site.
|
||||
- Les noms sont publics mais ne mènent qu'à une IP privée : ils révèlent l'existence de la VM,
|
||||
pas son contenu.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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 |
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user