diff --git a/README.md b/README.md index f068f67..d31830d 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m | Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | | Reverse proxy | Nginx, TLS | `infra/proxy` | En place | | CI/CD | GitHub Actions | `.github/workflows` | En place | -| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | +| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | En place, profil Compose | +| Tests e2e et de charge | Playwright, k6 | `tests` | En place | | ML | LightGBM, MLflow | `ml` | En place | Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend @@ -48,7 +49,8 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans ├── db/ │ ├── init/ Bootstrap PostgreSQL + TimescaleDB │ ├── migrations/ Migrations SQL versionnees -│ └── seeds/ Jeux de donnees de reference +│ ├── roles/ Roles PostgreSQL hors schema (supervision) +│ └── seeds/ Jeu de demonstration des tests ├── etl/airflow/ │ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, imports, dérive) │ ├── plugins/ Operateurs et hooks maison @@ -64,6 +66,9 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans │ ├── prometheus/ Collecte et regles d'alerte │ ├── grafana/ Provisioning et dashboards │ └── alertmanager/ Routage des alertes +├── tests/ +│ ├── e2e/ Parcours Playwright contre la stack +│ └── load/ Scenarios de charge k6 ├── docs/ ADR et vues d'architecture └── scripts/ Outillage local ``` @@ -148,10 +153,25 @@ nom de domaine public ne résout vers la machine. Routage, mode ACME et renouvel Sur la VM ENI, deux environnements cohabitent, recette sur `dev` et production sur `main`, chacun dans son dossier et son projet Compose : `scripts/provision-host.sh` les prépare, le -workflow `deploy.yml` les redéploie à chaque push par un runner auto-hébergé. Ports, noms +workflow `deploy.yml` les redéploie par un runner auto-hébergé, une fois la CI du commit poussé +verte ([ADR 0014](docs/adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md)). Ports, noms d'hôte et garde-fous dans [`docs/architecture/10-infra.md`](docs/architecture/10-infra.md) et [l'ADR 0009](docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md). +## Tests de bout en bout, charge et supervision + +| Besoin | Commandes | Détail | +|---|---|---| +| Parcours utilisateur (Playwright) | `make e2e-install`, puis `make e2e-prepare e2e` contre `make dev` | [`tests/e2e/README.md`](tests/e2e/README.md) | +| Tir de charge (k6) | `make load-smoke`, `load-test`, `load-stress`, `load-limits` | [`tests/load/README.md`](tests/load/README.md) | +| Supervision | `make monitoring-up`, Grafana sur | [`monitoring/README.md`](monitoring/README.md) | + +La CI joue les parcours, un tir de fumée et le contrôle de la limitation de débit à chaque PR +qui touche l'application, contre la stack de prod derrière le proxy +([ADR 0015](docs/adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md)). La supervision +est active en prod, à la demande ailleurs +([ADR 0016](docs/adr/0016-supervision-en-profil-compose.md)). + ## Conventions - Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court. diff --git a/apps/frontend/TESTING.md b/apps/frontend/TESTING.md index e87dbaf..7db5d36 100644 --- a/apps/frontend/TESTING.md +++ b/apps/frontend/TESTING.md @@ -86,3 +86,9 @@ describe('MonComposant', () => { - Un fichier ou un dossier seulement : `npx ng test --watch=false --coverage=false --include=src/app/core/services/alerts.service.spec.ts` (répéter `--include` pour plusieurs cibles ; un dossier joue tous ses specs) + +## Au-delà des tests unitaires +Les parcours utilisateur complets (connexion, rôles, sites, recommandations, alertes) sont +testés de bout en bout par Playwright, contre l'API et le proxy réels : voir +[tests/e2e/README.md](../../tests/e2e/README.md). Un élément sans rôle ni libellé stable que ces +parcours doivent viser reçoit un `data-testid`. diff --git a/docs/README.md b/docs/README.md index b36ffea..07b06cf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 | diff --git a/docs/adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md b/docs/adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md new file mode 100644 index 0000000..a7100a2 --- /dev/null +++ b/docs/adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md @@ -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. diff --git a/docs/adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md b/docs/adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md new file mode 100644 index 0000000..58b955d --- /dev/null +++ b/docs/adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md @@ -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). diff --git a/docs/adr/0016-supervision-en-profil-compose.md b/docs/adr/0016-supervision-en-profil-compose.md new file mode 100644 index 0000000..f38030c --- /dev/null +++ b/docs/adr/0016-supervision-en-profil-compose.md @@ -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`. diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 5d4ddb6..bb84e82 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -51,8 +51,8 @@ flowchart TB api["API FastAPI
apps/backend"] db[("PostgreSQL 17
TimescaleDB")] airflow["Airflow
etl/airflow"] - prom["Prometheus"] - grafana["Grafana"] + prom["Prometheus
profil monitoring"] + grafana["Grafana
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 @@ -76,8 +76,11 @@ génération des recommandations (issue #116), `historical_import` pour le datas (issue #119) et `mock_api_import` pour l'ingestion horaire de l'API Mock (issue #15). La réconciliation globale des données provenant des deux sources reste à compléter dans l'issue #15. -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 @@ -88,9 +91,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. La réconciliation globale des deux sources reste à compléter dans l'issue #15. | -| 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 @@ -148,7 +151,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 diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md index 1fbabda..1106e7d 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -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
nginx:1.28-alpine
:80 et :443"] + proxy["service proxy
nginx:1.31-alpine
:80 et :443"] front["service frontend
nginx statique :3000"] api["service backend
uvicorn :8000"] db[("service db
:5432")] mail["service mailpit"] + sup["profil monitoring
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 @@ -243,6 +249,8 @@ certificats, et ne démarre rien. | 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` | +| Supervision (profil `monitoring`) | à la demande, `make monitoring-up` | active, `COMPOSE_PROFILES=monitoring` | +| Grafana, Prometheus, Alertmanager, sur `127.0.0.1` | `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. @@ -358,6 +366,7 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de | 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 diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 1b6f9fc..d5bb620 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -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 @@ -435,7 +435,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 diff --git a/docs/architecture/50-cicd.md b/docs/architecture/50-cicd.md index 9a80102..deed4a9 100644 --- a/docs/architecture/50-cicd.md +++ b/docs/architecture/50-cicd.md @@ -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
paths-filter : composants touchés"] - subgraph back["Backend · .github/workflows/backend.yml"] - bv["verification
ruff, mypy, pytest --cov-fail-under=85"] - bi["integration
TimescaleDB réel + alembic upgrade head"] - bd["security-audit
uv export | pip-audit"] - bs["sast
bandit"] + subgraph comp["Workflows de composant (workflow_call)"] + back["backend.yml
lint, typage, tests ≥ 85 %, intégration, pip-audit, bandit"] + front["frontend.yml
build et tests, npm audit"] + mlw["ml.yml
lint, typage, tests, ML ↔ DB, chaîne ML → API, bandit"] + afw["airflow.yml
intégrité des DAGs, image"] + infw["infra.yml
Terraform, Compose et supervision, actionlint"] + e2e["e2e.yml
stack de prod, Playwright, k6 smoke et limitation"] end - subgraph front["Frontend · frontend.yml"] - fb["build
npm ci, npm run build"] - ft["test
couverture lcov"] - fd["security-audit
npm audit --audit-level=high"] - end + sonar["sonar
reprend les couvertures du run"] + ok["CI ok
seul check à exiger"] + dep["deploy.yml
runner eni-g3, rec ou prod"] - subgraph mlw["ML · ml.yml"] - mv["verification
ruff, mypy, pytest"] - ms["sast
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
ruff, intégrité des DAGs"] - ab["image
construction de l'image"] - end - - subgraph infw["Infra · infra.yml"] - it["terraform
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
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
runner eni-g3, environnement rec ou prod"] - end - - push -->|"push sur dev ou main"| dep - - planifie["chaque lundi 3h UTC,
ou à la main"] - subgraph dastw["DAST · dast.yml"] - zscan["zap
seed + scan actif OWASP ZAP"] - end - - planifie --> zscan - push -->|"PR sur dast.yml
ou dast-token.sh"| zscan + planifie["chaque lundi 3h UTC, à la main,
ou PR sur ses fichiers"] + dast["dast.yml
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,18 +94,19 @@ 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 | -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 @@ -145,11 +119,17 @@ de l'environnement est stable, hors du runner, parce que `.env`, certificats et 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=` @@ -179,6 +159,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 +206,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 +221,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 +257,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 +297,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 +389,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 +433,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`. diff --git a/docs/architecture/60-observabilite.md b/docs/architecture/60-observabilite.md new file mode 100644 index 0000000..7d41283 --- /dev/null +++ b/docs/architecture/60-observabilite.md @@ -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
/metrics"] + db[("db
TimescaleDB")] + mail["mailpit"] + + subgraph sup["Profil monitoring"] + prom["prometheus
15 s, 15 jours"] + am["alertmanager"] + graf["grafana"] + pge["postgres-exporter"] + node["node-exporter"] + cad["cadvisor"] + end + end + + hote["Hôte : VM ENI
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 | diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 0c41da2..162c784 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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).