diff --git a/.env.example b/.env.example
index 3125b73..78243da 100644
--- a/.env.example
+++ b/.env.example
@@ -69,8 +69,27 @@ PUBLIC_ORIGIN=
# l'extérieur. Décaler aussi POSTGRES_PORT, MAILPIT_UI_PORT et AIRFLOW_PORT (5434, 8026, 8082).
PROXY_HTTP_PORT=
PROXY_HTTPS_PORT=
+# Écouteur PROXY protocol du proxy, que seul le frontal de la VM joint (infra/front, ADR 0018).
+# Vide : port aléatoire sur 127.0.0.1. VM : 127.0.0.1:10444 en prod, 8444 en recette, 9444 en dev.
+PROXY_FRONT_PORT=
# Réglages mémoire de la stack déployée. Sans eux, timescaledb-tune réserve 25 % de la RAM de la
# machine à chaque base au premier démarrage. L'api-server Airflow 3 n'a rien à régler ici : son
# nombre de workers vaut 1 par défaut, contre 4 pour le webserver d'Airflow 2.
TS_TUNE_MEMORY=2GB
TS_TUNE_NUM_CPUS=2
+
+# Supervision (ADR 0016) : `monitoring` la démarre avec `make stack-up`, réglage de la prod.
+# Vide ailleurs, où `make monitoring-up` la lance à la demande.
+COMPOSE_PROFILES=
+# Jeton présenté par Prometheus sur `/metrics`, exigé par l'API dès qu'il est posé. Requis dès
+# que la supervision tourne ; même générateur que APP_SECRET_KEY.
+APP_METRICS_TOKEN=change_me
+# Compte `admin` de Grafana. Sans lui, le conteneur refuse de démarrer.
+GRAFANA_ADMIN_PASSWORD=change_me
+# Rôle PostgreSQL `supervision`, en lecture seule, de Grafana et de postgres-exporter
+# (db/roles/supervision.sql, posé par `make db-ensure-supervision`).
+SUPERVISION_DB_PASSWORD=change_me
+# Interfaces publiées sur 127.0.0.1 seulement, par tunnel SSH. 3000 est pris par le frontend.
+GRAFANA_PORT=3001
+PROMETHEUS_PORT=9090
+ALERTMANAGER_PORT=9093
diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml
new file mode 100644
index 0000000..4823b8b
--- /dev/null
+++ b/.github/actionlint.yaml
@@ -0,0 +1,3 @@
+self-hosted-runner:
+ labels:
+ - eni-g3
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index a02f919..1cf5cb9 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -20,6 +20,17 @@ updates:
- dependency-name: "@vitest/coverage-v8"
update-types: ["version-update:semver-major"]
+ # Tests de bout en bout, paquet npm distinct du frontend
+ - package-ecosystem: "npm"
+ directory: "/tests/e2e"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 2
+ groups:
+ e2e-dependencies:
+ patterns:
+ - "*"
+
# Backend — uv (lit pyproject.toml / uv.lock)
- package-ecosystem: "uv"
directory: "/apps/backend"
diff --git a/.github/workflows/airflow.yml b/.github/workflows/airflow.yml
index 0d652d9..d6d314a 100644
--- a/.github/workflows/airflow.yml
+++ b/.github/workflows/airflow.yml
@@ -6,42 +6,20 @@ name: Airflow
# Docker, dans son propre environnement (cf. etl/airflow/Dockerfile).
#
# Piège : l'image COPY les fichiers de dépendances et le code de ml/ et de apps/backend/. Une
-# modification de l'un ou de l'autre peut donc casser sa construction, d'où ces chemins dans
-# les déclencheurs, alors même que ce workflow ne teste ni le modèle ni l'API.
+# modification de l'un ou de l'autre peut donc casser sa construction : le filtre `airflow` de
+# ci.yml, qui appelle ce workflow, inclut ces chemins alors qu'il ne teste ni le modèle ni l'API.
on:
- push:
- paths:
- - "etl/airflow/**"
- - "ml/pyproject.toml"
- - "ml/uv.lock"
- - "ml/enervision_ml/**"
- - "apps/backend/pyproject.toml"
- - "apps/backend/uv.lock"
- - "apps/backend/app/**"
- - ".github/workflows/airflow.yml"
- pull_request:
- paths:
- - "etl/airflow/**"
- - "ml/pyproject.toml"
- - "ml/uv.lock"
- - "ml/enervision_ml/**"
- - "apps/backend/pyproject.toml"
- - "apps/backend/uv.lock"
- - "apps/backend/app/**"
- - ".github/workflows/airflow.yml"
+ workflow_call:
permissions:
contents: read
-concurrency:
- group: airflow-${{ github.ref }}
- cancel-in-progress: true
-
jobs:
verification:
name: Lint et intégrité des DAGs
runs-on: ubuntu-latest
+ timeout-minutes: 15
defaults:
run:
working-directory: etl/airflow
@@ -50,17 +28,19 @@ jobs:
- name: Récupère le dépôt
uses: actions/checkout@v7
+ # Action tierce, épinglée sur le commit du tag (règle Sonar githubactions:S7637).
- name: Installe uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
cache-dependency-glob: etl/airflow/uv.lock
+ prune-cache: false
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
- - name: Synchronise les dépendances sans dévier du verrou
- run: uv sync --all-groups --frozen
+ - name: Synchronise les dépendances sur le verrou
+ run: uv sync --all-groups --locked
- name: Vérifie le formatage
run: uv run ruff format --check .
@@ -76,6 +56,7 @@ jobs:
image:
name: Construction de l'image
runs-on: ubuntu-latest
+ timeout-minutes: 25
steps:
- name: Récupère le dépôt
@@ -93,7 +74,6 @@ jobs:
# `--help` sort par argparse avant `get_settings()` : ni base ni secret requis, et
# l'import des modules prouve que l'environnement /opt/backend est complet.
- # Les commandes des DAGs `alertes`, historique et API Mock sont couvertes.
- name: Vérifie que les quatre commandes backend s'importent sans réseau
run: >
docker run --rm --network none enervision-airflow:ci
@@ -101,4 +81,4 @@ jobs:
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.detection.internal_alerts --help
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.cli generate-recommendations --help
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.historical_import --help
- && env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.mock_api_import --help"
\ No newline at end of file
+ && env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.mock_api_import --help"
diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml
index 84c0664..27a9cc6 100644
--- a/.github/workflows/backend.yml
+++ b/.github/workflows/backend.yml
@@ -2,28 +2,20 @@ name: Backend
# Piège : la version de Python vient de apps/backend/.python-version, et elle doit rester
# en 3.14. Le code utilise le PEP 758, qu'un interpréteur 3.13 refuse de compiler.
+# Pourquoi : aucun déclencheur propre. ci.yml appelle ce workflow quand le backend change, et
+# Sonar y reprend la couverture versée par le job `verification` (ADR 0014).
on:
- push:
- paths:
- - "apps/backend/**"
- - ".github/workflows/backend.yml"
- pull_request:
- paths:
- - "apps/backend/**"
- - ".github/workflows/backend.yml"
+ workflow_call:
permissions:
contents: read
-concurrency:
- group: backend-${{ github.ref }}
- cancel-in-progress: true
-
jobs:
verification:
name: Lint, typage et tests
runs-on: ubuntu-latest
+ timeout-minutes: 15
defaults:
run:
working-directory: apps/backend
@@ -32,17 +24,20 @@ jobs:
- name: Récupère le dépôt
uses: actions/checkout@v7
+ # Action tierce, épinglée sur le commit du tag (règle Sonar githubactions:S7637).
- name: Installe uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
+ prune-cache: false
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
- - name: Synchronise les dépendances sans dévier du verrou
- run: uv sync --all-groups --frozen
+ # `--locked` et non `--frozen` : un verrou qui ne suit plus pyproject.toml doit casser ici.
+ - name: Synchronise les dépendances sur le verrou
+ run: uv sync --all-groups --locked
- name: Vérifie le formatage
run: uv run ruff format --check .
@@ -55,14 +50,21 @@ jobs:
# Le marqueur `integration` est exclu par défaut, donc aucune base n'est nécessaire ici.
- name: Tests et couverture
- run: uv run pytest --cov-fail-under=85
+ run: uv run pytest --cov-fail-under=85 --cov-report=xml
- # Piège : l'image est celle de docker-compose.yml, pas une image `postgres` nue. La première
- # migration (`5353c0e4f094`) échoue volontairement si l'extension TimescaleDB manque, et un
- # écart d'image entre la CI et le poste rendrait ce job vert sur une base qui n'est pas la nôtre.
+ - name: Verse la couverture pour Sonar
+ uses: actions/upload-artifact@v7
+ with:
+ name: backend-coverage
+ path: apps/backend/coverage.xml
+ if-no-files-found: error
+
+ # Piège : même image que docker-compose.yml, pas un `postgres` nu. La première migration refuse
+ # de s'appliquer sans TimescaleDB, et une autre image testerait une base qui n'est pas la nôtre.
integration:
name: Tests exigeant une base
runs-on: ubuntu-latest
+ timeout-minutes: 15
defaults:
run:
working-directory: apps/backend
@@ -93,16 +95,17 @@ jobs:
uses: actions/checkout@v7
- name: Installe uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
+ prune-cache: false
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
- - name: Synchronise les dépendances sans dévier du verrou
- run: uv sync --all-groups --frozen
+ - name: Synchronise les dépendances sur le verrou
+ run: uv sync --all-groups --locked
# Sur le poste, c'est db/init/110-test-database.sql qui pose l'extension. Ce fichier n'est
# pas monté ici, et sans lui `alembic upgrade head` s'arrête sur la garde de la révision 1.
@@ -112,14 +115,15 @@ jobs:
- name: Applique les migrations
run: uv run alembic upgrade head
- # `-m` en ligne de commande écrase celui d'`addopts`. La couverture est désactivée : ce job
- # ne joue qu'une partie de la suite, son taux n'aurait aucun sens face au seuil de 85 %.
+ # Couverture désactivée : ce job ne joue qu'une partie de la suite, son taux n'aurait
+ # aucun sens face au seuil de 85 %.
- name: Tests d'intégration
run: uv run pytest -m integration --no-cov
security-audit:
name: Audit des dépendances
runs-on: ubuntu-latest
+ timeout-minutes: 10
defaults:
run:
working-directory: apps/backend
@@ -129,21 +133,23 @@ jobs:
uses: actions/checkout@v7
- name: Installe uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
+ prune-cache: false
# L'audit porte sur le verrou, pas sur l'environnement : sinon pip-audit auditerait
# aussi les paquets que son propre `--with` injecte, hors dépendances du projet.
- name: Audite les dépendances livrées
# Piège : sans `shell: bash`, un échec de `uv export` serait masqué par le pipe.
shell: bash
- run: uv export --frozen --no-dev --no-emit-project --no-hashes | uvx pip-audit --requirement /dev/stdin --no-deps
+ run: uv export --locked --no-dev --no-emit-project --no-hashes | uvx pip-audit --requirement /dev/stdin --no-deps
sast:
name: Analyse statique de sécurité
runs-on: ubuntu-latest
+ timeout-minutes: 10
defaults:
run:
working-directory: apps/backend
@@ -155,7 +161,9 @@ jobs:
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
- name: Installe uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
+ with:
+ enable-cache: false
# Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des
# secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..5e2f4ad
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,225 @@
+# Pourquoi : un seul point d'entrée pour toute la CI (ADR 0014) - workflow CI. Chaque composant
+# ne tourne que si ses fichiers changent, Sonar reprend les couvertures déjà produites au lieu de
+# tout rejouer, et le déploiement ne part que d'un commit dont la CI est verte.
+# Piège : le seul check à exiger dans les règles de branche est « CI ok ». Un job sauté par son
+# filtre ne publie pas les checks de son workflow, qui resteraient en attente s'ils étaient exigés.
+# Piège : sur un push vers dev ou main, tous les filtres valent vrai. paths-filter comparerait
+# sinon à la base de fusion avec main, et Sonar n'analyserait qu'une partie de la branche.
+# Piège : pas d'annulation des runs de push. Un run coupé en plein `make stack-up` laisserait la
+# stack à moitié redémarrée ; le groupe par SHA évite aussi de mettre `dev` en file derrière lui.
+
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches: [dev, main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ changes:
+ name: Périmètre modifié
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ pull-requests: read
+ outputs:
+ backend: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.ci == 'true' || steps.filtre.outputs.backend == 'true' }}
+ frontend: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.ci == 'true' || steps.filtre.outputs.frontend == 'true' }}
+ ml: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.ci == 'true' || steps.filtre.outputs.ml == 'true' }}
+ airflow: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.ci == 'true' || steps.filtre.outputs.airflow == 'true' }}
+ terraform: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.ci == 'true' || steps.filtre.outputs.terraform == 'true' }}
+ compose: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.ci == 'true' || steps.filtre.outputs.compose == 'true' }}
+ workflows: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.workflows == 'true' }}
+ e2e: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.ci == 'true' || steps.filtre.outputs.e2e == 'true' }}
+ sonar: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.ci == 'true' || steps.filtre.outputs.sonar == 'true' }}
+
+ steps:
+ # Sur une PR, la liste des fichiers vient de l'API : ni checkout ni historique requis.
+ - name: Calcule le périmètre de la PR
+ id: filtre
+ if: github.event_name == 'pull_request'
+ uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
+ with:
+ filters: |
+ ci:
+ - ".github/workflows/ci.yml"
+ backend:
+ - "apps/backend/**"
+ - ".github/workflows/backend.yml"
+ frontend:
+ - "apps/frontend/**"
+ - ".github/workflows/frontend.yml"
+ ml:
+ - "ml/**"
+ - "apps/backend/alembic/**"
+ - "apps/backend/app/models/**"
+ - "apps/backend/tests/test_chaine_ml_api.py"
+ - "apps/backend/pyproject.toml"
+ - "apps/backend/uv.lock"
+ - ".github/workflows/ml.yml"
+ airflow:
+ - "etl/airflow/**"
+ - "ml/pyproject.toml"
+ - "ml/uv.lock"
+ - "ml/enervision_ml/**"
+ - "apps/backend/pyproject.toml"
+ - "apps/backend/uv.lock"
+ - "apps/backend/app/**"
+ - ".github/workflows/airflow.yml"
+ terraform:
+ - "infra/terraform/**"
+ - ".github/workflows/infra.yml"
+ compose:
+ - "docker-compose*.yml"
+ - ".env.example"
+ - "infra/front/**"
+ - "monitoring/**"
+ - ".github/workflows/infra.yml"
+ workflows:
+ - ".github/**"
+ e2e:
+ - "apps/frontend/**"
+ - "apps/backend/app/**"
+ - "apps/backend/alembic/**"
+ - "apps/backend/Dockerfile"
+ - "apps/backend/pyproject.toml"
+ - "apps/backend/uv.lock"
+ - "infra/proxy/**"
+ - "docker-compose*.yml"
+ - "db/**"
+ - "tests/**"
+ - "scripts/comptes-test.sh"
+ - "scripts/tls-selfsigned.sh"
+ - "Makefile"
+ - ".env.example"
+ - ".github/workflows/e2e.yml"
+ sonar:
+ - "apps/backend/**"
+ - "apps/frontend/**"
+ - "ml/**"
+ - "etl/airflow/**"
+ - "sonar-project.properties"
+
+ backend:
+ name: Backend
+ needs: changes
+ if: needs.changes.outputs.backend == 'true'
+ uses: ./.github/workflows/backend.yml
+
+ frontend:
+ name: Frontend
+ needs: changes
+ if: needs.changes.outputs.frontend == 'true'
+ uses: ./.github/workflows/frontend.yml
+
+ ml:
+ name: ML
+ needs: changes
+ if: needs.changes.outputs.ml == 'true'
+ uses: ./.github/workflows/ml.yml
+
+ airflow:
+ name: Airflow
+ needs: changes
+ if: needs.changes.outputs.airflow == 'true'
+ uses: ./.github/workflows/airflow.yml
+
+ infra:
+ name: Infra
+ needs: changes
+ if: >-
+ needs.changes.outputs.terraform == 'true'
+ || needs.changes.outputs.compose == 'true'
+ || needs.changes.outputs.workflows == 'true'
+ uses: ./.github/workflows/infra.yml
+ with:
+ terraform: ${{ needs.changes.outputs.terraform == 'true' }}
+ compose: ${{ needs.changes.outputs.compose == 'true' }}
+ workflows: ${{ needs.changes.outputs.workflows == 'true' }}
+
+ e2e:
+ name: E2E
+ needs: changes
+ if: needs.changes.outputs.e2e == 'true'
+ uses: ./.github/workflows/e2e.yml
+
+ # Ni dependabot[bot] ni une PR de fork ne reçoivent SONAR_TOKEN : le scan échouerait sans rien
+ # analyser. Tests et couverture restent joués par leurs jobs.
+ sonar:
+ name: SonarQube
+ needs: [changes, backend, frontend, ml]
+ if: >-
+ always() && !cancelled()
+ && !contains(needs.*.result, 'failure')
+ && needs.changes.outputs.sonar == 'true'
+ && github.actor != 'dependabot[bot]'
+ && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ steps:
+ - name: Récupère le dépôt
+ uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+
+ # Un téléchargement par rapport : backend et ML nomment tous deux le leur `coverage.xml`.
+ - name: Couverture du backend
+ if: needs.backend.result == 'success'
+ uses: actions/download-artifact@v8
+ with:
+ name: backend-coverage
+ path: apps/backend
+
+ - name: Couverture du pipeline ML
+ if: needs.ml.result == 'success'
+ uses: actions/download-artifact@v8
+ with:
+ name: ml-coverage
+ path: ml
+
+ - name: Couverture du frontend
+ if: needs.frontend.result == 'success'
+ uses: actions/download-artifact@v8
+ with:
+ name: frontend-coverage
+ path: apps/frontend/coverage/frontend
+
+ # Action tierce, épinglée sur le commit du tag (règle Sonar githubactions:S7637).
+ - name: Analyse SonarQube
+ uses: SonarSource/sonarqube-scan-action@ba9859eae8dd6bd29e412f25ddbbef3d032000f4 # v8.2.2
+ env:
+ SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+
+ ci-ok:
+ name: CI ok
+ needs: [changes, backend, frontend, ml, airflow, infra, e2e, sonar]
+ if: always()
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+
+ steps:
+ - name: Refuse si un job a échoué ou a été annulé
+ env:
+ RESULTATS: ${{ toJSON(needs.*.result) }}
+ run: |
+ echo "$RESULTATS"
+ if grep -qE '"(failure|cancelled)"' <<<"$RESULTATS"; then
+ echo "::error::Au moins un job de la CI a échoué ou a été annulé."
+ exit 1
+ fi
+
+ deploy:
+ name: Déploiement
+ needs: ci-ok
+ if: ${{ !cancelled() && needs.ci-ok.result == 'success' && github.event_name == 'push' }}
+ uses: ./.github/workflows/deploy.yml
diff --git a/.github/workflows/dast.yml b/.github/workflows/dast.yml
index 095781e..c3f8922 100644
--- a/.github/workflows/dast.yml
+++ b/.github/workflows/dast.yml
@@ -1,9 +1,9 @@
name: DAST
# Scan dynamique OWASP ZAP de l'API (issue #41). Il attaque une API qui tourne : le job démarre
-# la base et le backend sur le runner, sème un site et quelques relevés (sans ça le scan ne
-# frappe que des gestionnaires d'erreur), crée un compte `lecteur` jetable
-# (scripts/dast-token.sh), puis lance ZAP sur le contrat OpenAPI avec le jeton de ce compte.
+# la base et le backend sur le runner, sème le jeu de démonstration (sans ça le scan ne frappe que
+# des gestionnaires d'erreur), crée des comptes jetables (scripts/dast-token.sh), puis lance ZAP
+# sur le contrat OpenAPI avec le jeton du `lecteur`.
#
# Non bloquant pour l'instant sur les alertes (`continue-on-error` sur la seule étape du scan) :
# le volume d'un premier passage trié est inconnu. Deux étapes suivantes, elles, bloquent si le
@@ -24,6 +24,8 @@ on:
paths:
- ".github/workflows/dast.yml"
- "scripts/dast-token.sh"
+ - "scripts/comptes-test.sh"
+ - "db/seeds/**"
permissions:
contents: read
@@ -77,7 +79,7 @@ jobs:
- name: Installe uv
# Épinglé sur le commit du tag v7 (règle Sonar githubactions:S7637 : dépendance tierce,
# contrairement à actions/checkout ou actions/upload-artifact, premières parties).
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
+ uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
@@ -97,7 +99,7 @@ jobs:
# `apps/backend`, comme dans son Dockerfile. Les `uv run` suivants portent `--frozen
# --no-sync` pour ne rien résoudre ni reconstruire (règle S8544).
- name: Synchronise les dépendances sans dévier du verrou
- run: uv sync --frozen --no-dev --no-install-project --no-build
+ run: uv sync --locked --no-dev --no-install-project --no-build
working-directory: apps/backend
- name: Active TimescaleDB sur la base du scan
@@ -108,22 +110,9 @@ jobs:
working-directory: apps/backend
# Sans données, `GET /sites` rend `[]`, chaque `/{site_id}` rend 404 et le scan actif ne
- # frappe que des gestionnaires d'erreur plutôt que la logique métier. `db/seeds/` est vide
- # (pas encore d'outillage de jeu de données pour la CI) : un site et deux relevés à la main,
- # juste assez pour que les routes de lecture aient quelque chose à rendre.
- - name: Insère un site et des relevés minimaux pour le scan
- run: |
- psql -h localhost -p 5433 -U enervision -d enervision_dast <<'SQL'
- INSERT INTO site (site_id, site_name, site_type, location, capacity_kw, status)
- VALUES ('dast-site', 'Site du scan DAST', 'bureau', 'CI', 50, 'actif')
- ON CONFLICT (site_id) DO NOTHING;
-
- INSERT INTO reading (site_id, timestamp, source, consumption_kw, consumption_kwh, is_working_hours, data_quality, raw_data)
- VALUES
- ('dast-site', now() - interval '2 hours', 'api_current', 12.5, 12.5, true, 'good', '{}'),
- ('dast-site', now() - interval '1 hour', 'api_current', 13.0, 13.0, true, 'good', '{}')
- ON CONFLICT DO NOTHING;
- SQL
+ # frappe que des gestionnaires d'erreur plutôt que la logique métier.
+ - name: Sème le jeu de démonstration
+ run: psql -h localhost -p 5433 -U enervision -d enervision_dast -v ON_ERROR_STOP=1 -f db/seeds/demo.sql
- name: Démarre l'API
run: |
@@ -299,9 +288,11 @@ jobs:
if: always()
run: |
if [ -f zap-out/zap-report.md ]; then
- awk '/^## Alert Detail/{exit} {print}' zap-out/zap-report.md >> "$GITHUB_STEP_SUMMARY"
- echo "" >> "$GITHUB_STEP_SUMMARY"
- echo "Rapport complet (HTML/JSON/Markdown) dans l'artefact \`zap-report\`." >> "$GITHUB_STEP_SUMMARY"
+ {
+ awk '/^## Alert Detail/{exit} {print}' zap-out/zap-report.md
+ echo ""
+ echo "Rapport complet (HTML/JSON/Markdown) dans l'artefact \`zap-report\`."
+ } >> "$GITHUB_STEP_SUMMARY"
else
echo "Aucun rapport ZAP produit, voir le journal du job." >> "$GITHUB_STEP_SUMMARY"
fi
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 411e164..b5f057b 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -1,57 +1,74 @@
# Pourquoi : le runner tourne sur la VM ENI, adresse privée que les runners hébergés par GitHub
# ne joignent pas, et travaille dans un dossier stable par environnement plutôt que dans son
# espace de travail : `.env`, certificats et volumes y survivent d'un déploiement à l'autre.
+# Pourquoi : appelé par ci.yml une fois « CI ok » vert, jamais directement par un push, et il
+# déploie `GITHUB_SHA`, le commit testé, pas la pointe de branche du moment (ADR 0014).
+# Pourquoi : `main` va en prod, `dev` en recette, et toute autre branche lancée à la main
+# (workflow_dispatch) va dans `dev`, la vitrine d'une branche de travail (ADR 0017).
# Piège : jamais de déclencheur `pull_request` ici. Sur un dépôt public, une PR de fork
# exécuterait son code sur la machine de production (ADR 0009) - job deploy.
+# Piège : les CI de deux push finissent parfois dans le désordre. Un commit qui précède celui déjà
+# déployé depuis la même branche est ignoré, et le verrou est un `flock` sur le dossier de
+# l'environnement plutôt qu'un groupe `concurrency` : GitHub n'y garde qu'un job en attente, et
+# le suivant l'évince sans bruit.
name: Déploiement
on:
- push:
- branches: [dev, main]
+ workflow_call:
workflow_dispatch:
permissions:
contents: read
-concurrency:
- group: deploy-${{ github.ref_name }}
- cancel-in-progress: false
-
jobs:
deploy:
+ name: Déploie sur la VM
runs-on: [self-hosted, linux, eni-g3]
timeout-minutes: 30
environment:
- name: ${{ github.ref_name == 'main' && 'prod' || 'rec' }}
- url: ${{ github.ref_name == 'main' && 'https://enervision.local' || 'https://rec.enervision.local:8443' }}
+ name: ${{ github.ref_name == 'main' && 'prod' || github.ref_name == 'dev' && 'rec' || 'dev' }}
+ url: ${{ github.ref_name == 'main' && 'https://prod.enervision-g3.dynv6.net' || github.ref_name == 'dev' && 'https://rec.enervision-g3.dynv6.net' || 'https://dev.enervision-g3.dynv6.net' }}
env:
- ENVIRONNEMENT: ${{ github.ref_name == 'main' && 'prod' || 'rec' }}
- PORT_HTTPS: ${{ github.ref_name == 'main' && '443' || '8443' }}
+ ENVIRONNEMENT: ${{ github.ref_name == 'main' && 'prod' || github.ref_name == 'dev' && 'rec' || 'dev' }}
+ PORT_HTTPS: ${{ github.ref_name == 'main' && '10443' || github.ref_name == 'dev' && '8443' || '9443' }}
steps:
- - name: Aligner le dossier de l'environnement sur la branche poussée
+ # Un seul step : le verrou tombe avec le shell qui l'a posé.
+ - name: Déploie le commit testé, sans jamais reculer
run: |
cd "/srv/enervision/${ENVIRONNEMENT}"
+ exec 9>"$(git rev-parse --git-dir)/verrou-deploiement"
+ flock 9
+
+ echo "::group::Aligne le dossier de l'environnement sur le commit testé"
git fetch --quiet origin "${GITHUB_REF_NAME}"
+ deploye="$(git rev-parse HEAD)"
+ if [ "$(git branch --show-current)" = "$GITHUB_REF_NAME" ] && [ "$deploye" != "$GITHUB_SHA" ] \
+ && git merge-base --is-ancestor "$GITHUB_SHA" "$deploye"; then
+ echo "::notice::${GITHUB_SHA:0:7} précède le commit déjà déployé (${deploye:0:7}) : rien à déployer."
+ exit 0
+ fi
git checkout --quiet "${GITHUB_REF_NAME}"
- git reset --quiet --hard "origin/${GITHUB_REF_NAME}"
+ git reset --quiet --hard "${GITHUB_SHA}"
git log -1 --format='%h %s'
+ echo "::endgroup::"
- - name: Reconstruire et redémarrer la stack
- run: |
- cd "/srv/enervision/${ENVIRONNEMENT}"
+ echo "::group::Reconstruit et redémarre la stack"
+ # Un `.env` pas encore réaligné par provision-host.sh porte encore un nom en `.local`.
+ if [ -r ../dns.token ] && ! grep -q '^PUBLIC_HOST=.*\.local$' .env; then make tls-dns01; fi
make stack-up
+ if [ "${ENVIRONNEMENT}" = prod ]; then make front-up; fi
+ echo "::endgroup::"
- - name: Attendre que l'API réponde derrière le proxy
- run: |
- for tentative in $(seq 1 36); do
+ echo "::group::Attend que l'API réponde derrière le proxy"
+ for _ in $(seq 1 36); do
if curl --fail --silent --insecure "https://localhost:${PORT_HTTPS}/api/v1/health/ready"; then
exit 0
fi
sleep 5
done
+ echo "::endgroup::"
echo "L'API ne répond pas après 3 minutes" >&2
- cd "/srv/enervision/${ENVIRONNEMENT}"
compose="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
$compose ps
$compose logs --tail=50 backend proxy
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
new file mode 100644
index 0000000..1a7fec5
--- /dev/null
+++ b/.github/workflows/e2e.yml
@@ -0,0 +1,135 @@
+name: E2E
+
+# Pourquoi : les parcours tournent contre la stack telle qu'elle est déployée, derrière le proxy
+# TLS (cookie `__Secure-`, CSP, limitation de débit), pas contre `ng serve` - job parcours. Il
+# construit aussi les images backend et frontend, que rien d'autre ne construit avant le
+# déploiement (ADR 0015).
+# Piège : pas d'Airflow ici. `up` nomme ses services : sans eux, la construction de l'image
+# Airflow doublerait la durée du job sans rien tester de plus.
+
+on:
+ workflow_call:
+
+permissions:
+ contents: read
+
+jobs:
+ parcours:
+ name: Parcours Playwright et tirs k6
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ env:
+ COMPOSE_FILE: docker-compose.yml:docker-compose.prod.yml
+ PUBLIC_HOST: localhost
+ E2E_BASE_URL: https://localhost
+
+ steps:
+ - name: Récupère le dépôt
+ uses: actions/checkout@v7
+
+ - name: Prépare le .env de la stack
+ run: |
+ secret() { openssl rand -hex 32; }
+ sed -e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(secret)|" \
+ -e "s|^APP_SECRET_KEY=.*|APP_SECRET_KEY=$(secret)|" \
+ -e "s|^PUBLIC_HOST=.*|PUBLIC_HOST=localhost|" \
+ .env.example > .env
+
+ - name: Génère le certificat de démonstration
+ run: ./scripts/tls-selfsigned.sh
+
+ - name: Construit et démarre la stack derrière le proxy
+ run: docker compose up --detach --build --wait --wait-timeout 300 db mailpit backend frontend proxy
+
+ - name: Applique les migrations
+ run: docker compose exec -T backend alembic upgrade head
+
+ # Même cible que `make stack-up` en prod : les droits du rôle portent sur le schéma réel.
+ - name: Pose le rôle de supervision en lecture seule
+ run: make db-ensure-supervision
+
+ - name: Sème le jeu de démonstration
+ run: docker compose exec -T db psql -U enervision -d enervision -v ON_ERROR_STOP=1 < db/seeds/demo.sql
+
+ - name: Crée les comptes de test
+ env:
+ BASE_URL: https://localhost
+ APP_CLI: docker compose exec -T backend python -m app.cli
+ COMPTES_FICHIER: ${{ runner.temp }}/comptes.json
+ run: ./scripts/comptes-test.sh
+
+ - name: Installe Node
+ uses: actions/setup-node@v7
+ with:
+ node-version: 26
+ cache: npm
+ cache-dependency-path: tests/e2e/package-lock.json
+
+ - name: Installe Playwright
+ working-directory: tests/e2e
+ run: npm ci
+
+ - name: Restaure les navigateurs de Playwright
+ uses: actions/cache@v6
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-${{ runner.os }}-${{ hashFiles('tests/e2e/package-lock.json') }}
+
+ # `--with-deps` tourne même quand le cache a servi : il pose aussi les bibliothèques système.
+ - name: Installe Chromium
+ working-directory: tests/e2e
+ run: npx playwright install --with-deps chromium
+
+ - name: Joue les parcours
+ working-directory: tests/e2e
+ env:
+ E2E_COMPTES: ${{ runner.temp }}/comptes.json
+ run: npx playwright test
+
+ # Direct sur `backend:8000` : ce tir mesure l'API, pas la limitation de nginx.
+ - name: Tir k6 de fumée sur l'API
+ env:
+ K6_RESUME: /results/resume-smoke.md
+ run: |
+ K6_EMAIL="$(jq -r .lecteur.email "$RUNNER_TEMP/comptes.json")"
+ K6_PASSWORD="$(jq -r .lecteur.password "$RUNNER_TEMP/comptes.json")"
+ echo "::add-mask::$K6_PASSWORD"
+ export K6_EMAIL K6_PASSWORD
+ make load-smoke
+
+ - name: Vérifie par k6 que le proxy limite le débit
+ env:
+ K6_RESUME: /results/resume-limitation.md
+ run: make load-limits
+
+ - name: Publie la synthèse k6
+ if: ${{ !cancelled() }}
+ run: cat tests/load/results/resume-*.md >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
+
+ - name: Publie les rapports k6
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v7
+ with:
+ name: k6-rapports
+ path: tests/load/results/
+ if-no-files-found: ignore
+ retention-days: 14
+
+ - name: Publie le rapport Playwright
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v7
+ with:
+ name: playwright-report
+ path: |
+ tests/e2e/playwright-report/
+ tests/e2e/test-results/
+ if-no-files-found: ignore
+ retention-days: 14
+
+ - name: Journaux de la stack en cas d'échec
+ if: failure()
+ run: docker compose logs --tail=200 backend proxy frontend
+
+ - name: Arrête la stack
+ if: always()
+ run: docker compose down --volumes
diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml
index 1ba4010..075cfc7 100644
--- a/.github/workflows/frontend.yml
+++ b/.github/workflows/frontend.yml
@@ -1,64 +1,70 @@
name: Frontend
+# Pourquoi : aucun déclencheur propre. ci.yml appelle ce workflow quand le frontend change, et
+# Sonar y reprend la couverture versée par le job `verification` (ADR 0014).
+
on:
- push:
- paths:
- - "apps/frontend/**"
- - ".github/workflows/frontend.yml"
- pull_request:
- paths:
- - "apps/frontend/**"
- - ".github/workflows/frontend.yml"
+ workflow_call:
permissions:
contents: read
jobs:
- build:
+ # Un seul `npm ci` pour la construction et les tests : un job de plus ne ferait que le rejouer.
+ verification:
+ name: Construction et tests
runs-on: ubuntu-latest
+ timeout-minutes: 15
+ defaults:
+ run:
+ working-directory: apps/frontend
+
steps:
- - uses: actions/checkout@v7
- - uses: actions/setup-node@v7
+ - name: Récupère le dépôt
+ uses: actions/checkout@v7
+
+ - name: Installe Node
+ uses: actions/setup-node@v7
with:
node-version: 26
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- - run: npm ci
- working-directory: apps/frontend
- - run: npm run build
- working-directory: apps/frontend
+ - name: Installe les dépendances
+ run: npm ci
- security-audit:
- name: Audit des dépendances
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v7
- - uses: actions/setup-node@v7
- with:
- node-version: 26
- # Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison.
- - run: npm audit --audit-level=high --package-lock-only
- working-directory: apps/frontend
+ - name: Construit l'application
+ run: npm run build
- test:
- needs: build
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v7
- - uses: actions/setup-node@v7
- with:
- node-version: 26
- cache: npm
- cache-dependency-path: apps/frontend/package-lock.json
- - name : Installation des dépendances (Front)
- run: npm ci
- working-directory: apps/frontend
- - name : Lancement des tests et génénration du rapport de couverture (Front)
- run: npm test --watch=false --code-coverage --coverageReporters=lcov
- working-directory: apps/frontend
- - name: Upload coverage
+ # Piège : `npm test --watch=false` garde l'option pour npm, `ng test` ne la reçoit jamais.
+ # La couverture lcov vient d'angular.json (`coverage: true`).
+ - name: Tests et couverture
+ run: npm run test:ci
+
+ - name: Verse la couverture pour Sonar
uses: actions/upload-artifact@v7
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info
+ if-no-files-found: error
+
+ security-audit:
+ name: Audit des dépendances
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ defaults:
+ run:
+ working-directory: apps/frontend
+
+ steps:
+ - name: Récupère le dépôt
+ uses: actions/checkout@v7
+
+ - name: Installe Node
+ uses: actions/setup-node@v7
+ with:
+ node-version: 26
+
+ # Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison.
+ - name: Audite le verrou
+ run: npm audit --audit-level=high --package-lock-only
diff --git a/.github/workflows/infra.yml b/.github/workflows/infra.yml
index a49088e..3c7f221 100644
--- a/.github/workflows/infra.yml
+++ b/.github/workflows/infra.yml
@@ -1,38 +1,40 @@
name: Infra
-# Pourquoi : le Terraform du dépôt est resté cassé sans que rien ne le dise, faute de job qui le
-# joue. Ce workflow n'applique rien : il vérifie le formatage et la validité de chaque racine.
-# Piège : la boucle parcourt `environments/*`, pour qu'une racine ajoutée soit couverte sans
-# toucher à ce fichier.
+# Pourquoi : rien de ce qui décrit l'infrastructure ne s'exécute avant le déploiement. Terraform est
+# resté cassé sans que rien ne le dise, faute de job qui le joue : ce workflow n'applique rien, il
+# vérifie le Terraform, les fichiers Compose et les workflows eux-mêmes - jobs terraform, compose,
+# workflows. ci.yml choisit par ses entrées ceux qui tournent (ADR 0014).
+# Piège : la boucle Terraform parcourt `environments/*`, pour qu'une racine ajoutée soit couverte
+# sans toucher à ce fichier.
on:
- push:
- paths:
- - "infra/terraform/**"
- - ".github/workflows/infra.yml"
- pull_request:
- paths:
- - "infra/terraform/**"
- - ".github/workflows/infra.yml"
+ workflow_call:
+ inputs:
+ terraform:
+ type: boolean
+ default: false
+ compose:
+ type: boolean
+ default: false
+ workflows:
+ type: boolean
+ default: false
permissions:
contents: read
-concurrency:
- group: infra-${{ github.ref }}
- cancel-in-progress: true
-
jobs:
terraform:
name: Formatage et validation Terraform
+ if: inputs.terraform
runs-on: ubuntu-latest
+ timeout-minutes: 10
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
- # Action tierce, donc epinglee sur un SHA de commit et pas sur un tag mobile : un tag se
- # redeplace, et ce workflow tourne avec les droits du depot (regle Sonar githubactions:S7637).
+ # Action tierce, épinglée sur le commit du tag (règle Sonar githubactions:S7637).
- name: Installe Terraform
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
@@ -50,3 +52,56 @@ jobs:
terraform -chdir="${racine}" validate
echo "::endgroup::"
done
+
+ compose:
+ name: Validation des fichiers Compose et de la supervision
+ if: inputs.compose
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ steps:
+ - name: Récupère le dépôt
+ uses: actions/checkout@v7
+
+ # Compose interpole tout le fichier : les `:?` exigent une valeur, pas un vrai secret.
+ - name: Prépare un .env d'exemple
+ run: cp .env.example .env
+
+ - name: Valide la stack de développement
+ run: docker compose config --quiet
+
+ - name: Valide la stack déployée, profils compris
+ run: docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile acme --profile monitoring --profile load config --quiet
+
+ - name: Valide le frontal SNI de la VM
+ run: |
+ docker compose -f infra/front/compose.yml config --quiet
+ docker run --rm -v "$PWD/infra/front/nginx.conf:/etc/nginx/nginx.conf:ro" nginx:1.31-alpine nginx -t
+
+ # Mêmes commandes que `make monitoring-check` : images et montages viennent du fichier Compose.
+ - name: Valide la configuration de Prometheus et ses règles
+ run: docker compose --profile monitoring run --rm --no-deps --entrypoint promtool prometheus check config /etc/prometheus/prometheus.yml
+
+ - name: Joue les tests unitaires des règles d'alerte
+ run: docker compose --profile monitoring run --rm --no-deps --entrypoint promtool prometheus test rules /etc/prometheus/tests/enervision.test.yml
+
+ - name: Valide la configuration d'Alertmanager
+ run: docker compose --profile monitoring run --rm --no-deps --entrypoint amtool alertmanager check-config /etc/alertmanager/alertmanager.yml
+
+ - name: Valide les tableaux de bord Grafana
+ run: for tableau in monitoring/grafana/dashboards/*.json; do jq empty "$tableau"; done
+
+ workflows:
+ name: Analyse des workflows
+ if: inputs.workflows
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ steps:
+ - name: Récupère le dépôt
+ uses: actions/checkout@v7
+
+ # Image épinglée par tag, comme les images des fichiers Compose. Elle embarque shellcheck,
+ # qui analyse aussi les blocs `run:`.
+ - name: actionlint
+ run: docker run --rm -v "$PWD:/repo" --workdir /repo rhysd/actionlint:1.7.12 -color
diff --git a/.github/workflows/ml.yml b/.github/workflows/ml.yml
index 4145c4e..1391136 100644
--- a/.github/workflows/ml.yml
+++ b/.github/workflows/ml.yml
@@ -2,46 +2,20 @@ name: ML
# Piège : la version de Python vient de ml/.python-version, et doit rester en 3.14 (cf.
# .github/workflows/backend.yml, même contrainte).
+# Pourquoi : aucun déclencheur propre. ci.yml l'appelle aussi quand les migrations ou les modèles
+# du backend changent, dont dépend le job `integration` (ADR 0014).
on:
- push:
- paths:
- - "ml/**"
- - ".github/workflows/ml.yml"
- # Le job `integration` monte son schema avec les migrations du backend et joue le test de
- # chaine qui vit dans ses tests : sans ces chemins, une migration modifiee ne declencherait
- # rien et le schema deriverait du SQL du pipeline sans que rien ne casse. Meme raisonnement
- # que le filtre d'airflow.yml, qui inclut deja des chemins de ml/ et de apps/backend/.
- - "apps/backend/alembic/**"
- - "apps/backend/app/models/**"
- - "apps/backend/tests/test_chaine_ml_api.py"
- - "apps/backend/pyproject.toml"
- - "apps/backend/uv.lock"
- pull_request:
- paths:
- - "ml/**"
- - ".github/workflows/ml.yml"
- # Le job `integration` monte son schema avec les migrations du backend et joue le test de
- # chaine qui vit dans ses tests : sans ces chemins, une migration modifiee ne declencherait
- # rien et le schema deriverait du SQL du pipeline sans que rien ne casse. Meme raisonnement
- # que le filtre d'airflow.yml, qui inclut deja des chemins de ml/ et de apps/backend/.
- - "apps/backend/alembic/**"
- - "apps/backend/app/models/**"
- - "apps/backend/tests/test_chaine_ml_api.py"
- - "apps/backend/pyproject.toml"
- - "apps/backend/uv.lock"
+ workflow_call:
permissions:
contents: read
-concurrency:
- group: ml-${{ github.ref }}
- cancel-in-progress: true
-
jobs:
verification:
name: Lint, typage et tests
runs-on: ubuntu-latest
+ timeout-minutes: 15
defaults:
run:
working-directory: ml
@@ -50,17 +24,19 @@ jobs:
- name: Récupère le dépôt
uses: actions/checkout@v7
+ # Action tierce, épinglée sur le commit du tag (règle Sonar githubactions:S7637).
- name: Installe uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
cache-dependency-glob: ml/uv.lock
+ prune-cache: false
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
- - name: Synchronise les dépendances sans dévier du verrou
- run: uv sync --all-groups --frozen
+ - name: Synchronise les dépendances sur le verrou
+ run: uv sync --all-groups --locked
- name: Vérifie le formatage
run: uv run ruff format --check .
@@ -71,17 +47,24 @@ jobs:
- name: Typage
run: uv run mypy enervision_ml tests
- # Les tests exigeant une base portent le marqueur `integration`, ecarte par defaut et
- # joue par le job `integration` ci-dessous.
- - name: Tests
- run: uv run pytest
+ # Les tests exigeant une base portent le marqueur `integration`, écarté par défaut et
+ # joué par le job `integration` ci-dessous.
+ - name: Tests et couverture
+ run: uv run pytest --cov-report=xml
- # Le seul job du depot qui dispose a la fois des deux environnements uv et d'une base. Piege :
- # le schema de la base ML est celui du backend (apps/backend/alembic, proprietaire du schema).
- # Le reconstruire ici a la main rendrait ce job vert sur une base qui n'est pas la notre.
+ - name: Verse la couverture pour Sonar
+ uses: actions/upload-artifact@v7
+ with:
+ name: ml-coverage
+ path: ml/coverage.xml
+ if-no-files-found: error
+
+ # Piège : le schéma de la base ML est celui du backend (apps/backend/alembic, propriétaire du
+ # schéma). Le reconstruire ici à la main rendrait ce job vert sur une base qui n'est pas la nôtre.
integration:
name: ML - DB et chaîne ML - DB - API
runs-on: ubuntu-latest
+ timeout-minutes: 20
services:
db:
@@ -112,26 +95,27 @@ jobs:
uses: actions/checkout@v7
- name: Installe uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
cache-dependency-glob: |
ml/uv.lock
apps/backend/uv.lock
+ prune-cache: false
- name: Installe l'interpréteur déclaré par .python-version
working-directory: ml
run: uv python install
- - name: Synchronise le pipeline ML sans dévier du verrou
+ - name: Synchronise le pipeline ML sur le verrou
working-directory: ml
- run: uv sync --all-groups --frozen
+ run: uv sync --all-groups --locked
# Le backend est installé ici parce qu'il porte les migrations, seule source du schéma, et
# le test de chaîne, qui interroge l'API.
- - name: Synchronise le backend sans dévier du verrou
+ - name: Synchronise le backend sur le verrou
working-directory: apps/backend
- run: uv sync --all-groups --frozen
+ run: uv sync --all-groups --locked
# db/init/110-test-database.sql n'est pas monté ici, et sans l'extension la première
# révision Alembic refuse de s'appliquer.
@@ -142,8 +126,8 @@ jobs:
working-directory: apps/backend
run: uv run alembic upgrade head
- # `-m` en ligne de commande écrase celui d'addopts. Couverture désactivée : ce job ne joue
- # qu'une partie de la suite, son taux n'aurait pas de sens (même raison que backend.yml).
+ # Couverture désactivée : ce job ne joue qu'une partie de la suite, son taux n'aurait pas
+ # de sens (même raison que backend.yml).
- name: Tests ML exigeant une base
working-directory: ml
run: uv run pytest -m integration --no-cov
@@ -159,6 +143,7 @@ jobs:
sast:
name: Analyse statique de sécurité
runs-on: ubuntu-latest
+ timeout-minutes: 10
defaults:
run:
working-directory: ml
@@ -170,7 +155,9 @@ jobs:
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
- name: Installe uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
+ with:
+ enable-cache: false
- name: Analyse le code livré (bloquant à partir de MEDIUM)
run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium
diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml
deleted file mode 100644
index 4544c4e..0000000
--- a/.github/workflows/sonarqube.yml
+++ /dev/null
@@ -1,172 +0,0 @@
-name: SonarQube
-
-on:
- push:
- paths:
- - "apps/frontend/**"
- - "apps/backend/**"
- - "ml/**"
- - "etl/airflow/**"
- - ".github/workflows/sonarqube.yml"
- pull_request:
- paths:
- - "apps/frontend/**"
- - "apps/backend/**"
- - "ml/**"
- - "etl/airflow/**"
- - ".github/workflows/sonarqube.yml"
-
-
-# Build l'ensemble du projet, puis lance les tests
-# Génère les rapports de couverture, puis lance l'analyse SonarQube
-
-jobs:
- build-front:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v7
- - uses: actions/setup-node@v7
- with:
- node-version: 26
- cache: npm
- cache-dependency-path: apps/frontend/package-lock.json
-
- - run: npm ci
- working-directory: apps/frontend
- - run: npm run build
- working-directory: apps/frontend
-
- test-front:
- needs: build-front
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v7
- - uses: actions/setup-node@v7
- with:
- node-version: 26
- cache: npm
- cache-dependency-path: apps/frontend/package-lock.json
-
- - name : Installation des dépendances (Front)
- run: npm ci
- working-directory: apps/frontend
-
- - name : Lancement des tests et génénration du rapport de couverture (Front)
- run: npm test --watch=false --code-coverage --coverageReporters=lcov
- working-directory: apps/frontend
-
- - name: Upload coverage
- uses: actions/upload-artifact@v7
- with:
- name: frontend-coverage
- path: apps/frontend/coverage/frontend/lcov.info
-
- build-back:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v7
- - name: Installe uv
- uses: astral-sh/setup-uv@v7
- with:
- enable-cache: true
- cache-dependency-glob: apps/backend/uv.lock
- - name: Installe l'interpréteur déclaré par .python-version
- run: uv python install
- working-directory: apps/backend
-
- - name: Synchronise les dépendances sans dévier du verrou
- run: uv sync --all-groups --frozen
- working-directory: apps/backend
-
- - name: Vérifie le formatage
- run: uv run ruff format --check .
- working-directory: apps/backend
-
- - name: Analyse statique
- run: uv run ruff check --output-format=github .
- working-directory: apps/backend
-
- - name: Typage
- run: uv run mypy app
- working-directory: apps/backend
-
-
- test-back:
- needs: build-back
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v7
- - name: Installe uv
- uses: astral-sh/setup-uv@v7
- with:
- enable-cache: true
- cache-dependency-glob: apps/backend/uv.lock
-
- - name : Lancement des tests et génénration du rapport de couverture (Back)
- run: uv run pytest --cov-fail-under=85 --cov-report=xml
- working-directory: apps/backend
-
- - name: Upload coverage
- uses: actions/upload-artifact@v7
- with:
- name: backend-coverage
- path: apps/backend/coverage.xml
-
- test-ml:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v7
- - name: Installe uv
- uses: astral-sh/setup-uv@v7
- with:
- enable-cache: true
- cache-dependency-glob: ml/uv.lock
-
- - name: Installe l'interpréteur déclaré par .python-version
- run: uv python install
- working-directory: ml
-
- - name: Synchronise les dépendances sans dévier du verrou
- run: uv sync --all-groups --frozen
- working-directory: ml
-
- - name: Lancement des tests et génération du rapport de couverture (ML)
- run: uv run pytest --cov-report=xml
- working-directory: ml
-
- - name: Upload coverage
- uses: actions/upload-artifact@v7
- with:
- name: ml-coverage
- path: ml/coverage.xml
-
- sonarqube:
- needs: [build-front, build-back, test-front, test-back, test-ml]
- name: SonarQube
- # Pourquoi : GitHub ne fournit pas les secrets aux workflows lancés par dependabot[bot].
- # Sans SONAR_TOKEN le scan échoue sans rien analyser ; build et tests restent joués.
- if: github.actor != 'dependabot[bot]'
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v7
- with:
- fetch-depth: 0
- - name: Téléchargement du rapport de couverture (Front)
- uses: actions/download-artifact@v8
- with:
- name: frontend-coverage
- path: apps/frontend/coverage/frontend
- - name: Téléchargement du rapport de couverture (Back)
- uses: actions/download-artifact@v8
- with:
- name: backend-coverage
- path: apps/backend
- - name: Téléchargement du rapport de couverture (ML)
- uses: actions/download-artifact@v8
- with:
- name: ml-coverage
- path: ml
- - name: SonarQube Scan
- uses: SonarSource/sonarqube-scan-action@v8
- env:
- SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
diff --git a/.gitignore b/.gitignore
index c15d9ae..a0a805b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,12 @@ apps/frontend/.angular/
npm-debug.log*
yarn-error.log*
+# Tests de bout en bout et de charge : rapports générés et identifiants des comptes de test
+playwright-report/
+blob-report/
+tests/e2e/.comptes.json
+tests/load/results/
+
# Terraform
.terraform/
# .terraform.lock.hcl est versionne (pas ignore) pour figer les versions de provider entre contributeurs/CI
@@ -54,8 +60,6 @@ secrets/
data/raw/*
!data/raw/.gitkeep
*.sqlite3
-monitoring/grafana/data/
-monitoring/prometheus/data/
# ML : jeu de donnees, modeles entraines et suivi MLflow local, tous generes/volumineux
ml/data/
@@ -71,6 +75,7 @@ etl/airflow/tests/.airflow_home/
# TLS : certificats du reverse proxy, générés par script ou par certbot
infra/proxy/tls/*.pem
+infra/proxy/acme/
# IDE et OS
.idea/
diff --git a/Makefile b/Makefile
index 9a270f8..9d1e036 100644
--- a/Makefile
+++ b/Makefile
@@ -2,6 +2,7 @@ BACKEND := apps/backend
FRONTEND := apps/frontend
ML := ml
AIRFLOW := etl/airflow
+E2E := tests/e2e
COMPOSE_PROD := docker compose -f docker-compose.yml -f docker-compose.prod.yml
# Piège : sans `export`, une valeur passée en ligne de commande n'atteindrait pas docker compose.
@@ -34,6 +35,31 @@ PG_TEST_DB ?= enervision_test
TEST_DATABASE_URL ?= postgresql+asyncpg://$(PG_USER):$(PG_PASSWORD)@localhost:$(PG_PORT)/$(PG_TEST_DB)
ML_TEST_DATABASE_URL ?= postgresql+psycopg://$(PG_USER):$(PG_PASSWORD)@localhost:$(PG_PORT)/$(PG_TEST_DB)
+# Piege : ni make ni ces cibles ne lisent `.env` pour COMPOSE_PROFILES, que docker compose y lit
+# seul. `stack-up` le relit ici pour savoir s'il doit poser le role `supervision` apres migration.
+SUPERVISION := $(findstring monitoring,$(COMPOSE_PROFILES) $(call env-val,COMPOSE_PROFILES))
+SERVICES_SUPERVISION := prometheus alertmanager grafana postgres-exporter node-exporter cadvisor
+GRAFANA_PORT := $(or $(strip $(call env-val,GRAFANA_PORT)),3001)
+PROMETHEUS_PORT := $(or $(strip $(call env-val,PROMETHEUS_PORT)),9090)
+supervision-garde = for cle in APP_METRICS_TOKEN GRAFANA_ADMIN_PASSWORD SUPERVISION_DB_PASSWORD; do \
+ sed -n "s/^$$cle=//p" .env 2>/dev/null | tail -1 | grep -q . \
+ || { echo "$$cle manquant dans .env, requis par la supervision (cf. .env.example)"; exit 1; }; \
+ done
+MONITORING := docker compose --profile monitoring
+PROMTOOL := $(MONITORING) run --rm --no-deps --entrypoint promtool prometheus
+
+# Piege : `e2e-prepare` ajoute trois sites `demo-*` et des comptes `test-*` a la base visee. Elle
+# vise la base de `make dev` ; ne jamais la lancer contre la recette ou la prod.
+E2E_COMPTES ?= $(CURDIR)/$(E2E)/.comptes.json
+E2E_API ?= http://localhost:$(or $(strip $(call env-val,BACKEND_PORT)),8000)
+
+# Piege : `run` ne demarre que k6, la stack doit deja tourner. `--user` fait ecrire les rapports
+# de tests/load/results avec l'uid du poste, pas celui de l'image (12345), qui n'y a pas acces.
+k6-run = mkdir -p tests/load/results && $(COMPOSE_PROD) --profile load run --rm \
+ --user "$$(id -u):$$(id -g)" -e K6_WEB_DASHBOARD=true \
+ -e K6_WEB_DASHBOARD_EXPORT=/results/$(1)-$$(date +%Y%m%dT%H%M%S).html \
+ k6 run /scripts/$(1).js
+
# Le jeu historique s'arrete au 31/12/2024 : score et detection ancres a l'horloge reelle ne
# verraient qu'un parc muet depuis des mois. Cf. `--now` de enervision_ml.score.
DEMO_NOW ?= 2024-12-31T00:00:00Z
@@ -47,10 +73,12 @@ DEMO_NOW ?= 2024-12-31T00:00:00Z
migrate migrate-test bootstrap-admin services-up demo-data demo-data-force \
ml-lint ml-typecheck ml-test ml-check ml-train ml-score mlflow-up detect-alerts recommendations \
airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs \
- tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs
+ tls-selfsigned tls-acme tls-renew tls-dns01 front-up stack-up stack-down stack-logs \
+ e2e-install e2e-prepare e2e load-smoke load-test load-stress load-limits \
+ db-ensure-supervision monitoring-up monitoring-down monitoring-logs monitoring-check
help: ## Liste les cibles disponibles
- @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
+ @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
install: install-backend install-frontend install-ml install-airflow ## Installe les dépendances backend, frontend, ML et Airflow
@@ -183,8 +211,10 @@ stack-up: ## Démarre la stack derrière le reverse proxy, puis migre la base. P
|| { echo "Aucun certificat dans infra/proxy/tls. Lancer d'abord make tls-selfsigned"; exit 1; }
@openssl x509 -in infra/proxy/tls/fullchain.pem -noout -checkhost "$(PUBLIC_HOST)" >/dev/null \
|| { echo "Le certificat ne couvre pas $(PUBLIC_HOST). Relancer make tls-selfsigned PUBLIC_HOST=$(PUBLIC_HOST) FORCE=1"; exit 1; }
+ @$(if $(SUPERVISION),$(supervision-garde),true)
$(COMPOSE_PROD) up -d --build
$(COMPOSE_PROD) exec -T backend alembic upgrade head
+ @$(if $(SUPERVISION),$(MAKE) --no-print-directory db-ensure-supervision,true)
stack-down: ## Arrête la stack complète en conservant les données
$(COMPOSE_PROD) stop
@@ -205,6 +235,82 @@ tls-renew: ## Renouvelle les certificats Let's Encrypt et recharge le proxy
$(COMPOSE_PROD) --profile acme run --rm certbot renew --deploy-hook /deploy-hook.sh
$(COMPOSE_PROD) exec proxy nginx -s reload
+# Pourquoi : la VM n'a qu'une IP privée, que Let's Encrypt ne joint pas ; le défi DNS-01 passe
+# par l'API du fournisseur DNS, dynv6 par défaut (ADR 0018). Le jeton ne passe jamais par `argv`.
+ACME_SH := neilpang/acme.sh:3.1.6
+DNS01_API ?= dns_dynv6
+DNS01_JETON_VAR ?= DYNV6_TOKEN
+DNS01_JETON_FICHIER ?= $(abspath $(CURDIR)/../dns.token)
+acme-sh = docker run --rm --user "$$(id -u):$$(id -g)" -e $(DNS01_JETON_VAR) -e AUTO_UPGRADE=0 \
+ -v "$(CURDIR)/infra/proxy/acme:/acme.sh" -v "$(CURDIR)/infra/proxy/tls:/tls" $(ACME_SH)
+
+# acme.sh sort en 2 quand le certificat n'est pas à renouveler, et recopie le jeton dans
+# acme/account.conf, d'où le chmod. `--dnssleep` : Let's Encrypt valide depuis plusieurs réseaux.
+tls-dns01: ## Certificat Let's Encrypt par DNS-01, renouvelé seulement à échéance. Jeton : ../dns.token
+ @case "$(PUBLIC_HOST)" in *.local | localhost) echo "PUBLIC_HOST=$(PUBLIC_HOST) n'est pas un nom public"; exit 1 ;; esac
+ @test -r "$(DNS01_JETON_FICHIER)" || { echo "Jeton DNS illisible : $(DNS01_JETON_FICHIER)"; exit 1; }
+ @mkdir -p infra/proxy/acme && chmod 700 infra/proxy/acme
+ @$(DNS01_JETON_VAR)="$$(tr -d '[:space:]' < "$(DNS01_JETON_FICHIER)")"; export $(DNS01_JETON_VAR); \
+ $(acme-sh) --issue --server letsencrypt --dns $(DNS01_API) --dnssleep 90 -d "$(PUBLIC_HOST)"; \
+ code=$$?; chmod -R go-rwx infra/proxy/acme; [ $$code -eq 0 ] || [ $$code -eq 2 ] || exit $$code
+ @$(acme-sh) --install-cert --ecc -d "$(PUBLIC_HOST)" \
+ --fullchain-file /tls/fullchain.pem --key-file /tls/privkey.pem
+ @$(COMPOSE_PROD) exec -T proxy nginx -s reload 2>/dev/null \
+ || echo "Proxy arrêté : il lira le certificat à son démarrage"
+
+front-up: ## Démarre ou recharge le frontal SNI de la VM, sur les ports 80 et 443 de l'hôte
+ docker compose -f infra/front/compose.yml up -d
+ docker compose -f infra/front/compose.yml exec -T front nginx -s reload
+
+e2e-install: ## Installe Playwright et Chromium pour les tests de bout en bout
+ cd $(E2E) && npm ci && npx playwright install chromium
+
+e2e-prepare: ## Sème le jeu de démonstration et crée les comptes de test sur la base de `make dev`
+ docker compose exec -T db psql -U $(PG_USER) -d $(PG_DB) -v ON_ERROR_STOP=1 < db/seeds/demo.sql
+ cd $(BACKEND) && BASE_URL=$(E2E_API) COMPTES_FICHIER=$(E2E_COMPTES) ADMIN_SUPPLEMENTAIRE=1 \
+ ../../scripts/comptes-test.sh
+
+e2e: ## Joue les parcours Playwright. E2E_BASE_URL= optionnel (défaut http://localhost:4200)
+ cd $(E2E) && E2E_COMPTES=$(E2E_COMPTES) npx playwright test
+
+load-smoke: ## Tir k6 d'une minute. K6_EMAIL= et K6_PASSWORD= d'un lecteur, K6_BASE_URL= optionnel
+ $(call k6-run,smoke)
+
+load-test: ## Charge nominale k6, 50 utilisateurs pendant 8 minutes. Rapport HTML dans tests/load/results
+ $(call k6-run,charge)
+
+load-stress: ## Monte le débit jusqu'à la rupture de l'API. Sur la VM, la prod partage la machine
+ $(call k6-run,stress)
+
+load-limits: ## Vérifie par le proxy que nginx limite le débit d'une même adresse (429)
+ $(call k6-run,limitation-debit)
+
+# Piege : le mot de passe est lu dans `.env` par le shell et passe a psql sur son entree
+# standard. Developpe par make, il apparaitrait en clair dans la ligne de commande (`ps`).
+db-ensure-supervision: ## Crée ou réaligne le rôle `supervision`, en lecture seule, de Grafana et de l'exportateur
+ @mdp="$$(sed -n 's/^SUPERVISION_DB_PASSWORD=//p' .env 2>/dev/null | tail -1)"; \
+ [ -n "$$mdp" ] || { echo "SUPERVISION_DB_PASSWORD manquant dans .env"; exit 1; }; \
+ { printf '\\set mot_de_passe %s\n' "$$mdp"; cat db/roles/supervision.sql; } \
+ | docker compose exec -T db psql -U $(PG_USER) -d $(PG_DB) -v ON_ERROR_STOP=1 -v base=$(PG_DB) -q
+
+monitoring-up: ## Démarre la supervision sur la stack en cours : Prometheus, Alertmanager, Grafana, exporteurs
+ @$(supervision-garde)
+ $(MONITORING) up -d --no-deps $(SERVICES_SUPERVISION)
+ @$(MAKE) --no-print-directory db-ensure-supervision
+ @echo "grafana -> http://localhost:$(GRAFANA_PORT) prometheus -> http://localhost:$(PROMETHEUS_PORT)"
+
+monitoring-down: ## Arrête la supervision en conservant ses données
+ $(MONITORING) stop $(SERVICES_SUPERVISION)
+
+monitoring-logs: ## Suit les journaux de Prometheus, Alertmanager et Grafana
+ $(MONITORING) logs -f prometheus alertmanager grafana
+
+monitoring-check: ## Valide la configuration de supervision et joue les tests des règles d'alerte, comme la CI
+ $(PROMTOOL) check config /etc/prometheus/prometheus.yml
+ $(PROMTOOL) test rules /etc/prometheus/tests/enervision.test.yml
+ $(MONITORING) run --rm --no-deps --entrypoint amtool alertmanager check-config /etc/alertmanager/alertmanager.yml
+ @for tableau in monitoring/grafana/dashboards/*.json; do jq empty "$$tableau" || exit 1; done
+
db-up: ## Démarre la base PostgreSQL TimescaleDB
docker compose up -d db
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