Merge remote-tracking branch 'origin/dev' into feat/reconciliation-dag
This commit is contained in:
@@ -69,8 +69,27 @@ PUBLIC_ORIGIN=
|
|||||||
# l'extérieur. Décaler aussi POSTGRES_PORT, MAILPIT_UI_PORT et AIRFLOW_PORT (5434, 8026, 8082).
|
# l'extérieur. Décaler aussi POSTGRES_PORT, MAILPIT_UI_PORT et AIRFLOW_PORT (5434, 8026, 8082).
|
||||||
PROXY_HTTP_PORT=
|
PROXY_HTTP_PORT=
|
||||||
PROXY_HTTPS_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
|
# 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
|
# 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.
|
# nombre de workers vaut 1 par défaut, contre 4 pour le webserver d'Airflow 2.
|
||||||
TS_TUNE_MEMORY=2GB
|
TS_TUNE_MEMORY=2GB
|
||||||
TS_TUNE_NUM_CPUS=2
|
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
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
self-hosted-runner:
|
||||||
|
labels:
|
||||||
|
- eni-g3
|
||||||
@@ -20,6 +20,17 @@ updates:
|
|||||||
- dependency-name: "@vitest/coverage-v8"
|
- dependency-name: "@vitest/coverage-v8"
|
||||||
update-types: ["version-update:semver-major"]
|
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)
|
# Backend — uv (lit pyproject.toml / uv.lock)
|
||||||
- package-ecosystem: "uv"
|
- package-ecosystem: "uv"
|
||||||
directory: "/apps/backend"
|
directory: "/apps/backend"
|
||||||
|
|||||||
@@ -6,42 +6,20 @@ name: Airflow
|
|||||||
# Docker, dans son propre environnement (cf. etl/airflow/Dockerfile).
|
# 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
|
# 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
|
# modification de l'un ou de l'autre peut donc casser sa construction : le filtre `airflow` de
|
||||||
# les déclencheurs, alors même que ce workflow ne teste ni le modèle ni l'API.
|
# ci.yml, qui appelle ce workflow, inclut ces chemins alors qu'il ne teste ni le modèle ni l'API.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_call:
|
||||||
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"
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: airflow-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verification:
|
verification:
|
||||||
name: Lint et intégrité des DAGs
|
name: Lint et intégrité des DAGs
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: etl/airflow
|
working-directory: etl/airflow
|
||||||
@@ -50,17 +28,19 @@ jobs:
|
|||||||
- name: Récupère le dépôt
|
- name: Récupère le dépôt
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
# Action tierce, épinglée sur le commit du tag (règle Sonar githubactions:S7637).
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: etl/airflow/uv.lock
|
cache-dependency-glob: etl/airflow/uv.lock
|
||||||
|
prune-cache: false
|
||||||
|
|
||||||
- name: Installe l'interpréteur déclaré par .python-version
|
- name: Installe l'interpréteur déclaré par .python-version
|
||||||
run: uv python install
|
run: uv python install
|
||||||
|
|
||||||
- name: Synchronise les dépendances sans dévier du verrou
|
- name: Synchronise les dépendances sur le verrou
|
||||||
run: uv sync --all-groups --frozen
|
run: uv sync --all-groups --locked
|
||||||
|
|
||||||
- name: Vérifie le formatage
|
- name: Vérifie le formatage
|
||||||
run: uv run ruff format --check .
|
run: uv run ruff format --check .
|
||||||
@@ -76,6 +56,7 @@ jobs:
|
|||||||
image:
|
image:
|
||||||
name: Construction de l'image
|
name: Construction de l'image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 25
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Récupère le dépôt
|
- 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
|
# `--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.
|
# 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
|
- name: Vérifie que les quatre commandes backend s'importent sans réseau
|
||||||
run: >
|
run: >
|
||||||
docker run --rm --network none enervision-airflow:ci
|
docker run --rm --network none enervision-airflow:ci
|
||||||
|
|||||||
@@ -2,28 +2,20 @@ name: Backend
|
|||||||
|
|
||||||
# Piège : la version de Python vient de apps/backend/.python-version, et elle doit rester
|
# 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.
|
# 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:
|
on:
|
||||||
push:
|
workflow_call:
|
||||||
paths:
|
|
||||||
- "apps/backend/**"
|
|
||||||
- ".github/workflows/backend.yml"
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "apps/backend/**"
|
|
||||||
- ".github/workflows/backend.yml"
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: backend-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verification:
|
verification:
|
||||||
name: Lint, typage et tests
|
name: Lint, typage et tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: apps/backend
|
working-directory: apps/backend
|
||||||
@@ -32,17 +24,20 @@ jobs:
|
|||||||
- name: Récupère le dépôt
|
- name: Récupère le dépôt
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
# Action tierce, épinglée sur le commit du tag (règle Sonar githubactions:S7637).
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: apps/backend/uv.lock
|
cache-dependency-glob: apps/backend/uv.lock
|
||||||
|
prune-cache: false
|
||||||
|
|
||||||
- name: Installe l'interpréteur déclaré par .python-version
|
- name: Installe l'interpréteur déclaré par .python-version
|
||||||
run: uv python install
|
run: uv python install
|
||||||
|
|
||||||
- name: Synchronise les dépendances sans dévier du verrou
|
# `--locked` et non `--frozen` : un verrou qui ne suit plus pyproject.toml doit casser ici.
|
||||||
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
|
- name: Vérifie le formatage
|
||||||
run: uv run ruff format --check .
|
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.
|
# Le marqueur `integration` est exclu par défaut, donc aucune base n'est nécessaire ici.
|
||||||
- name: Tests et couverture
|
- 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
|
- name: Verse la couverture pour Sonar
|
||||||
# migration (`5353c0e4f094`) échoue volontairement si l'extension TimescaleDB manque, et un
|
uses: actions/upload-artifact@v7
|
||||||
# écart d'image entre la CI et le poste rendrait ce job vert sur une base qui n'est pas la nôtre.
|
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:
|
integration:
|
||||||
name: Tests exigeant une base
|
name: Tests exigeant une base
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: apps/backend
|
working-directory: apps/backend
|
||||||
@@ -93,16 +95,17 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: apps/backend/uv.lock
|
cache-dependency-glob: apps/backend/uv.lock
|
||||||
|
prune-cache: false
|
||||||
|
|
||||||
- name: Installe l'interpréteur déclaré par .python-version
|
- name: Installe l'interpréteur déclaré par .python-version
|
||||||
run: uv python install
|
run: uv python install
|
||||||
|
|
||||||
- name: Synchronise les dépendances sans dévier du verrou
|
- name: Synchronise les dépendances sur le verrou
|
||||||
run: uv sync --all-groups --frozen
|
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
|
# 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.
|
# 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
|
- name: Applique les migrations
|
||||||
run: uv run alembic upgrade head
|
run: uv run alembic upgrade head
|
||||||
|
|
||||||
# `-m` en ligne de commande écrase celui d'`addopts`. La couverture est désactivée : ce job
|
# Couverture désactivée : ce job ne joue qu'une partie de la suite, son taux n'aurait
|
||||||
# ne joue qu'une partie de la suite, son taux n'aurait aucun sens face au seuil de 85 %.
|
# aucun sens face au seuil de 85 %.
|
||||||
- name: Tests d'intégration
|
- name: Tests d'intégration
|
||||||
run: uv run pytest -m integration --no-cov
|
run: uv run pytest -m integration --no-cov
|
||||||
|
|
||||||
security-audit:
|
security-audit:
|
||||||
name: Audit des dépendances
|
name: Audit des dépendances
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: apps/backend
|
working-directory: apps/backend
|
||||||
@@ -129,21 +133,23 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: apps/backend/uv.lock
|
cache-dependency-glob: apps/backend/uv.lock
|
||||||
|
prune-cache: false
|
||||||
|
|
||||||
# L'audit porte sur le verrou, pas sur l'environnement : sinon pip-audit auditerait
|
# 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.
|
# aussi les paquets que son propre `--with` injecte, hors dépendances du projet.
|
||||||
- name: Audite les dépendances livrées
|
- name: Audite les dépendances livrées
|
||||||
# Piège : sans `shell: bash`, un échec de `uv export` serait masqué par le pipe.
|
# Piège : sans `shell: bash`, un échec de `uv export` serait masqué par le pipe.
|
||||||
shell: bash
|
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:
|
sast:
|
||||||
name: Analyse statique de sécurité
|
name: Analyse statique de sécurité
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: apps/backend
|
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
|
# 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.
|
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
|
||||||
- name: Installe uv
|
- 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
|
# 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.
|
# secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod.
|
||||||
|
|||||||
@@ -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
|
||||||
+15
-24
@@ -1,9 +1,9 @@
|
|||||||
name: DAST
|
name: DAST
|
||||||
|
|
||||||
# Scan dynamique OWASP ZAP de l'API (issue #41). Il attaque une API qui tourne : le job démarre
|
# 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
|
# la base et le backend sur le runner, sème le jeu de démonstration (sans ça le scan ne frappe que
|
||||||
# frappe que des gestionnaires d'erreur), crée un compte `lecteur` jetable
|
# des gestionnaires d'erreur), crée des comptes jetables (scripts/dast-token.sh), puis lance ZAP
|
||||||
# (scripts/dast-token.sh), puis lance ZAP sur le contrat OpenAPI avec le jeton de ce compte.
|
# 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) :
|
# 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
|
# le volume d'un premier passage trié est inconnu. Deux étapes suivantes, elles, bloquent si le
|
||||||
@@ -24,6 +24,8 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- ".github/workflows/dast.yml"
|
- ".github/workflows/dast.yml"
|
||||||
- "scripts/dast-token.sh"
|
- "scripts/dast-token.sh"
|
||||||
|
- "scripts/comptes-test.sh"
|
||||||
|
- "db/seeds/**"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -77,7 +79,7 @@ jobs:
|
|||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
# Épinglé sur le commit du tag v7 (règle Sonar githubactions:S7637 : dépendance tierce,
|
# É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).
|
# 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:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: apps/backend/uv.lock
|
cache-dependency-glob: apps/backend/uv.lock
|
||||||
@@ -97,7 +99,7 @@ jobs:
|
|||||||
# `apps/backend`, comme dans son Dockerfile. Les `uv run` suivants portent `--frozen
|
# `apps/backend`, comme dans son Dockerfile. Les `uv run` suivants portent `--frozen
|
||||||
# --no-sync` pour ne rien résoudre ni reconstruire (règle S8544).
|
# --no-sync` pour ne rien résoudre ni reconstruire (règle S8544).
|
||||||
- name: Synchronise les dépendances sans dévier du verrou
|
- 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
|
working-directory: apps/backend
|
||||||
|
|
||||||
- name: Active TimescaleDB sur la base du scan
|
- name: Active TimescaleDB sur la base du scan
|
||||||
@@ -108,22 +110,9 @@ jobs:
|
|||||||
working-directory: apps/backend
|
working-directory: apps/backend
|
||||||
|
|
||||||
# Sans données, `GET /sites` rend `[]`, chaque `/{site_id}` rend 404 et le scan actif ne
|
# 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
|
# frappe que des gestionnaires d'erreur plutôt que la logique métier.
|
||||||
# (pas encore d'outillage de jeu de données pour la CI) : un site et deux relevés à la main,
|
- name: Sème le jeu de démonstration
|
||||||
# juste assez pour que les routes de lecture aient quelque chose à rendre.
|
run: psql -h localhost -p 5433 -U enervision -d enervision_dast -v ON_ERROR_STOP=1 -f db/seeds/demo.sql
|
||||||
- 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
|
|
||||||
|
|
||||||
- name: Démarre l'API
|
- name: Démarre l'API
|
||||||
run: |
|
run: |
|
||||||
@@ -299,9 +288,11 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
if [ -f zap-out/zap-report.md ]; then
|
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"
|
awk '/^## Alert Detail/{exit} {print}' zap-out/zap-report.md
|
||||||
echo "Rapport complet (HTML/JSON/Markdown) dans l'artefact \`zap-report\`." >> "$GITHUB_STEP_SUMMARY"
|
echo ""
|
||||||
|
echo "Rapport complet (HTML/JSON/Markdown) dans l'artefact \`zap-report\`."
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
else
|
else
|
||||||
echo "Aucun rapport ZAP produit, voir le journal du job." >> "$GITHUB_STEP_SUMMARY"
|
echo "Aucun rapport ZAP produit, voir le journal du job." >> "$GITHUB_STEP_SUMMARY"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,57 +1,74 @@
|
|||||||
# Pourquoi : le runner tourne sur la VM ENI, adresse privée que les runners hébergés par GitHub
|
# 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
|
# 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.
|
# 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
|
# 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.
|
# 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
|
name: Déploiement
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_call:
|
||||||
branches: [dev, main]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-${{ github.ref_name }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
|
name: Déploie sur la VM
|
||||||
runs-on: [self-hosted, linux, eni-g3]
|
runs-on: [self-hosted, linux, eni-g3]
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
environment:
|
environment:
|
||||||
name: ${{ github.ref_name == 'main' && 'prod' || 'rec' }}
|
name: ${{ github.ref_name == 'main' && 'prod' || github.ref_name == 'dev' && 'rec' || 'dev' }}
|
||||||
url: ${{ github.ref_name == 'main' && 'https://enervision.local' || 'https://rec.enervision.local:8443' }}
|
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:
|
env:
|
||||||
ENVIRONNEMENT: ${{ github.ref_name == 'main' && 'prod' || 'rec' }}
|
ENVIRONNEMENT: ${{ github.ref_name == 'main' && 'prod' || github.ref_name == 'dev' && 'rec' || 'dev' }}
|
||||||
PORT_HTTPS: ${{ github.ref_name == 'main' && '443' || '8443' }}
|
PORT_HTTPS: ${{ github.ref_name == 'main' && '10443' || github.ref_name == 'dev' && '8443' || '9443' }}
|
||||||
steps:
|
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: |
|
run: |
|
||||||
cd "/srv/enervision/${ENVIRONNEMENT}"
|
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}"
|
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 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'
|
git log -1 --format='%h %s'
|
||||||
|
echo "::endgroup::"
|
||||||
|
|
||||||
- name: Reconstruire et redémarrer la stack
|
echo "::group::Reconstruit et redémarre la stack"
|
||||||
run: |
|
# Un `.env` pas encore réaligné par provision-host.sh porte encore un nom en `.local`.
|
||||||
cd "/srv/enervision/${ENVIRONNEMENT}"
|
if [ -r ../dns.token ] && ! grep -q '^PUBLIC_HOST=.*\.local$' .env; then make tls-dns01; fi
|
||||||
make stack-up
|
make stack-up
|
||||||
|
if [ "${ENVIRONNEMENT}" = prod ]; then make front-up; fi
|
||||||
|
echo "::endgroup::"
|
||||||
|
|
||||||
- name: Attendre que l'API réponde derrière le proxy
|
echo "::group::Attend que l'API réponde derrière le proxy"
|
||||||
run: |
|
for _ in $(seq 1 36); do
|
||||||
for tentative in $(seq 1 36); do
|
|
||||||
if curl --fail --silent --insecure "https://localhost:${PORT_HTTPS}/api/v1/health/ready"; then
|
if curl --fail --silent --insecure "https://localhost:${PORT_HTTPS}/api/v1/health/ready"; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
sleep 5
|
sleep 5
|
||||||
done
|
done
|
||||||
|
echo "::endgroup::"
|
||||||
echo "L'API ne répond pas après 3 minutes" >&2
|
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="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
|
||||||
$compose ps
|
$compose ps
|
||||||
$compose logs --tail=50 backend proxy
|
$compose logs --tail=50 backend proxy
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,64 +1,70 @@
|
|||||||
name: Frontend
|
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:
|
on:
|
||||||
push:
|
workflow_call:
|
||||||
paths:
|
|
||||||
- "apps/frontend/**"
|
|
||||||
- ".github/workflows/frontend.yml"
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "apps/frontend/**"
|
|
||||||
- ".github/workflows/frontend.yml"
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
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
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: apps/frontend
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- name: Récupère le dépôt
|
||||||
- uses: actions/setup-node@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Installe Node
|
||||||
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 26
|
node-version: 26
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: apps/frontend/package-lock.json
|
cache-dependency-path: apps/frontend/package-lock.json
|
||||||
|
|
||||||
- run: npm ci
|
- name: Installe les dépendances
|
||||||
working-directory: apps/frontend
|
|
||||||
- run: npm run build
|
|
||||||
working-directory: apps/frontend
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
run: npm ci
|
||||||
working-directory: apps/frontend
|
|
||||||
- name : Lancement des tests et génénration du rapport de couverture (Front)
|
- name: Construit l'application
|
||||||
run: npm test --watch=false --code-coverage --coverageReporters=lcov
|
run: npm run build
|
||||||
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
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: frontend-coverage
|
name: frontend-coverage
|
||||||
path: apps/frontend/coverage/frontend/lcov.info
|
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
|
||||||
|
|||||||
+73
-18
@@ -1,38 +1,40 @@
|
|||||||
name: Infra
|
name: Infra
|
||||||
|
|
||||||
# Pourquoi : le Terraform du dépôt est resté cassé sans que rien ne le dise, faute de job qui le
|
# Pourquoi : rien de ce qui décrit l'infrastructure ne s'exécute avant le déploiement. Terraform est
|
||||||
# joue. Ce workflow n'applique rien : il vérifie le formatage et la validité de chaque racine.
|
# resté cassé sans que rien ne le dise, faute de job qui le joue : ce workflow n'applique rien, il
|
||||||
# Piège : la boucle parcourt `environments/*`, pour qu'une racine ajoutée soit couverte sans
|
# vérifie le Terraform, les fichiers Compose et les workflows eux-mêmes - jobs terraform, compose,
|
||||||
# toucher à ce fichier.
|
# 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:
|
on:
|
||||||
push:
|
workflow_call:
|
||||||
paths:
|
inputs:
|
||||||
- "infra/terraform/**"
|
terraform:
|
||||||
- ".github/workflows/infra.yml"
|
type: boolean
|
||||||
pull_request:
|
default: false
|
||||||
paths:
|
compose:
|
||||||
- "infra/terraform/**"
|
type: boolean
|
||||||
- ".github/workflows/infra.yml"
|
default: false
|
||||||
|
workflows:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: infra-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
terraform:
|
terraform:
|
||||||
name: Formatage et validation Terraform
|
name: Formatage et validation Terraform
|
||||||
|
if: inputs.terraform
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Récupère le dépôt
|
- name: Récupère le dépôt
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
# Action tierce, donc epinglee sur un SHA de commit et pas sur un tag mobile : un tag se
|
# Action tierce, épinglée sur le commit du tag (règle Sonar githubactions:S7637).
|
||||||
# redeplace, et ce workflow tourne avec les droits du depot (regle Sonar githubactions:S7637).
|
|
||||||
- name: Installe Terraform
|
- name: Installe Terraform
|
||||||
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||||
with:
|
with:
|
||||||
@@ -50,3 +52,56 @@ jobs:
|
|||||||
terraform -chdir="${racine}" validate
|
terraform -chdir="${racine}" validate
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
done
|
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
|
||||||
|
|||||||
+35
-48
@@ -2,46 +2,20 @@ name: ML
|
|||||||
|
|
||||||
# Piège : la version de Python vient de ml/.python-version, et doit rester en 3.14 (cf.
|
# 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).
|
# .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:
|
on:
|
||||||
push:
|
workflow_call:
|
||||||
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"
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ml-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verification:
|
verification:
|
||||||
name: Lint, typage et tests
|
name: Lint, typage et tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: ml
|
working-directory: ml
|
||||||
@@ -50,17 +24,19 @@ jobs:
|
|||||||
- name: Récupère le dépôt
|
- name: Récupère le dépôt
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
# Action tierce, épinglée sur le commit du tag (règle Sonar githubactions:S7637).
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: ml/uv.lock
|
cache-dependency-glob: ml/uv.lock
|
||||||
|
prune-cache: false
|
||||||
|
|
||||||
- name: Installe l'interpréteur déclaré par .python-version
|
- name: Installe l'interpréteur déclaré par .python-version
|
||||||
run: uv python install
|
run: uv python install
|
||||||
|
|
||||||
- name: Synchronise les dépendances sans dévier du verrou
|
- name: Synchronise les dépendances sur le verrou
|
||||||
run: uv sync --all-groups --frozen
|
run: uv sync --all-groups --locked
|
||||||
|
|
||||||
- name: Vérifie le formatage
|
- name: Vérifie le formatage
|
||||||
run: uv run ruff format --check .
|
run: uv run ruff format --check .
|
||||||
@@ -71,17 +47,24 @@ jobs:
|
|||||||
- name: Typage
|
- name: Typage
|
||||||
run: uv run mypy enervision_ml tests
|
run: uv run mypy enervision_ml tests
|
||||||
|
|
||||||
# Les tests exigeant une base portent le marqueur `integration`, ecarte par defaut et
|
# Les tests exigeant une base portent le marqueur `integration`, écarté par défaut et
|
||||||
# joue par le job `integration` ci-dessous.
|
# joué par le job `integration` ci-dessous.
|
||||||
- name: Tests
|
- name: Tests et couverture
|
||||||
run: uv run pytest
|
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 :
|
- name: Verse la couverture pour Sonar
|
||||||
# le schema de la base ML est celui du backend (apps/backend/alembic, proprietaire du schema).
|
uses: actions/upload-artifact@v7
|
||||||
# Le reconstruire ici a la main rendrait ce job vert sur une base qui n'est pas la notre.
|
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:
|
integration:
|
||||||
name: ML - DB et chaîne ML - DB - API
|
name: ML - DB et chaîne ML - DB - API
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
@@ -112,26 +95,27 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: |
|
cache-dependency-glob: |
|
||||||
ml/uv.lock
|
ml/uv.lock
|
||||||
apps/backend/uv.lock
|
apps/backend/uv.lock
|
||||||
|
prune-cache: false
|
||||||
|
|
||||||
- name: Installe l'interpréteur déclaré par .python-version
|
- name: Installe l'interpréteur déclaré par .python-version
|
||||||
working-directory: ml
|
working-directory: ml
|
||||||
run: uv python install
|
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
|
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 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.
|
# 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
|
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
|
# 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.
|
# révision Alembic refuse de s'appliquer.
|
||||||
@@ -142,8 +126,8 @@ jobs:
|
|||||||
working-directory: apps/backend
|
working-directory: apps/backend
|
||||||
run: uv run alembic upgrade head
|
run: uv run alembic upgrade head
|
||||||
|
|
||||||
# `-m` en ligne de commande écrase celui d'addopts. Couverture désactivée : ce job ne joue
|
# Couverture désactivée : ce job ne joue qu'une partie de la suite, son taux n'aurait pas
|
||||||
# qu'une partie de la suite, son taux n'aurait pas de sens (même raison que backend.yml).
|
# de sens (même raison que backend.yml).
|
||||||
- name: Tests ML exigeant une base
|
- name: Tests ML exigeant une base
|
||||||
working-directory: ml
|
working-directory: ml
|
||||||
run: uv run pytest -m integration --no-cov
|
run: uv run pytest -m integration --no-cov
|
||||||
@@ -159,6 +143,7 @@ jobs:
|
|||||||
sast:
|
sast:
|
||||||
name: Analyse statique de sécurité
|
name: Analyse statique de sécurité
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: ml
|
working-directory: ml
|
||||||
@@ -170,7 +155,9 @@ jobs:
|
|||||||
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
|
# 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.
|
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
|
||||||
- name: Installe uv
|
- 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)
|
- name: Analyse le code livré (bloquant à partir de MEDIUM)
|
||||||
run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium
|
run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium
|
||||||
|
|||||||
@@ -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 }}
|
|
||||||
+7
-2
@@ -22,6 +22,12 @@ apps/frontend/.angular/
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-error.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/
|
.terraform/
|
||||||
# .terraform.lock.hcl est versionne (pas ignore) pour figer les versions de provider entre contributeurs/CI
|
# .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/*
|
||||||
!data/raw/.gitkeep
|
!data/raw/.gitkeep
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
monitoring/grafana/data/
|
|
||||||
monitoring/prometheus/data/
|
|
||||||
|
|
||||||
# ML : jeu de donnees, modeles entraines et suivi MLflow local, tous generes/volumineux
|
# ML : jeu de donnees, modeles entraines et suivi MLflow local, tous generes/volumineux
|
||||||
ml/data/
|
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
|
# TLS : certificats du reverse proxy, générés par script ou par certbot
|
||||||
infra/proxy/tls/*.pem
|
infra/proxy/tls/*.pem
|
||||||
|
infra/proxy/acme/
|
||||||
|
|
||||||
# IDE et OS
|
# IDE et OS
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ BACKEND := apps/backend
|
|||||||
FRONTEND := apps/frontend
|
FRONTEND := apps/frontend
|
||||||
ML := ml
|
ML := ml
|
||||||
AIRFLOW := etl/airflow
|
AIRFLOW := etl/airflow
|
||||||
|
E2E := tests/e2e
|
||||||
COMPOSE_PROD := docker compose -f docker-compose.yml -f docker-compose.prod.yml
|
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.
|
# 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)
|
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)
|
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
|
# 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.
|
# verraient qu'un parc muet depuis des mois. Cf. `--now` de enervision_ml.score.
|
||||||
DEMO_NOW ?= 2024-12-31T00:00:00Z
|
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 \
|
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 \
|
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 \
|
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
|
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
|
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; }
|
|| { 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 \
|
@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; }
|
|| { 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) up -d --build
|
||||||
$(COMPOSE_PROD) exec -T backend alembic upgrade head
|
$(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
|
stack-down: ## Arrête la stack complète en conservant les données
|
||||||
$(COMPOSE_PROD) stop
|
$(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) --profile acme run --rm certbot renew --deploy-hook /deploy-hook.sh
|
||||||
$(COMPOSE_PROD) exec proxy nginx -s reload
|
$(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
|
db-up: ## Démarre la base PostgreSQL TimescaleDB
|
||||||
docker compose up -d db
|
docker compose up -d db
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
|
|||||||
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
|
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
|
||||||
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
|
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
|
||||||
| CI/CD | GitHub Actions | `.github/workflows` | 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 |
|
| ML | LightGBM, MLflow | `ml` | En place |
|
||||||
|
|
||||||
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend
|
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/
|
├── db/
|
||||||
│ ├── init/ Bootstrap PostgreSQL + TimescaleDB
|
│ ├── init/ Bootstrap PostgreSQL + TimescaleDB
|
||||||
│ ├── migrations/ Migrations SQL versionnees
|
│ ├── migrations/ Migrations SQL versionnees
|
||||||
│ └── seeds/ Jeux de donnees de reference
|
│ ├── roles/ Roles PostgreSQL hors schema (supervision)
|
||||||
|
│ └── seeds/ Jeu de demonstration des tests
|
||||||
├── etl/airflow/
|
├── etl/airflow/
|
||||||
│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, imports, dérive)
|
│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, imports, dérive)
|
||||||
│ ├── plugins/ Operateurs et hooks maison
|
│ ├── 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
|
│ ├── prometheus/ Collecte et regles d'alerte
|
||||||
│ ├── grafana/ Provisioning et dashboards
|
│ ├── grafana/ Provisioning et dashboards
|
||||||
│ └── alertmanager/ Routage des alertes
|
│ └── alertmanager/ Routage des alertes
|
||||||
|
├── tests/
|
||||||
|
│ ├── e2e/ Parcours Playwright contre la stack
|
||||||
|
│ └── load/ Scenarios de charge k6
|
||||||
├── docs/ ADR et vues d'architecture
|
├── docs/ ADR et vues d'architecture
|
||||||
└── scripts/ Outillage local
|
└── 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`,
|
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
|
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
|
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).
|
[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 <http://localhost:3001> | [`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
|
## Conventions
|
||||||
|
|
||||||
- Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court.
|
- Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Literal, Self
|
from typing import Literal, Self
|
||||||
|
|
||||||
from pydantic import Field, SecretStr, model_validator
|
from pydantic import Field, SecretStr, field_validator, model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
Environment = Literal["local", "dev", "staging", "prod"]
|
Environment = Literal["local", "dev", "staging", "prod"]
|
||||||
@@ -76,6 +76,13 @@ class Settings(BaseSettings):
|
|||||||
expose_api_docs: bool | None = None
|
expose_api_docs: bool | None = None
|
||||||
metrics_token: SecretStr | None = None
|
metrics_token: SecretStr | None = None
|
||||||
|
|
||||||
|
# Compose passe `APP_METRICS_TOKEN` vide quand aucun jeton n'est posé : vide vaut absent, sinon
|
||||||
|
# `/metrics` exigerait un `Bearer` sans valeur et plus rien ne pourrait le scruter.
|
||||||
|
@field_validator("metrics_token", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _jeton_vide_vaut_absent(cls, valeur: object) -> object:
|
||||||
|
return None if valeur == "" else valeur
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def allowed_origins(self) -> list[str]:
|
def allowed_origins(self) -> list[str]:
|
||||||
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ from fastapi import Depends, FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from prometheus_fastapi_instrumentator import Instrumentator
|
from prometheus_client import CollectorRegistry, GCCollector, PlatformCollector, ProcessCollector
|
||||||
|
from prometheus_fastapi_instrumentator import Instrumentator, metrics
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import HTMLResponse
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
@@ -37,6 +38,16 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|||||||
await get_engine().dispose()
|
await get_engine().dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# Pourquoi : le registre global n'accepte chaque métrique qu'une fois. Toute application créée
|
||||||
|
# après la première, dans les tests notamment, n'aurait rien mesuré.
|
||||||
|
def _registre_de_metriques() -> CollectorRegistry:
|
||||||
|
registre = CollectorRegistry()
|
||||||
|
ProcessCollector(registry=registre)
|
||||||
|
PlatformCollector(registry=registre)
|
||||||
|
GCCollector(registry=registre)
|
||||||
|
return registre
|
||||||
|
|
||||||
|
|
||||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
resolved = settings or get_settings()
|
resolved = settings or get_settings()
|
||||||
configure_logging(resolved)
|
configure_logging(resolved)
|
||||||
@@ -102,7 +113,14 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
|
|
||||||
register_error_handlers(application)
|
register_error_handlers(application)
|
||||||
|
|
||||||
Instrumentator().instrument(application).expose(
|
# Les sondes de santé tombent toutes les 30 s : comptées, elles fausseraient latences et débit.
|
||||||
|
# Seaux fins autour du seuil de charge (p95 < 500 ms, ADR 0015), route par route.
|
||||||
|
registre = _registre_de_metriques()
|
||||||
|
Instrumentator(
|
||||||
|
excluded_handlers=["/metrics", f"{resolved.api_prefix}/health/.*"], registry=registre
|
||||||
|
).add(
|
||||||
|
metrics.default(latency_lowr_buckets=(0.05, 0.1, 0.25, 0.5, 1, 2.5), registry=registre)
|
||||||
|
).instrument(application).expose(
|
||||||
application,
|
application,
|
||||||
endpoint="/metrics",
|
endpoint="/metrics",
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
|
|||||||
@@ -72,6 +72,20 @@ async def test_metrics_stay_open_when_no_token_is_configured(client: AsyncClient
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_empty_metrics_token_means_no_token() -> None:
|
||||||
|
assert (await interroge({"metrics_token": ""}, "/metrics")).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_metrics_ignore_health_probes_but_count_business_routes(client: AsyncClient) -> None:
|
||||||
|
await client.get("/api/v1/health/live")
|
||||||
|
await client.get("/api/v1/sites")
|
||||||
|
|
||||||
|
exposition = (await client.get("/metrics")).text
|
||||||
|
|
||||||
|
assert 'handler="/api/v1/health/live"' not in exposition
|
||||||
|
assert 'handler="/api/v1/sites"' in exposition
|
||||||
|
|
||||||
|
|
||||||
async def test_metrics_demand_the_token_once_one_is_configured() -> None:
|
async def test_metrics_demand_the_token_once_one_is_configured() -> None:
|
||||||
surcharges = {"metrics_token": "un-jeton-de-supervision-assez-long"}
|
surcharges = {"metrics_token": "un-jeton-de-supervision-assez-long"}
|
||||||
|
|
||||||
|
|||||||
@@ -86,3 +86,9 @@ describe('MonComposant', () => {
|
|||||||
- Un fichier ou un dossier seulement :
|
- Un fichier ou un dossier seulement :
|
||||||
`npx ng test --watch=false --coverage=false --include=src/app/core/services/alerts.service.spec.ts`
|
`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)
|
(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`.
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
sonar.projectKey=ProjetPiscine_EnerVision
|
|
||||||
sonar.organization=groupe3-ener-vision
|
|
||||||
sonar.sourceEncoding=UTF-8
|
|
||||||
|
|
||||||
# Dossier contenant le code source
|
|
||||||
sonar.sources=apps/frontend/src,apps/backend/app
|
|
||||||
sonar.tests=apps/backend/tests
|
|
||||||
|
|
||||||
# Liste des fichiers et dossiers à exclure de l'analyse
|
|
||||||
# Liste des fichiers et dossiers à exclure de l'analyse
|
|
||||||
sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js,github,db,ml,docker-compose.yml,**/**/Dockerfile,**/**/proxy.conf.json,**/**/package.json,**/**/angular.json
|
|
||||||
|
|
||||||
# Chemin vers le rapport de couverture de code
|
|
||||||
# Fichier généré par Vitest
|
|
||||||
# Chemin vers le rapport de couverture de code
|
|
||||||
# Fichier généré par Vitest
|
|
||||||
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info
|
|
||||||
sonar.python.coverage.reportPaths=apps/backend/cov.info
|
|
||||||
@@ -43,6 +43,31 @@ describe('AuthService', () => {
|
|||||||
expect(service.isAuthenticated()).toBe(true);
|
expect(service.isAuthenticated()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('garde le mot de passe provisoire pour un seul changement quand il doit être changé', () => {
|
||||||
|
service.login({ email: 'a@a.com', password: 'Provisoire' }).subscribe();
|
||||||
|
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush({
|
||||||
|
...tokenResponse,
|
||||||
|
principal: { ...tokenResponse.principal, must_change_password: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.takeProvisionalPassword()).toBe('Provisoire');
|
||||||
|
expect(service.takeProvisionalPassword()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne garde aucun mot de passe quand il est déjà définitif, ni après la fin de session', () => {
|
||||||
|
service.login({ email: 'a@a.com', password: 'Definitif' }).subscribe();
|
||||||
|
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse);
|
||||||
|
expect(service.takeProvisionalPassword()).toBeNull();
|
||||||
|
|
||||||
|
service.login({ email: 'a@a.com', password: 'Provisoire' }).subscribe();
|
||||||
|
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush({
|
||||||
|
...tokenResponse,
|
||||||
|
principal: { ...tokenResponse.principal, must_change_password: true },
|
||||||
|
});
|
||||||
|
service.clearSession();
|
||||||
|
expect(service.takeProvisionalPassword()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('efface la session au logout', () => {
|
it('efface la session au logout', () => {
|
||||||
service.login({ email: 'a@a.com', password: 'secret' }).subscribe();
|
service.login({ email: 'a@a.com', password: 'secret' }).subscribe();
|
||||||
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse);
|
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse);
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export class AuthService {
|
|||||||
// mémoire. Un rechargement de page le perd, c'est voulu par le contrat.
|
// mémoire. Un rechargement de page le perd, c'est voulu par le contrat.
|
||||||
private accessTokenSignal = signal<string | null>(null);
|
private accessTokenSignal = signal<string | null>(null);
|
||||||
private principalSignal = signal<Principal | null>(null);
|
private principalSignal = signal<Principal | null>(null);
|
||||||
|
// Pourquoi : redemander le mot de passe provisoire qu'on vient de vérifier laisse un gestionnaire
|
||||||
|
// de mots de passe y coller un ancien mot de passe du site, et `/auth/password` répond 401.
|
||||||
|
private provisionalPassword: string | null = null;
|
||||||
|
|
||||||
readonly principal = this.principalSignal.asReadonly();
|
readonly principal = this.principalSignal.asReadonly();
|
||||||
readonly isAuthenticated = computed(() => this.principalSignal() !== null);
|
readonly isAuthenticated = computed(() => this.principalSignal() !== null);
|
||||||
@@ -37,12 +40,26 @@ export class AuthService {
|
|||||||
clearSession(): void {
|
clearSession(): void {
|
||||||
this.accessTokenSignal.set(null);
|
this.accessTokenSignal.set(null);
|
||||||
this.principalSignal.set(null);
|
this.principalSignal.set(null);
|
||||||
|
this.provisionalPassword = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
login(credentials: LoginRequest): Observable<TokenResponse> {
|
login(credentials: LoginRequest): Observable<TokenResponse> {
|
||||||
return this.http
|
return this.http
|
||||||
.post<TokenResponse>(`${environment.apiUrl}/auth/login`, credentials, { withCredentials: true })
|
.post<TokenResponse>(`${environment.apiUrl}/auth/login`, credentials, { withCredentials: true })
|
||||||
.pipe(tap((response) => this.setSession(response)));
|
.pipe(
|
||||||
|
tap((response) => {
|
||||||
|
this.setSession(response);
|
||||||
|
this.provisionalPassword = response.principal.must_change_password
|
||||||
|
? credentials.password
|
||||||
|
: null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
takeProvisionalPassword(): string | null {
|
||||||
|
const password = this.provisionalPassword;
|
||||||
|
this.provisionalPassword = null;
|
||||||
|
return password;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Un seul rafraîchissement en vol à la fois, partagé entre tous les
|
// Un seul rafraîchissement en vol à la fois, partagé entre tous les
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
Votre mot de passe est provisoire, vous devez le modifier avant de continuer
|
Votre mot de passe est provisoire, vous devez le modifier avant de continuer
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<input hidden type="email" autocomplete="username" [value]="email" readonly />
|
||||||
|
|
||||||
|
@if (asksCurrentPassword()) {
|
||||||
<label class="form-label" for="current_password">Mot de passe actuel</label>
|
<label class="form-label" for="current_password">Mot de passe actuel</label>
|
||||||
<input
|
<input
|
||||||
id="current_password"
|
id="current_password"
|
||||||
@@ -15,6 +18,7 @@
|
|||||||
formControlName="current_password"
|
formControlName="current_password"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
<label class="form-label" for="new_password">Nouveau mot de passe</label>
|
<label class="form-label" for="new_password">Nouveau mot de passe</label>
|
||||||
<input
|
<input
|
||||||
@@ -24,7 +28,7 @@
|
|||||||
formControlName="new_password"
|
formControlName="new_password"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
/>
|
/>
|
||||||
<span class="form-hint">{{ passwordHint }}</span>
|
<app-password-requirements [password]="newPassword()" />
|
||||||
|
|
||||||
@if (errorMessage()) {
|
@if (errorMessage()) {
|
||||||
<ev-alert severity="danger">{{ errorMessage() }}</ev-alert>
|
<ev-alert severity="danger">{{ errorMessage() }}</ev-alert>
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { ReactiveFormsModule } from '@angular/forms';
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
import { of, throwError } from 'rxjs';
|
import { of, throwError } from 'rxjs';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import { ChangePassword } from './change-password';
|
import { ChangePassword } from './change-password';
|
||||||
import { AuthService } from '../../../core/services/auth.service';
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
|
|
||||||
|
const NOUVEAU = 'Un-nouveau-mot-de-passe1!';
|
||||||
|
|
||||||
describe('ChangePassword', () => {
|
describe('ChangePassword', () => {
|
||||||
let authMock: { changePassword: ReturnType<typeof vi.fn> };
|
let authMock: {
|
||||||
|
changePassword: ReturnType<typeof vi.fn>;
|
||||||
|
takeProvisionalPassword: ReturnType<typeof vi.fn>;
|
||||||
|
principal: ReturnType<typeof signal>;
|
||||||
|
};
|
||||||
let routerMock: { navigate: ReturnType<typeof vi.fn> };
|
let routerMock: { navigate: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
authMock = { changePassword: vi.fn() };
|
authMock = {
|
||||||
|
changePassword: vi.fn(),
|
||||||
|
takeProvisionalPassword: vi.fn().mockReturnValue(null),
|
||||||
|
principal: signal({ email: 'johan@enervision.fr' }),
|
||||||
|
};
|
||||||
routerMock = { navigate: vi.fn() };
|
routerMock = { navigate: vi.fn() };
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
@@ -23,6 +35,10 @@ describe('ChangePassword', () => {
|
|||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function champActuel(fixture: { nativeElement: HTMLElement }): HTMLInputElement | null {
|
||||||
|
return fixture.nativeElement.querySelector('#current_password');
|
||||||
|
}
|
||||||
|
|
||||||
it('ne soumet pas si le formulaire est invalide (mot de passe trop court)', () => {
|
it('ne soumet pas si le formulaire est invalide (mot de passe trop court)', () => {
|
||||||
const fixture = TestBed.createComponent(ChangePassword);
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
const component = fixture.componentInstance;
|
const component = fixture.componentInstance;
|
||||||
@@ -44,7 +60,7 @@ describe('ChangePassword', () => {
|
|||||||
it('redirige vers /dashboard après un changement réussi', () => {
|
it('redirige vers /dashboard après un changement réussi', () => {
|
||||||
const fixture = TestBed.createComponent(ChangePassword);
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
const component = fixture.componentInstance;
|
const component = fixture.componentInstance;
|
||||||
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
|
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU });
|
||||||
|
|
||||||
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
||||||
|
|
||||||
@@ -52,19 +68,68 @@ describe('ChangePassword', () => {
|
|||||||
expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']);
|
expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("affiche un message d'erreur si le mot de passe actuel est incorrect", () => {
|
it("demande le mot de passe actuel quand la connexion ne l'a pas transmis (page rechargée)", () => {
|
||||||
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(champActuel(fixture)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('réutilise le mot de passe provisoire de la connexion sans le redemander', () => {
|
||||||
|
authMock.takeProvisionalPassword.mockReturnValue('Provisoire-24-caracteres');
|
||||||
|
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
||||||
const fixture = TestBed.createComponent(ChangePassword);
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
const component = fixture.componentInstance;
|
const component = fixture.componentInstance;
|
||||||
component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
|
fixture.detectChanges();
|
||||||
|
|
||||||
authMock.changePassword.mockReturnValue(throwError(() => new Error('401')));
|
expect(champActuel(fixture)).toBeNull();
|
||||||
|
component.form.controls.new_password.setValue(NOUVEAU);
|
||||||
|
component.onSubmit();
|
||||||
|
|
||||||
|
expect(authMock.changePassword).toHaveBeenCalledWith({
|
||||||
|
current_password: 'Provisoire-24-caracteres',
|
||||||
|
new_password: NOUVEAU,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('associe le formulaire au compte connecté pour les gestionnaires de mots de passe', () => {
|
||||||
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const identifiant = fixture.nativeElement.querySelector('input[autocomplete="username"]');
|
||||||
|
expect(identifiant.value).toBe('johan@enervision.fr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sur un 401, dit que le mot de passe actuel est faux et le redemande', () => {
|
||||||
|
authMock.takeProvisionalPassword.mockReturnValue('Provisoire-perime');
|
||||||
|
authMock.changePassword.mockReturnValue(
|
||||||
|
throwError(() => new HttpErrorResponse({ status: 401 })),
|
||||||
|
);
|
||||||
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.form.controls.new_password.setValue(NOUVEAU);
|
||||||
|
|
||||||
component.onSubmit();
|
component.onSubmit();
|
||||||
fixture.detectChanges(); // rend le bloc @if (errorMessage())
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(component.errorMessage()).toContain('incorrect');
|
expect(component.errorMessage()).toContain('Mot de passe actuel incorrect');
|
||||||
const errorEl = fixture.nativeElement.querySelector('.ev-alert');
|
expect(fixture.nativeElement.querySelector('.ev-alert')?.textContent).toContain('incorrect');
|
||||||
expect(errorEl?.textContent).toContain('incorrect');
|
expect(champActuel(fixture)).not.toBeNull();
|
||||||
|
expect(component.form.controls.current_password.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sur un 422, dit que le nouveau mot de passe ne respecte pas la politique', () => {
|
||||||
|
authMock.changePassword.mockReturnValue(
|
||||||
|
throwError(() => new HttpErrorResponse({ status: 422 })),
|
||||||
|
);
|
||||||
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU });
|
||||||
|
|
||||||
|
component.onSubmit();
|
||||||
|
|
||||||
|
expect(component.errorMessage()).toContain('Nouveau mot de passe refusé');
|
||||||
|
expect(component.form.controls.current_password.value).toBe('ancien-mot-de-passe');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('désactive le bouton tant que le formulaire est invalide', () => {
|
it('désactive le bouton tant que le formulaire est invalide', () => {
|
||||||
@@ -79,7 +144,7 @@ describe('ChangePassword', () => {
|
|||||||
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
|
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
|
||||||
const fixture = TestBed.createComponent(ChangePassword);
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
const component = fixture.componentInstance;
|
const component = fixture.componentInstance;
|
||||||
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
|
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU });
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
||||||
@@ -90,8 +155,7 @@ describe('ChangePassword', () => {
|
|||||||
|
|
||||||
expect(authMock.changePassword).toHaveBeenCalledWith({
|
expect(authMock.changePassword).toHaveBeenCalledWith({
|
||||||
current_password: 'ancien-mot-de-passe',
|
current_password: 'ancien-mot-de-passe',
|
||||||
new_password: 'Un-nouveau-mot-de-passe1!',
|
new_password: NOUVEAU,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
import { AuthService } from '../../../core/services/auth.service';
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
import { Button } from '../../../shared/components/ui/button/button';
|
import { Button } from '../../../shared/components/ui/button/button';
|
||||||
import { Card } from '../../../shared/components/ui/card/card';
|
import { Card } from '../../../shared/components/ui/card/card';
|
||||||
import { Alert } from '../../../shared/components/ui/alert/alert';
|
import { Alert } from '../../../shared/components/ui/alert/alert';
|
||||||
import { Brand } from '../../../shared/components/ui/brand/brand';
|
import { Brand } from '../../../shared/components/ui/brand/brand';
|
||||||
|
import { PasswordRequirementsChecklist } from '../../../shared/components/password-requirements/password-requirements';
|
||||||
import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator';
|
import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-change-password',
|
selector: 'app-change-password',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [ReactiveFormsModule, Button, Card, Alert, Brand],
|
imports: [ReactiveFormsModule, Button, Card, Alert, Brand, PasswordRequirementsChecklist],
|
||||||
templateUrl: './change-password.html',
|
templateUrl: './change-password.html',
|
||||||
styleUrl: './change-password.scss',
|
styleUrl: './change-password.scss',
|
||||||
})
|
})
|
||||||
@@ -20,30 +23,47 @@ export class ChangePassword {
|
|||||||
private auth = inject(AuthService);
|
private auth = inject(AuthService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
|
||||||
|
private provisionalPassword = this.auth.takeProvisionalPassword();
|
||||||
|
|
||||||
errorMessage = signal<string | null>(null);
|
errorMessage = signal<string | null>(null);
|
||||||
isLoading = signal(false);
|
isLoading = signal(false);
|
||||||
passwordHint = PASSWORD_HINT;
|
asksCurrentPassword = signal(this.provisionalPassword === null);
|
||||||
|
email = this.auth.principal()?.email ?? '';
|
||||||
|
|
||||||
form = this.fb.nonNullable.group({
|
form = this.fb.nonNullable.group({
|
||||||
current_password: ['', Validators.required],
|
current_password: [this.provisionalPassword ?? '', Validators.required],
|
||||||
new_password: ['', passwordValidators],
|
new_password: ['', passwordValidators],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
newPassword = toSignal(this.form.controls.new_password.valueChanges, { initialValue: '' });
|
||||||
|
|
||||||
onSubmit(): void {
|
onSubmit(): void {
|
||||||
if (this.form.invalid) return;
|
if (this.form.invalid) return;
|
||||||
this.isLoading.set(true);
|
this.isLoading.set(true);
|
||||||
this.errorMessage.set(null);
|
this.errorMessage.set(null);
|
||||||
|
|
||||||
this.auth.changePassword(this.form.getRawValue()).subscribe({
|
this.auth.changePassword(this.form.getRawValue()).subscribe({
|
||||||
next: (response) => {
|
next: () => {
|
||||||
this.router.navigate(['/dashboard']);
|
this.router.navigate(['/dashboard']);
|
||||||
},
|
},
|
||||||
error: () => {
|
error: (error: HttpErrorResponse) => {
|
||||||
this.isLoading.set(false);
|
this.isLoading.set(false);
|
||||||
this.errorMessage.set(
|
this.errorMessage.set(this.explique(error));
|
||||||
`Mot de passe actuel incorrect, ou nouveau mot de passe invalide (${this.passwordHint}).`,
|
if (error.status === 401) {
|
||||||
);
|
this.form.controls.current_password.reset('');
|
||||||
|
this.asksCurrentPassword.set(true);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private explique(error: HttpErrorResponse): string {
|
||||||
|
if (error.status === 401) {
|
||||||
|
return 'Mot de passe actuel incorrect : saisissez le mot de passe provisoire qui vous a été transmis.';
|
||||||
|
}
|
||||||
|
if (error.status === 422) {
|
||||||
|
return `Nouveau mot de passe refusé (${PASSWORD_HINT}).`;
|
||||||
|
}
|
||||||
|
return 'Le changement de mot de passe a échoué, réessayez dans un instant.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
@if (data(); as d) {
|
@if (data(); as d) {
|
||||||
<div class="sites-grid">
|
<div class="sites-grid">
|
||||||
@for (site of d.sites; track site.site_id) {
|
@for (site of d.sites; track site.site_id) {
|
||||||
<ev-card class="site-card">
|
<ev-card class="site-card" data-testid="site-card">
|
||||||
<div class="site-card__header">
|
<div class="site-card__header">
|
||||||
<span class="site-card__name">{{ site.site_name }}</span>
|
<span class="site-card__name">{{ site.site_name }}</span>
|
||||||
<ev-badge [tone]="badgeToneForOverall(site.overall)">{{ site.overall }}</ev-badge>
|
<ev-badge [tone]="badgeToneForOverall(site.overall)">{{ site.overall }}</ev-badge>
|
||||||
|
|||||||
+6
-1
@@ -5,7 +5,12 @@ PostgreSQL 17 avec l'extension TimescaleDB, servie en local par le service `db`
|
|||||||
|
|
||||||
- `init` : scripts de bootstrap joues au premier demarrage du conteneur.
|
- `init` : scripts de bootstrap joues au premier demarrage du conteneur.
|
||||||
- `migrations` : migrations SQL versionnees.
|
- `migrations` : migrations SQL versionnees.
|
||||||
- `seeds` : jeux de donnees de reference.
|
- `seeds` : jeux de donnees de reference. `demo.sql` seme trois sites `demo-*`, 72 heures de
|
||||||
|
releves, des alertes et des rapports de derive pour la CI, l'e2e et les tirs de charge. Base
|
||||||
|
jetable seulement.
|
||||||
|
- `roles` : roles PostgreSQL hors schema applicatif. `supervision.sql` pose le role en lecture
|
||||||
|
seule de Grafana et de postgres-exporter, rejoue par `make db-ensure-supervision` (et par
|
||||||
|
`make stack-up` quand la supervision est active) plutot que par `init`, qui ne rejoue jamais.
|
||||||
|
|
||||||
Les migrations du schema applicatif expose par l'API vivent dans
|
Les migrations du schema applicatif expose par l'API vivent dans
|
||||||
`apps/backend/alembic`, pas ici.
|
`apps/backend/alembic`, pas ici.
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Contrainte : rôle en lecture seule de la supervision (Grafana, postgres-exporter) -
|
||||||
|
-- supervision.sql. Ses droits ne portent que sur les tables métier : jamais `app_user`, les
|
||||||
|
-- jetons ni le journal d'audit. `pg_monitor` donne à l'exportateur les vues de statistiques.
|
||||||
|
-- Rejoué par `make db-ensure-supervision`, qui passe `mot_de_passe` et `base` en variables psql :
|
||||||
|
-- crée le rôle au besoin, puis réaligne à chaque passage mot de passe et droits.
|
||||||
|
-- Piège : les tables doivent exister, d'où l'appel après `alembic upgrade head` dans `stack-up`.
|
||||||
|
|
||||||
|
SELECT 'CREATE ROLE supervision LOGIN'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supervision') \gexec
|
||||||
|
|
||||||
|
ALTER ROLE supervision WITH LOGIN PASSWORD :'mot_de_passe';
|
||||||
|
GRANT pg_monitor TO supervision;
|
||||||
|
GRANT CONNECT ON DATABASE :"base" TO supervision;
|
||||||
|
GRANT USAGE ON SCHEMA public TO supervision;
|
||||||
|
GRANT SELECT ON site, reading, alert, prediction, recommendation, drift_report TO supervision;
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
-- Contrainte : jeu de démonstration pour une base JETABLE (CI, e2e, charge, DAST) - demo.sql.
|
||||||
|
-- Rejouable : identifiants fixes et `ON CONFLICT DO NOTHING`, ou `NOT EXISTS` là où aucune
|
||||||
|
-- contrainte d'unicité ne protège la table.
|
||||||
|
-- Pourquoi : les horodatages suivent `now()`. La fenêtre par défaut de `GET /readings` couvre les
|
||||||
|
-- 24 dernières heures, et un jeu figé dans le passé laisserait le tableau de bord vide.
|
||||||
|
-- Piège : `demo-ecole` finit sur une lecture `partial` sans humidité, pour que la supervision des
|
||||||
|
-- capteurs montre un site dégradé ; au-delà de dix alertes, le fil affiche « Afficher plus ».
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
INSERT INTO site (site_id, site_name, site_type, location, capacity_kw, status)
|
||||||
|
VALUES
|
||||||
|
('demo-siege', 'Siège Part-Dieu', 'office', 'Lyon', 450, 'actif'),
|
||||||
|
('demo-usine', 'Usine de Vénissieux', 'factory', 'Vénissieux', 900, 'actif'),
|
||||||
|
('demo-ecole', 'Groupe scolaire Gratte-Ciel', 'school', 'Villeurbanne', 250, 'maintenance')
|
||||||
|
ON CONFLICT (site_id) DO NOTHING;
|
||||||
|
|
||||||
|
WITH profil (site_id, base_kw, amplitude_kw) AS (
|
||||||
|
VALUES ('demo-siege', 180.0, 120.0), ('demo-usine', 520.0, 260.0), ('demo-ecole', 70.0, 60.0)
|
||||||
|
),
|
||||||
|
heures AS (
|
||||||
|
SELECT date_trunc('hour', now()) - make_interval(hours => n) AS horodatage, n
|
||||||
|
FROM generate_series(0, 71) AS n
|
||||||
|
),
|
||||||
|
lectures AS (
|
||||||
|
SELECT
|
||||||
|
p.site_id,
|
||||||
|
h.horodatage,
|
||||||
|
h.n,
|
||||||
|
round((p.base_kw + p.amplitude_kw * greatest(0, sin(pi() * (extract(hour FROM h.horodatage) - 6) / 14)))::numeric, 2)::double precision AS kw,
|
||||||
|
extract(isodow FROM h.horodatage) < 6 AND extract(hour FROM h.horodatage) BETWEEN 8 AND 18 AS ouvre
|
||||||
|
FROM profil AS p
|
||||||
|
CROSS JOIN heures AS h
|
||||||
|
)
|
||||||
|
INSERT INTO reading (
|
||||||
|
site_id, timestamp, source, consumption_kw, consumption_kwh, consumption_euros,
|
||||||
|
voltage_v, current_a, power_factor, temperature_celsius, humidity_percent,
|
||||||
|
is_working_hours, data_quality, null_reasons, raw_data
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
site_id,
|
||||||
|
horodatage,
|
||||||
|
'api_current',
|
||||||
|
kw,
|
||||||
|
kw,
|
||||||
|
round((kw * 0.19)::numeric, 2),
|
||||||
|
230.0,
|
||||||
|
round((kw * 1000 / (230.0 * 3 * 0.95))::numeric, 1)::double precision,
|
||||||
|
0.95,
|
||||||
|
19.5 + 3 * sin(pi() * extract(hour FROM horodatage) / 12),
|
||||||
|
CASE WHEN site_id = 'demo-ecole' AND n = 0 THEN NULL ELSE 45.0 END,
|
||||||
|
ouvre,
|
||||||
|
CASE WHEN site_id = 'demo-ecole' AND n = 0 THEN 'partial' ELSE 'good' END,
|
||||||
|
CASE WHEN site_id = 'demo-ecole' AND n = 0 THEN ARRAY['humidity_sensor_failure'] END,
|
||||||
|
'{}'::jsonb
|
||||||
|
FROM lectures
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO prediction (site_id, target_at, target_metric, period_minutes, predicted_value, model_reference, status)
|
||||||
|
SELECT s.site_id, date_trunc('hour', now()) + interval '1 hour', 'consumption_kwh', 60, s.valeur, 'demo-seed', 'available'
|
||||||
|
FROM (VALUES ('demo-siege', 214.0), ('demo-usine', 610.5), ('demo-ecole', 88.2)) AS s (site_id, valeur)
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM prediction AS p
|
||||||
|
WHERE p.site_id = s.site_id
|
||||||
|
AND p.model_reference = 'demo-seed'
|
||||||
|
AND p.target_at = date_trunc('hour', now()) + interval '1 hour'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO alert (source_alert_id, site_id, source, timestamp, type, severity, message, value, threshold, metric, raw_data)
|
||||||
|
SELECT a.id, a.site_id, 'enervision', now() - make_interval(hours => a.age_h), a.type, a.severite, a.message, a.valeur, a.seuil, a.metrique, '{}'::jsonb
|
||||||
|
FROM (
|
||||||
|
VALUES
|
||||||
|
('demo-01', 'demo-siege', 1, 'spike', 'high', 'Pic de consommation à 312 kW', 312.0, 250.0, 'consumption_kw'),
|
||||||
|
('demo-02', 'demo-siege', 3, 'threshold', 'medium', 'Seuil de 80 % de la capacité franchi', 372.0, 360.0, 'consumption_kw'),
|
||||||
|
('demo-03', 'demo-siege', 6, 'anomaly', 'low', 'Consommation nocturne inhabituelle', 205.0, NULL, 'consumption_kw'),
|
||||||
|
('demo-04', 'demo-siege', 9, 'sensor', 'medium', 'Capteur de température muet', NULL, NULL, 'temperature_celsius'),
|
||||||
|
('demo-05', 'demo-siege', 20, 'outage', 'critical', 'Coupure de courant de 2 heures', 0.0, NULL, 'consumption_kw'),
|
||||||
|
('demo-06', 'demo-usine', 2, 'spike', 'critical', 'Pic de consommation à 1 020 kW', 1020.0, 850.0, 'consumption_kw'),
|
||||||
|
('demo-07', 'demo-usine', 4, 'threshold', 'high', 'Seuil de 90 % de la capacité franchi', 830.0, 810.0, 'consumption_kw'),
|
||||||
|
('demo-08', 'demo-usine', 8, 'anomaly', 'medium', 'Facteur de puissance dégradé', 0.71, 0.85, 'power_factor'),
|
||||||
|
('demo-09', 'demo-usine', 14, 'outage', 'high', 'Perte de mesure sur la ligne principale', NULL, NULL, 'consumption_kw'),
|
||||||
|
('demo-10', 'demo-usine', 30, 'sensor', 'low', 'Capteur électrique intermittent', NULL, NULL, 'voltage_v'),
|
||||||
|
('demo-11', 'demo-ecole', 1, 'sensor', 'high', 'Capteur d''humidité muet', NULL, NULL, 'humidity_percent'),
|
||||||
|
('demo-12', 'demo-ecole', 5, 'anomaly', 'low', 'Chauffage actif hors des heures d''ouverture', 96.0, NULL, 'consumption_kw'),
|
||||||
|
('demo-13', 'demo-ecole', 12, 'threshold', 'medium', 'Seuil de 60 % de la capacité franchi', 158.0, 150.0, 'consumption_kw'),
|
||||||
|
('demo-14', 'demo-ecole', 40, 'spike', 'medium', 'Pic de consommation à 190 kW', 190.0, 160.0, 'consumption_kw')
|
||||||
|
) AS a (id, site_id, age_h, type, severite, message, valeur, seuil, metrique)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO drift_report (window_start, window_end, n_observations, mae, mape, bias, reference_mae, coverage_ratio, insufficient_data_ratio, model_references, status, reason, site_id)
|
||||||
|
SELECT date_trunc('day', now()) - interval '7 days', date_trunc('day', now()), d.n, d.mae, d.mape, d.biais, 11.0, 0.98, 0.0, ARRAY['demo-seed'], d.statut, d.raison, d.site_id
|
||||||
|
FROM (
|
||||||
|
VALUES
|
||||||
|
('demo-siege', 168, 9.4, 0.052, -1.2, 'stable', NULL),
|
||||||
|
('demo-usine', 168, 31.8, 0.061, 14.5, 'derive', 'MAE supérieure à 1,5 fois la référence'),
|
||||||
|
('demo-ecole', 120, 6.1, 0.083, 0.4, 'stable', NULL),
|
||||||
|
(NULL, 456, 16.9, 0.064, 5.1, 'stable', NULL)
|
||||||
|
) AS d (site_id, n, mae, mape, biais, statut, raison)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -58,6 +58,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${PROXY_HTTP_PORT:-80}:80"
|
- "${PROXY_HTTP_PORT:-80}:80"
|
||||||
- "${PROXY_HTTPS_PORT:-443}:443"
|
- "${PROXY_HTTPS_PORT:-443}:443"
|
||||||
|
# Vide : port aléatoire sur la boucle locale, pour que deux stacks sans frontal cohabitent.
|
||||||
|
- "${PROXY_FRONT_PORT:-127.0.0.1:}:4443"
|
||||||
volumes:
|
volumes:
|
||||||
- ./infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro
|
- ./infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
- ./infra/proxy/conf.d:/etc/nginx/conf.d:ro
|
- ./infra/proxy/conf.d:/etc/nginx/conf.d:ro
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ services:
|
|||||||
APP_SMTP_PORT: "1025"
|
APP_SMTP_PORT: "1025"
|
||||||
APP_SMTP_USE_TLS: "false"
|
APP_SMTP_USE_TLS: "false"
|
||||||
APP_SMTP_FROM_ADDRESS: ${APP_SMTP_FROM_ADDRESS:-no-reply@enervision.fr}
|
APP_SMTP_FROM_ADDRESS: ${APP_SMTP_FROM_ADDRESS:-no-reply@enervision.fr}
|
||||||
|
APP_METRICS_TOKEN: ${APP_METRICS_TOKEN:-}
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:-8000}:8000"
|
- "${BACKEND_PORT:-8000}:8000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -193,7 +194,137 @@ services:
|
|||||||
airflow-init:
|
airflow-init:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
|
||||||
|
# Profil `monitoring` : actif en prod par COMPOSE_PROFILES, à la demande ailleurs (ADR 0016).
|
||||||
|
# Aucun `depends_on` : `make monitoring-up` démarre en `--no-deps`, sans jamais recréer `db`.
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v3.14.0
|
||||||
|
profiles: ["monitoring"]
|
||||||
|
command:
|
||||||
|
- --config.file=/etc/prometheus/prometheus.yml
|
||||||
|
- --storage.tsdb.path=/prometheus
|
||||||
|
- --storage.tsdb.retention.time=15d
|
||||||
|
- --storage.tsdb.retention.size=1GB
|
||||||
|
volumes:
|
||||||
|
- ./monitoring/prometheus:/etc/prometheus:ro
|
||||||
|
- prometheus_data:/prometheus
|
||||||
|
secrets:
|
||||||
|
- metrics_token
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${PROMETHEUS_PORT:-9090}:9090"
|
||||||
|
mem_limit: 512m
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
alertmanager:
|
||||||
|
image: prom/alertmanager:v0.34.1
|
||||||
|
profiles: ["monitoring"]
|
||||||
|
command:
|
||||||
|
- --config.file=/etc/alertmanager/alertmanager.yml
|
||||||
|
- --storage.path=/alertmanager
|
||||||
|
volumes:
|
||||||
|
- ./monitoring/alertmanager:/etc/alertmanager:ro
|
||||||
|
- alertmanager_data:/alertmanager
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${ALERTMANAGER_PORT:-9093}:9093"
|
||||||
|
mem_limit: 64m
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Sans mot de passe, Grafana créerait un compte admin/admin : le conteneur refuse de démarrer.
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:13.2.2
|
||||||
|
profiles: ["monitoring"]
|
||||||
|
entrypoint:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- ': "$${GF_SECURITY_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD manquant dans .env}" && exec /run.sh'
|
||||||
|
environment:
|
||||||
|
GF_SECURITY_ADMIN_USER: admin
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-}
|
||||||
|
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||||
|
GF_AUTH_ANONYMOUS_ENABLED: "false"
|
||||||
|
GF_ANALYTICS_REPORTING_ENABLED: "false"
|
||||||
|
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
|
||||||
|
GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES: "false"
|
||||||
|
GF_NEWS_NEWS_FEED_ENABLED: "false"
|
||||||
|
GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH: /etc/grafana/dashboards/api.json
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-enervision}
|
||||||
|
SUPERVISION_DB_PASSWORD: ${SUPERVISION_DB_PASSWORD:-}
|
||||||
|
volumes:
|
||||||
|
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
- ./monitoring/grafana/dashboards:/etc/grafana/dashboards:ro
|
||||||
|
- grafana_data:/var/lib/grafana
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${GRAFANA_PORT:-3001}:3000"
|
||||||
|
mem_limit: 256m
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres-exporter:
|
||||||
|
image: prometheuscommunity/postgres-exporter:v0.20.1
|
||||||
|
profiles: ["monitoring"]
|
||||||
|
environment:
|
||||||
|
DATA_SOURCE_URI: db:5432/${POSTGRES_DB:-enervision}?sslmode=disable
|
||||||
|
DATA_SOURCE_USER: supervision
|
||||||
|
DATA_SOURCE_PASS: ${SUPERVISION_DB_PASSWORD:-}
|
||||||
|
mem_limit: 64m
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
node-exporter:
|
||||||
|
image: prom/node-exporter:v1.12.1
|
||||||
|
profiles: ["monitoring"]
|
||||||
|
command:
|
||||||
|
- --path.rootfs=/host
|
||||||
|
pid: host
|
||||||
|
volumes:
|
||||||
|
- /:/host:ro,rslave
|
||||||
|
mem_limit: 64m
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Contrainte : cAdvisor lit les cgroups de tous les conteneurs de l'hôte, d'où `privileged` et
|
||||||
|
# ses montages en lecture seule. Aucun port publié : seul Prometheus le joint.
|
||||||
|
cadvisor:
|
||||||
|
image: gcr.io/cadvisor/cadvisor:v0.55.1
|
||||||
|
profiles: ["monitoring"]
|
||||||
|
privileged: true
|
||||||
|
devices:
|
||||||
|
- /dev/kmsg
|
||||||
|
command:
|
||||||
|
- --docker_only=true
|
||||||
|
- --housekeeping_interval=30s
|
||||||
|
- --store_container_labels=false
|
||||||
|
volumes:
|
||||||
|
- /:/rootfs:ro
|
||||||
|
- /var/run:/var/run:ro
|
||||||
|
- /sys:/sys:ro
|
||||||
|
- /var/lib/docker/:/var/lib/docker:ro
|
||||||
|
- /dev/disk/:/dev/disk:ro
|
||||||
|
mem_limit: 160m
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Pourquoi : sur le réseau du projet, k6 joint `backend:8000` sans passer par nginx, dont la
|
||||||
|
# limite par adresse (20 req/s) fausserait la mesure de l'API. `make load-*` le lance (ADR 0015).
|
||||||
|
k6:
|
||||||
|
image: grafana/k6:2.3.0
|
||||||
|
profiles: ["load"]
|
||||||
|
volumes:
|
||||||
|
- ./tests/load:/scripts:ro
|
||||||
|
- ./tests/load/results:/results
|
||||||
|
environment:
|
||||||
|
K6_BASE_URL: ${K6_BASE_URL:-http://backend:8000}
|
||||||
|
K6_PROXY_URL: ${K6_PROXY_URL:-https://proxy}
|
||||||
|
K6_EMAIL: ${K6_EMAIL:-}
|
||||||
|
K6_PASSWORD: ${K6_PASSWORD:-}
|
||||||
|
K6_RESUME: ${K6_RESUME:-}
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
airflow_logs:
|
airflow_logs:
|
||||||
airflow_ml_state:
|
airflow_ml_state:
|
||||||
|
prometheus_data:
|
||||||
|
alertmanager_data:
|
||||||
|
grafana_data:
|
||||||
|
|
||||||
|
# Vide tant qu'APP_METRICS_TOKEN n'est pas posé : l'API n'exige alors aucun jeton.
|
||||||
|
secrets:
|
||||||
|
metrics_token:
|
||||||
|
environment: APP_METRICS_TOKEN
|
||||||
|
|||||||
@@ -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 |
|
| [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 |
|
| [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 |
|
| [0013](adr/0013-surveillance-de-derive-dans-le-backend.md) | La surveillance de dérive vit dans le backend et écrit sa propre table |
|
||||||
|
| [0014](adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md) | Un pipeline CI unique appelle les workflows de composant et conditionne le déploiement |
|
||||||
|
| [0015](adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md) | Les tests de bout en bout et de charge visent la stack Compose déployée |
|
||||||
|
| [0016](adr/0016-supervision-en-profil-compose.md) | La supervision vit dans un profil Compose, active en prod |
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# 0014 - Un pipeline CI unique appelle les workflows de composant et conditionne le déploiement
|
||||||
|
|
||||||
|
- Statut : accepté
|
||||||
|
- Date : 2026-09-23
|
||||||
|
- Complète : [0009](0009-deux-environnements-compose-sur-la-vm-eni.md), qui reste en vigueur
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Au 22/09, huit workflows se déclenchaient chacun de leur côté, et l'audit y a relevé :
|
||||||
|
|
||||||
|
- **Double exécution.** Chaque workflow partait sur `push` (toutes branches) **et** sur
|
||||||
|
`pull_request`. Un commit poussé sur une branche de PR jouait donc toute la CI deux fois,
|
||||||
|
à la même minute (constaté dans l'historique des runs de `test/integration-api-db-ml`).
|
||||||
|
- **Sonar refaisait tout.** `sonarqube.yml` reconstruisait le frontend et retestait frontend,
|
||||||
|
backend et ML pour produire ses rapports de couverture, en double exact de `frontend.yml`,
|
||||||
|
`backend.yml` et `ml.yml`. Son test backend tournait sans `uv sync`. Il n'avait ni
|
||||||
|
`permissions` ni `concurrency`.
|
||||||
|
- **Déploiement non conditionné.** `deploy.yml` partait à chaque push sur `dev` ou `main`,
|
||||||
|
que la CI du commit soit verte ou non, et déployait la pointe de branche du moment plutôt
|
||||||
|
que le commit poussé.
|
||||||
|
- **Erreurs silencieuses et hygiène.**
|
||||||
|
- `npm test --watch=false --code-coverage` : npm garde ces options pour lui, `ng test` ne
|
||||||
|
les reçoit jamais, et la CI ne tenait que par les réglages d'`angular.json`.
|
||||||
|
- `uv sync --frozen` ne vérifie pas que `uv.lock` suit `pyproject.toml`.
|
||||||
|
- Plusieurs actions tierces étaient épinglées par tag, contrairement à la règle Sonar
|
||||||
|
`githubactions:S7637`.
|
||||||
|
- Aucun job n'avait de `timeout-minutes` (360 minutes par défaut).
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
**`ci.yml` est le seul workflow déclenché par `pull_request` et par les push sur `dev` et
|
||||||
|
`main`.** Les workflows de composant (`backend`, `frontend`, `ml`, `airflow`, `infra`, `e2e`)
|
||||||
|
passent en `workflow_call` et n'ont plus de déclencheur propre.
|
||||||
|
|
||||||
|
1. **`changes`.** Un job initial calcule, par `dorny/paths-filter` épinglé sur un SHA, les
|
||||||
|
composants touchés par la PR, et chaque composant n'est appelé que si son filtre vaut vrai.
|
||||||
|
Sur un push vers `dev` ou `main`, tous les filtres valent vrai : l'analyse Sonar reste
|
||||||
|
complète sur les branches longues, et paths-filter ne compare pas à la base de fusion avec
|
||||||
|
`main`, qui a 80 commits de retard.
|
||||||
|
2. **`sonar`.** Il ne reconstruit ni ne reteste plus rien : il télécharge, dans le même run, les
|
||||||
|
couvertures versées par les jobs `verification` des composants.
|
||||||
|
3. **`CI ok`.** Le job agrège le résultat de tous les autres. Il tourne toujours (`if:
|
||||||
|
always()`) et échoue dès qu'un job est en `failure` ou `cancelled`. **C'est le seul check à
|
||||||
|
exiger dans les règles de branche** : un composant sauté par son filtre ne publie aucun check
|
||||||
|
interne, qui resterait « en attente » s'il était exigé.
|
||||||
|
4. **`deploy`.** Il appelle `deploy.yml`, sur les seuls push, et seulement si `CI ok` a réussi.
|
||||||
|
`deploy.yml` aligne le dossier de l'environnement sur `GITHUB_SHA`, le commit testé, sauf
|
||||||
|
si ce commit précède celui déjà déployé : les CI de deux push peuvent finir dans le désordre,
|
||||||
|
et un environnement ne recule jamais. Les déploiements d'un même environnement passent un par
|
||||||
|
un sous un verrou `flock` sur la VM, et non dans un groupe `concurrency`, où GitHub ne garde
|
||||||
|
qu'un job en attente et annule le précédent quand un troisième arrive.
|
||||||
|
|
||||||
|
`deploy.yml` n'a toujours **aucun déclencheur `pull_request`** : il n'accepte que
|
||||||
|
`workflow_call` et `workflow_dispatch`, dans l'esprit de l'ADR 0009.
|
||||||
|
|
||||||
|
## Alternatives écartées
|
||||||
|
|
||||||
|
| Écartée | Raison |
|
||||||
|
|---|---|
|
||||||
|
| Garder huit workflows et restreindre seulement `push` à `dev` et `main` | Supprime la double exécution, pas le doublon Sonar : il faudrait toujours rejouer les tests pour que Sonar ait ses couvertures, les artefacts ne passant pas d'un workflow à l'autre. Et rien n'empêche un déploiement rouge. |
|
||||||
|
| Déclencher le déploiement par `workflow_run` | `workflow_run` joue toujours le fichier de la branche par défaut, `main`, en retard de 80 commits : la recette ne se serait plus déployée avant la prochaine remontée vers `main`, sans erreur visible. |
|
||||||
|
| `alls-green` ou une action tierce d'agrégation | Dix lignes de shell sur `toJSON(needs.*.result)` font le même travail, sans dépendance de plus à épingler. |
|
||||||
|
| Cache de couches Docker (`bake-action`, `type=gha`) pour l'e2e | Quatre pièges (noms d'image, cibles Compose, buildx, `load`) pour deux à quatre minutes gagnées. Reporté après le rendu. |
|
||||||
|
|
||||||
|
## Conséquences
|
||||||
|
|
||||||
|
- Une PR ne joue que ce qu'elle touche. Une PR de documentation ne joue que `changes` et
|
||||||
|
`CI ok`.
|
||||||
|
- Les checks s'appellent désormais « Backend / Lint, typage et tests », etc. Au 23/09, ni `dev`
|
||||||
|
ni `main` n'ont de règle de protection : à la première, exiger **« CI ok »** et rien d'autre.
|
||||||
|
- Modifier `ci.yml` rejoue toute la CI sur la PR (filtre `ci`).
|
||||||
|
- Le job `deploy` reste en file tant que le runner `eni-g3` n'est pas enregistré sur la VM,
|
||||||
|
comme avant. Le groupe de concurrence par SHA des push l'empêche de bloquer les runs suivants.
|
||||||
|
- La sécurité du runner auto-hébergé ne repose pas sur l'absence de `pull_request` dans
|
||||||
|
`deploy.yml`. Une PR de fork peut ajouter son propre workflow. Ce qui protège le runner :
|
||||||
|
- l'approbation obligatoire des workflows de tous les contributeurs externes ;
|
||||||
|
- les règles de branche des environnements `rec` (`dev`) et `prod` (`main` et un relecteur).
|
||||||
|
|
||||||
|
Ces deux réglages restent à poser par l'administratrice du dépôt.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# 0015 - Les tests de bout en bout et de charge visent la stack Compose déployée
|
||||||
|
|
||||||
|
- Statut : accepté
|
||||||
|
- Date : 2026-09-23
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Les issues #46 (Playwright) et #47 (k6) demandent des preuves de robustesse pour EC03 et EC04.
|
||||||
|
Rien ne vérifiait un parcours utilisateur complet : les tests du frontend simulent l'API, ceux
|
||||||
|
du backend n'ouvrent pas de navigateur. Rien ne mesurait non plus l'API sous charge, et le
|
||||||
|
dépôt ne chiffre aucun temps de réponse ni aucun volume d'utilisateurs.
|
||||||
|
|
||||||
|
Trois contraintes du système pèsent sur la manière de tester :
|
||||||
|
|
||||||
|
- **La session tient dans un cookie de refresh HttpOnly qui tourne à chaque usage.** Rejouer un
|
||||||
|
cookie déjà servi révoque toute la famille de session (ADR 0002).
|
||||||
|
- **Le cookie n'est `__Secure-` et `Secure` que hors `local`, derrière le proxy TLS.** Tester
|
||||||
|
contre `ng serve` ne dit rien de ce que voit un navigateur en prod (ADR 0007).
|
||||||
|
- **nginx limite chaque adresse IP** à 20 req/s sur l'API, avec une rafale de 40, et à 30
|
||||||
|
connexions par minute, avec une rafale de 20 (ADR 0007). Tout le trafic d'un tir parti d'une
|
||||||
|
seule machine partage la même adresse.
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
**Playwright joue contre la stack de prod** (`docker-compose.yml` et
|
||||||
|
`docker-compose.prod.yml`), sur `https://localhost` avec un certificat auto-signé.
|
||||||
|
- **En CI**, le workflow `e2e.yml` démarre `db`, `mailpit`, `backend`, `frontend` et `proxy`,
|
||||||
|
sème `db/seeds/demo.sql` et crée les comptes par `scripts/comptes-test.sh`.
|
||||||
|
- **Sur le poste**, la même suite vise `make dev` (`http://localhost:4200`).
|
||||||
|
- **Écriture des tests**, imposée par la rotation du refresh et par la zone `auth` :
|
||||||
|
- un seul worker ;
|
||||||
|
- une session par fichier, sans `storageState` partagé ;
|
||||||
|
- chaque parcours qui consomme un compte le crée lui-même.
|
||||||
|
|
||||||
|
**k6 tourne en service Compose (profil `load`) sur le réseau du projet et vise `backend:8000`**,
|
||||||
|
pour mesurer l'API et non la limite de nginx. Un seul scénario, `limitation-debit.js`, passe par
|
||||||
|
`https://proxy`, pour vérifier que la limite tient : des 429, jamais de 5xx.
|
||||||
|
|
||||||
|
**Hypothèses et seuils**, faute d'exigence chiffrée :
|
||||||
|
|
||||||
|
| Hypothèse ou seuil | Valeur |
|
||||||
|
|---|---|
|
||||||
|
| Utilisateurs simultanés | 50 : 40 sur le tableau de bord, qui interroge `/stats/summary` toutes les 10 s et `/alerts` toutes les 60 s ; 10 qui explorent les sites |
|
||||||
|
| Lectures, p95 | < 500 ms |
|
||||||
|
| Lectures, p99 | < 1 s |
|
||||||
|
| Échecs HTTP | < 1 % |
|
||||||
|
| Vérifications réussies | > 99 % |
|
||||||
|
|
||||||
|
**En CI de PR** : Playwright, le tir `smoke` (une minute) et `limitation-debit`. La charge
|
||||||
|
nominale et le stress se lancent à la main (`make load-test`, `make load-stress`), en recette,
|
||||||
|
parce que rec et prod partagent la VM (ADR 0009).
|
||||||
|
|
||||||
|
## Alternatives écartées
|
||||||
|
|
||||||
|
| Écartée | Raison |
|
||||||
|
|---|---|
|
||||||
|
| Playwright contre `ng serve` en CI | Pas de TLS, pas de cookie `__Secure-`, pas de CSP ni de limitation : le parcours testé ne serait pas celui des utilisateurs. |
|
||||||
|
| `storageState` partagé entre fichiers | Chaque fichier rejouerait le même cookie de refresh ; le second usage révoque la famille, et la suite échoue de façon intermittente selon l'ordre. |
|
||||||
|
| k6 depuis le runner, à travers le proxy | Au-delà de 20 req/s, on mesure nginx. Relever la limite pour le tir, ce serait tester une configuration qui n'est pas celle de la prod. |
|
||||||
|
| Tir de charge complet à chaque PR | Huit minutes de plus par PR, sur un runner partagé dont les performances varient d'un run à l'autre : un seuil franchi n'y voudrait rien dire. |
|
||||||
|
| Un workflow k6 en `workflow_dispatch` contre la recette | La recette partage la VM avec la prod ; un tir déclenché d'un clic ralentirait la prod sans que personne soit prévenu. La cible Makefile, lancée sur la VM, garde un humain dans la boucle. |
|
||||||
|
|
||||||
|
## Conséquences
|
||||||
|
|
||||||
|
- La CI construit enfin les images backend et frontend avant le déploiement, par le job E2E.
|
||||||
|
- `db/seeds/demo.sql` et `scripts/comptes-test.sh` deviennent le jeu commun de la CI, repris par
|
||||||
|
le DAST. Les deux sont réservés aux bases jetables.
|
||||||
|
- Les seuils de k6 sont des hypothèses de l'équipe : à réviser dès qu'un besoin chiffré existe.
|
||||||
|
- L'API expose des seaux de latence fins autour de 500 ms, pour que Grafana lise le même seuil
|
||||||
|
que k6 (ADR 0016).
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# 0016 - La supervision vit dans un profil Compose, active en prod
|
||||||
|
|
||||||
|
- Statut : accepté
|
||||||
|
- Date : 2026-09-23
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
L'API expose `/metrics` au format Prometheus depuis le début, et `monitoring/` ne contenait que
|
||||||
|
des `.gitkeep` : aucun collecteur, aucun tableau de bord, aucune alerte (issue #26). La VM ENI
|
||||||
|
porte la recette et la prod, deux piles complètes, sur 8 Go de mémoire (ADR 0009).
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
**Prometheus, Alertmanager, Grafana et trois exporteurs** sont des services de
|
||||||
|
`docker-compose.yml` sous le profil `monitoring` : postgres-exporter, node-exporter et cAdvisor.
|
||||||
|
|
||||||
|
- **En prod**, `COMPOSE_PROFILES=monitoring` dans le `.env` : `make stack-up`, donc chaque
|
||||||
|
déploiement, les démarre avec le reste.
|
||||||
|
- **En recette et sur le poste**, ils se lancent à la demande (`make monitoring-up`, en
|
||||||
|
`--no-deps`). La recette ne paie rien tant qu'on ne les lance pas.
|
||||||
|
- **Mémoire.** Chaque service a un `mem_limit`, pour environ 700 Mo au total.
|
||||||
|
- **Accès.** Les interfaces n'écoutent que sur `127.0.0.1` et se consultent par tunnel SSH,
|
||||||
|
comme Airflow. Rien ne passe par le proxy : Grafana derrière nginx exigerait sa propre
|
||||||
|
authentification forte, et rendrait `/metrics` joignable à un routage près (ADR 0007).
|
||||||
|
- **Sécurité.**
|
||||||
|
- **Jeton.** Prometheus présente sur `/metrics` le jeton `APP_METRICS_TOKEN`, passé en
|
||||||
|
secret Compose. Il est exigé dès que la supervision tourne.
|
||||||
|
- **Lecture de la base.** Grafana et l'exportateur lisent la base par un rôle `supervision`
|
||||||
|
en lecture seule, limité aux tables métier (`db/roles/supervision.sql`). Ils n'ont
|
||||||
|
jamais accès à `app_user`, aux jetons ni à l'audit.
|
||||||
|
- **Alertes.**
|
||||||
|
- Neuf règles couvrent l'API, la base, l'hôte et les cibles, chacune avec un cas de test
|
||||||
|
joué par `promtool test rules` en CI.
|
||||||
|
- Alertmanager les envoie par courriel à Mailpit, le seul SMTP de la stack.
|
||||||
|
- **Dérive du modèle.** Elle s'affiche dans Grafana par une lecture SQL de `drift_report`.
|
||||||
|
L'ADR 0013 a écarté une jauge Prometheus calculée par un traitement par lot, pas la lecture
|
||||||
|
de sa table.
|
||||||
|
|
||||||
|
## Alternatives écartées
|
||||||
|
|
||||||
|
| Écartée | Raison |
|
||||||
|
|---|---|
|
||||||
|
| Supervision démarrée dans les deux environnements | Double la mémoire consommée sur une VM déjà serrée, pour des tableaux de recette que personne ne regarde. |
|
||||||
|
| Une pile de supervision partagée, troisième projet Compose | Elle devrait rejoindre les réseaux des deux projets, par des réseaux externes à déclarer sur la VM : plus de pièces, et un couplage entre environnements que l'ADR 0009 évite. |
|
||||||
|
| Publier Grafana derrière le proxy | Une interface d'administration de plus exposée au réseau de l'école, et un pas de plus vers une publication accidentelle de `/metrics`. |
|
||||||
|
| Grafana avec le compte applicatif de la base | Le compte applicatif écrit partout, y compris dans `app_user`. Une requête libre dans Grafana y aurait accès. |
|
||||||
|
| Une jauge de fraîcheur des relevés calculée par l'API au moment du scrape | Une requête SQL dans un collecteur synchrone, à chaque scrape. La même information se lit directement dans TimescaleDB depuis Grafana. |
|
||||||
|
|
||||||
|
## Conséquences
|
||||||
|
|
||||||
|
- **Secrets.** `.env.example` gagne `COMPOSE_PROFILES`, `APP_METRICS_TOKEN`,
|
||||||
|
`GRAFANA_ADMIN_PASSWORD`, `SUPERVISION_DB_PASSWORD` et les ports.
|
||||||
|
`scripts/provision-host.sh` génère ces secrets pour un nouvel environnement. Le `.env` d'un
|
||||||
|
environnement déjà provisionné n'est jamais réécrit : il faut les y ajouter à la main.
|
||||||
|
`make stack-up` refuse de démarrer si le profil est actif et qu'un secret manque.
|
||||||
|
- **Instrumentation.** L'API ne compte plus les sondes de santé dans ses métriques, et chaque
|
||||||
|
application a son propre registre Prometheus.
|
||||||
|
- **cAdvisor tourne en `privileged`**, avec des montages en lecture seule et sans port publié.
|
||||||
|
C'est le prix de la mémoire par conteneur, l'indicateur qui compte le plus sur une VM partagée.
|
||||||
|
- **Données non couvertes.** L'ingestion et les DAG Airflow n'ont pas encore de métriques
|
||||||
|
(StatsD ou OpenTelemetry). Le tableau « Données » les supplée en lisant `reading`.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 0017 - Un troisième environnement, `dev`, déployé à la demande depuis n'importe quelle branche
|
||||||
|
|
||||||
|
- Statut : accepté
|
||||||
|
- Date : 2026-09-23
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
L'[ADR 0009](0009-deux-environnements-compose-sur-la-vm-eni.md) a posé deux environnements sur
|
||||||
|
la VM ENI : la recette suit `dev`, la production suit `main`. Les environnements GitHub en
|
||||||
|
comptent trois, `dev`, `rec` et `prod`, et le troisième ne déployait rien.
|
||||||
|
|
||||||
|
Il manque un endroit où montrer une branche de travail avant son merge : la recette ne doit
|
||||||
|
porter que ce qui est intégré à `dev`, sinon elle cesse d'être une recette. Un
|
||||||
|
`workflow_dispatch` sur une branche de travail envoyait d'ailleurs cette branche dans la
|
||||||
|
recette, puisque tout ce qui n'était pas `main` y partait.
|
||||||
|
|
||||||
|
La VM est passée à 32 Go : une troisième TimescaleDB, réglée à 2 Go comme les deux autres,
|
||||||
|
tient sans peine.
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
**Un troisième projet Compose, `enervision-dev`, dans `/srv/enervision/dev`**, bâti exactement
|
||||||
|
comme les deux autres : son clone, son `.env`, son certificat, préparés par
|
||||||
|
`scripts/provision-host.sh`.
|
||||||
|
|
||||||
|
**Déployé à la demande, jamais sur un push.** `deploy.yml` envoie `main` en prod, `dev` en
|
||||||
|
recette, et toute autre branche lancée depuis l'onglet Actions dans `dev`. Seul un membre ayant
|
||||||
|
le droit d'écriture sur le dépôt peut lancer un workflow.
|
||||||
|
|
||||||
|
**Ports décalés d'un cran de plus** : HTTPS `9443`, et sur `127.0.0.1` la redirection HTTP
|
||||||
|
`8083`, PostgreSQL `5435`, Mailpit `8027`, Airflow `8084`. Nom d'hôte `dev.enervision.local`,
|
||||||
|
pour la même raison de cookie que la recette.
|
||||||
|
|
||||||
|
**Le verrou de déploiement suit l'environnement** (un `flock` sur son dossier, ADR 0014), et non
|
||||||
|
plus la branche : deux branches lancées coup sur coup écriraient sinon dans le même dossier en
|
||||||
|
même temps.
|
||||||
|
|
||||||
|
## Alternatives écartées
|
||||||
|
|
||||||
|
- **`dev` suit la branche `dev` à chaque push, la recette devient manuelle** : la recette
|
||||||
|
offrirait une version figée au jury, mais la doc CI/CD, l'ADR 0009 et l'habitude de l'équipe
|
||||||
|
basculeraient à deux jours du rendu.
|
||||||
|
- **Un environnement par branche de travail** : un projet Compose et une TimescaleDB par
|
||||||
|
branche, sans mécanisme de nettoyage. La machine ne le porterait pas longtemps.
|
||||||
|
- **Garder `dev` sur les postes seulement** : rien à montrer d'une branche non mergée sans
|
||||||
|
passer par la recette.
|
||||||
|
|
||||||
|
## Conséquences
|
||||||
|
|
||||||
|
- Une branche de travail créée avant ce changement porte l'ancien `deploy.yml` : lancée à la
|
||||||
|
main, elle part encore dans la recette. Limiter l'environnement GitHub `rec` à la branche
|
||||||
|
`dev` ferme ce chemin, réglage que seul un administrateur du dépôt peut poser.
|
||||||
|
- `dev` ne garde aucune donnée d'une branche à l'autre au-delà de ce que ses migrations
|
||||||
|
acceptent : une branche dont les migrations divergent de `dev` peut laisser la base dans un
|
||||||
|
état que la suivante refuse. Recréer le volume, `docker compose down -v`, est alors le remède.
|
||||||
|
- Trois environnements construisent leurs images séparément : l'écart de l'ADR 0009, un même
|
||||||
|
commit construit deux fois, reste ouvert jusqu'au passage à GHCR.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# 0018 - Noms publics, certificats Let's Encrypt par DNS-01 et frontal SNI sans port
|
||||||
|
|
||||||
|
- Statut : accepté
|
||||||
|
- Date : 2026-09-23
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Les trois environnements de la VM ENI ([ADR 0009](0009-deux-environnements-compose-sur-la-vm-eni.md),
|
||||||
|
[ADR 0017](0017-environnement-dev-a-la-demande.md)) répondaient sur `enervision.local`,
|
||||||
|
`rec.enervision.local:8443` et `dev.enervision.local:9443`, avec des certificats auto-signés.
|
||||||
|
Chaque poste devait éditer son `/etc/hosts` et accepter trois avertissements du navigateur :
|
||||||
|
rien de présentable à un jury, et rien d'utilisable par quelqu'un qui n'a pas la main sur son
|
||||||
|
poste.
|
||||||
|
|
||||||
|
Contraintes : la VM n'a qu'une IP privée, `10.101.200.37`, que ni Internet ni Let's Encrypt ne
|
||||||
|
joignent, et le réseau de l'école ne doit pas être touché. Vérifications faites le 23/09 : les
|
||||||
|
résolveurs de l'école rendent bien une adresse privée pour un nom public, la VM sort en HTTPS
|
||||||
|
vers Let's Encrypt et vers l'API de dynv6, mais le filtrage de l'école bloque duckdns.org, site
|
||||||
|
et API, depuis les postes comme depuis la VM.
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
**Des noms publics qui visent l'IP privée.** Dans `enervision-g3.dynv6.net`, zone gratuite de
|
||||||
|
dynv6, trois enregistrements A portent `prod.`, `rec.` et `dev.`. `provision-host.sh` les publie
|
||||||
|
par l'API dynv6 : le DNS est décrit par le code comme le reste. La prod n'est pas à la racine de
|
||||||
|
la zone : dynv6 y sert mal un TXT `_acme-challenge`, que l'API ne liste ni ne supprime et qu'un
|
||||||
|
seul de ses trois serveurs renvoie (constaté le 23/09), si bien que son défi DNS-01 échoue. Tout
|
||||||
|
poste du réseau de l'école les résout sans configuration ; hors de ce réseau, l'IP ne mène
|
||||||
|
nulle part.
|
||||||
|
|
||||||
|
**Des certificats Let's Encrypt par défi DNS-01.** Le défi passe par l'API dynv6, qui pose
|
||||||
|
l'enregistrement TXT : Let's Encrypt n'a jamais à joindre la VM. `make tls-dns01` (acme.sh
|
||||||
|
épinglé) le joue dans chaque stack ; il ne renouvelle qu'à échéance, d'où son rejeu à chaque
|
||||||
|
déploiement et chaque nuit par cron. `--dnssleep 90` laisse aux trois serveurs de dynv6 le temps
|
||||||
|
de servir le TXT avant que Let's Encrypt ne le cherche depuis plusieurs réseaux. Un certificat par environnement plutôt qu'un joker : chaque
|
||||||
|
stack garde le sien, et la clé de la prod n'est pas lisible depuis le clone de dev.
|
||||||
|
|
||||||
|
**Un frontal SNI sur 443, le seul composant exposé.** `infra/front`, un nginx sur le réseau de
|
||||||
|
l'hôte, lit le nom demandé dans le ClientHello et relaie le flux TLS intact vers la stack visée,
|
||||||
|
publiée sur la boucle locale. Il ne détient aucun certificat. Le port 80 y redirige vers
|
||||||
|
HTTPS. Les URL perdent leur port.
|
||||||
|
|
||||||
|
**Le PROXY protocol entre frontal et stacks.** Relayé tel quel, le flux arriverait avec l'IP du
|
||||||
|
frontal : `limit_req` et `get_client_ip()` compteraient tous les postes comme un seul, et un
|
||||||
|
utilisateur bloquerait la connexion de tous. Chaque proxy de stack reçoit donc le frontal sur un
|
||||||
|
écouteur dédié, 4443, qui exige l'en-tête PROXY protocol et en tire l'IP du client. Le 443 de
|
||||||
|
la stack reste sans PROXY protocol, pour les postes de développement et la sonde du déploiement.
|
||||||
|
|
||||||
|
**Le fournisseur est un paramètre.** `DNS01_API` et `DNS01_JETON_VAR` nomment le greffon acme.sh,
|
||||||
|
le jeton vit dans `dns.token` quel que soit le fournisseur : passer à un domaine acheté chez
|
||||||
|
Cloudflare ou OVH ne demande que ces deux variables et `domaine`, plus `publier_dns()`.
|
||||||
|
|
||||||
|
**`scripts/provision-host.sh` fait foi pour l'adressage et les secrets.** Un `.env` existant
|
||||||
|
garde ses secrets, reçoit ceux qui lui manquent et voit hôte, ports et profils réalignés sur le
|
||||||
|
tableau du script. C'est ce qui permet de migrer trois `.env` nés avant ce changement, et le
|
||||||
|
clone de la prod, en retard sur `main`, sans dépendre de son `.env.example`.
|
||||||
|
|
||||||
|
## Alternatives écartées
|
||||||
|
|
||||||
|
- **Garder `/etc/hosts` et l'auto-signé** : trois manipulations par poste et trois
|
||||||
|
avertissements, précisément ce qu'il fallait supprimer.
|
||||||
|
- **DuckDNS** : premier choix, inscription en un clic, mais bloqué par le filtrage de l'école :
|
||||||
|
sans son API, pas de défi DNS-01.
|
||||||
|
- **deSEC (`dedyn.io`)** : joignable et associatif, mais les inscriptions de nouveaux domaines
|
||||||
|
`dedyn.io` étaient fermées le 23/09 ; il reste le bon choix pour un domaine acheté.
|
||||||
|
- **nip.io ou sslip.io** : résolution sans compte, mais aucun moyen d'y obtenir un certificat.
|
||||||
|
- **Services à certificat joker public (traefik.me, local-ip.co)** : leur clé privée est publiée
|
||||||
|
par conception, n'importe qui peut usurper ces noms.
|
||||||
|
- **Tunnel vers Internet (Cloudflare Tunnel, Tailscale Funnel)** : accès depuis l'extérieur,
|
||||||
|
mais l'application serait exposée hors de l'école, décision refusée.
|
||||||
|
- **Autorité de certification interne (mkcert, step-ca)** : chaque poste devrait l'installer.
|
||||||
|
- **Terminaison TLS au frontal** : un seul endroit pour les certificats, mais les stacks
|
||||||
|
recevraient du HTTP clair que leur proxy redirige vers HTTPS, et en-têtes de sécurité comme
|
||||||
|
limitation de débit seraient à déplacer. Le relais SNI ne touche à rien de tout cela.
|
||||||
|
- **Domaine acheté** : plus présentable, mais un achat et un compte de plus pour un bénéfice nul
|
||||||
|
sur l'accès. Seule `DOMAINE` changerait.
|
||||||
|
|
||||||
|
## Conséquences
|
||||||
|
|
||||||
|
- L'objection de l'ADR 0009 à un proxy frontal, qui aurait dû joindre plusieurs réseaux Compose
|
||||||
|
aux services homonymes, tombe : le frontal ne joint que des ports de la boucle locale.
|
||||||
|
- Sans le frontal, plus rien n'est joignable sur la VM. `deploy.yml` le relance à chaque
|
||||||
|
déploiement de la prod, et son `restart: unless-stopped` le ramène après un redémarrage.
|
||||||
|
- Le jeton dynv6 vit dans `/srv/enervision/dns.token`, jamais dans git, GitHub ni le state
|
||||||
|
Terraform ; acme.sh en garde une copie dans `infra/proxy/acme/`, retirée à la lecture des
|
||||||
|
autres comptes. Qui le détient peut repointer les trois noms.
|
||||||
|
- dynv6 devient une dépendance : s'il tombe, les noms cessent de résoudre et les
|
||||||
|
renouvellements échouent. Les certificats valent 90 jours, la marge est large.
|
||||||
|
- Un filtrage de l'école qui viendrait à bloquer dynv6 arrêterait les renouvellements, pas les
|
||||||
|
noms : la résolution passe par les serveurs DNS de l'école, pas par le site.
|
||||||
|
- Les noms sont publics mais ne mènent qu'à une IP privée : ils révèlent l'existence de la VM,
|
||||||
|
pas son contenu.
|
||||||
@@ -51,8 +51,8 @@ flowchart TB
|
|||||||
api["API FastAPI<br/>apps/backend"]
|
api["API FastAPI<br/>apps/backend"]
|
||||||
db[("PostgreSQL 17<br/>TimescaleDB")]
|
db[("PostgreSQL 17<br/>TimescaleDB")]
|
||||||
airflow["Airflow<br/>etl/airflow"]
|
airflow["Airflow<br/>etl/airflow"]
|
||||||
prom["Prometheus"]
|
prom["Prometheus<br/>profil monitoring"]
|
||||||
grafana["Grafana"]
|
grafana["Grafana<br/>profil monitoring"]
|
||||||
end
|
end
|
||||||
|
|
||||||
navigateur --> proxy
|
navigateur --> proxy
|
||||||
@@ -61,9 +61,9 @@ flowchart TB
|
|||||||
front -.-> api
|
front -.-> api
|
||||||
api --> db
|
api --> db
|
||||||
airflow --> db
|
airflow --> db
|
||||||
prom -.-> api
|
prom --> api
|
||||||
grafana -.-> db
|
grafana --> db
|
||||||
grafana -.-> prom
|
grafana --> prom
|
||||||
```
|
```
|
||||||
|
|
||||||
Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API, mais un
|
Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API, mais un
|
||||||
@@ -82,8 +82,11 @@ CSV plutôt que de laisser les deux sources dupliquer silencieusement un même i
|
|||||||
pipeline ML déduplique par construction (`DISTINCT ON`, source `csv` préférée) au cas où un
|
pipeline ML déduplique par construction (`DISTINCT ON`, source `csv` préférée) au cas où un
|
||||||
recouvrement se produirait malgré tout — voir [40-data.md](40-data.md).
|
recouvrement se produirait malgré tout — voir [40-data.md](40-data.md).
|
||||||
|
|
||||||
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
|
Les liens de la supervision sont en trait plein depuis le 23/09 (issue #26) : Prometheus scrute
|
||||||
collecteur ne vient le lire.
|
`/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
|
## État de la stack
|
||||||
|
|
||||||
@@ -94,9 +97,9 @@ collecteur ne vient le lire.
|
|||||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
|
| 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) |
|
| 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 |
|
| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Provisionnement de la VM par Terraform, qui installe Docker, prépare les deux environnements et enregistre le runner, jamais appliqué ([ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée |
|
||||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
|
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Fait` | Profil Compose `monitoring`, actif en prod : Prometheus et trois exporteurs (PostgreSQL, hôte, conteneurs), neuf règles d'alerte testées par `promtool`, Alertmanager vers Mailpit, trois tableaux de bord Grafana provisionnés. Voir [60-observabilite.md](60-observabilite.md) |
|
||||||
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver et scheduler avec LocalExecutor via Docker Compose, sur une base PostgreSQL dédiée. Six DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes`, `historical_import`, `mock_api_import` et `derive` (quotidien, surveillance de dérive). L'import historique reste manuel et l'import API Mock s'exécute chaque heure. Réconciliation entre les deux sources (issue #15) : trou temporel accepté, recouvrement refusé à l'ingestion et dédupliqué en défense côté ML, voir [40-data.md](40-data.md). |
|
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver et scheduler avec LocalExecutor via Docker Compose, sur une base PostgreSQL dédiée. Six DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes`, `historical_import`, `mock_api_import` et `derive` (quotidien, surveillance de dérive). L'import historique reste manuel et l'import API Mock s'exécute chaque heure. Réconciliation entre les deux sources (issue #15) : trou temporel accepté, recouvrement refusé à l'ingestion et dédupliqué en défense côté ML, voir [40-data.md](40-data.md). |
|
||||||
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 7 workflows, 19 jobs : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow, formatage et validation du Terraform. Déploiement continu vers la VM ENI écrit par `deploy.yml`, `dev` en recette et `main` en production après approbation ([ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)), mais jamais exécuté : la machine n'est pas provisionnée et le runner n'y est pas enregistré. Détail dans [50-cicd.md](50-cicd.md) |
|
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | Un orchestrateur `ci.yml` qui n'appelle que les composants modifiés ([ADR 0014](../adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md)) : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow, Terraform, Compose et supervision, parcours Playwright et tirs k6 contre la stack de prod ([ADR 0015](../adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md)). Déploiement vers la VM ENI par `deploy.yml`, appelé une fois « CI ok » vert, `dev` en recette et `main` en production après approbation ([ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)), mais jamais exécuté : le runner n'est pas enregistré sur la machine. Détail dans [50-cicd.md](50-cicd.md) |
|
||||||
|
|
||||||
## Flux bout en bout
|
## Flux bout en bout
|
||||||
|
|
||||||
@@ -155,7 +158,7 @@ consolidée.
|
|||||||
- **Caviardage des journaux** : jetons, empreintes Argon2, mots de passe et cookies sont
|
- **Caviardage des journaux** : jetons, empreintes Argon2, mots de passe et cookies sont
|
||||||
expurgés avant écriture.
|
expurgés avant écriture.
|
||||||
- **Documentation interactive fermée** en préproduction et en production, `/metrics` derrière un
|
- **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.
|
- **CI backend bloquante** : format, lint, typage strict et tests avec seuil de couverture.
|
||||||
- **Conteneur backend non-root**, déclaré dans `apps/backend/Dockerfile`.
|
- **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
|
- **Terminaison TLS au frontal** : un reverse proxy Nginx est le seul service publié, il redirige
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ flowchart TB
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `db` | `timescale/timescaledb-ha:pg17` | Publié sur **5433** côté hôte, 5432 souvent déjà pris. `healthcheck` `pg_isready`, 12 tentatives, `start_period` 40s |
|
| `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` |
|
| `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` :
|
**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
|
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"]
|
navigateur["Navigateur"]
|
||||||
|
|
||||||
subgraph machine["Machine on-premise"]
|
subgraph machine["Machine on-premise"]
|
||||||
proxy["service proxy<br/>nginx:1.28-alpine<br/>:80 et :443"]
|
proxy["service proxy<br/>nginx:1.31-alpine<br/>:80 et :443"]
|
||||||
front["service frontend<br/>nginx statique :3000"]
|
front["service frontend<br/>nginx statique :3000"]
|
||||||
api["service backend<br/>uvicorn :8000"]
|
api["service backend<br/>uvicorn :8000"]
|
||||||
db[("service db<br/>:5432")]
|
db[("service db<br/>:5432")]
|
||||||
mail["service mailpit"]
|
mail["service mailpit"]
|
||||||
|
sup["profil monitoring<br/>Prometheus, Alertmanager, Grafana"]
|
||||||
end
|
end
|
||||||
|
|
||||||
navigateur -->|"HTTPS"| proxy
|
navigateur -->|"HTTPS"| proxy
|
||||||
@@ -213,6 +216,9 @@ flowchart LR
|
|||||||
proxy -->|"/api/"| api
|
proxy -->|"/api/"| api
|
||||||
api --> db
|
api --> db
|
||||||
api --> mail
|
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
|
Le proxy est **le seul service à publier des ports** sur le réseau. Backend et frontend ne sont
|
||||||
@@ -227,27 +233,36 @@ Deux conséquences se propagent jusqu'à l'application, et elles ne se devinent
|
|||||||
- `APP_TRUST_PROXY_HEADERS` passe à vrai en même temps, sinon la limitation de débit par IP
|
- `APP_TRUST_PROXY_HEADERS` passe à vrai en même temps, sinon la limitation de débit par IP
|
||||||
compte sur l'IP du proxy et devient globale.
|
compte sur l'IP du proxy et devient globale.
|
||||||
|
|
||||||
### Deux environnements sur la même machine
|
### Trois environnements sur la même machine
|
||||||
|
|
||||||
Statut : `En cours`, la machine n'étant pas encore provisionnée. Décision et motifs dans
|
Statut : `En cours`. Décision et motifs dans
|
||||||
l'[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md).
|
l'[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md), étendue à un troisième
|
||||||
La VM `eadl-2025-nantes-g3` portera la recette et la production, chacune dans son clone du dépôt,
|
environnement par l'[ADR 0017](../adr/0017-environnement-dev-a-la-demande.md) ; noms,
|
||||||
son `.env` et son projet Compose. Le nom de projet préfixe volumes, réseau et conteneurs : rien
|
certificats et frontal sans port dans l'[ADR 0018](../adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md).
|
||||||
n'est partagé. `scripts/provision-host.sh` prépare les deux dossiers, génère les secrets et les
|
La VM `eadl-2025-nantes-g3` porte le développement, la recette et la production, chacun dans son
|
||||||
certificats, et ne démarre rien.
|
clone du dépôt, son `.env` et son projet Compose. Le nom de projet préfixe volumes, réseau et
|
||||||
|
conteneurs : rien n'est partagé. `scripts/provision-host.sh` prépare les trois dossiers, génère
|
||||||
|
les secrets et les certificats, et ne démarre rien.
|
||||||
|
|
||||||
| | Recette | Production |
|
| | Développement | Recette | Production |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| Branche, environnement GitHub | `dev`, `rec` | `main`, `prod` |
|
| Branche, environnement GitHub | toute branche lancée à la main, `dev` | `dev`, `rec` | `main`, `prod` |
|
||||||
| Dossier, projet Compose | `/srv/enervision/rec`, `enervision-rec` | `/srv/enervision/prod`, `enervision-prod` |
|
| Dossier, projet Compose | `/srv/enervision/dev`, `enervision-dev` | `/srv/enervision/rec`, `enervision-rec` | `/srv/enervision/prod`, `enervision-prod` |
|
||||||
| URL | `https://rec.enervision.local:8443` | `https://enervision.local` |
|
| URL | `https://dev.enervision-g3.dynv6.net` | `https://rec.enervision-g3.dynv6.net` | `https://prod.enervision-g3.dynv6.net` |
|
||||||
| Proxy HTTP, HTTPS | `127.0.0.1:8081`, `8443` | `80`, `443` |
|
| Proxy HTTP, HTTPS, PROXY protocol, sur `127.0.0.1` | `8083`, `9443`, `9444` | `8081`, `8443`, `8444` | `10080`, `10443`, `10444` |
|
||||||
| PostgreSQL, Mailpit, Airflow, sur `127.0.0.1` | `5434`, `8026`, `8082` | `5433`, `8025`, `8080` |
|
| PostgreSQL, Mailpit, Airflow, sur `127.0.0.1` | `5435`, `8027`, `8084` | `5434`, `8026`, `8082` | `5433`, `8025`, `8080` |
|
||||||
|
| Supervision (profil `monitoring`) | à la demande, `make monitoring-up` | à la demande, `make monitoring-up` | active, `COMPOSE_PROFILES=monitoring` |
|
||||||
|
| Grafana, Prometheus, Alertmanager, sur `127.0.0.1` | `3003`, `9092`, `9095` | `3002`, `9091`, `9094` | `3001`, `9090`, `9093` |
|
||||||
|
|
||||||
Les deux noms d'hôte visent la même IP, à déclarer dans le `/etc/hosts` des postes. Deux noms
|
Les trois noms sont publics chez dynv6 et visent l'IP privée de la VM : rien à déclarer sur
|
||||||
distincts sont nécessaires : le cookie `__Secure-ev_refresh` est posé par hôte, pas par port.
|
les postes du réseau de l'école, et rien n'est joignable hors de ce réseau. Trois noms distincts
|
||||||
La redirection HTTP de la recette est ramenée sur la boucle locale parce que la configuration
|
sont nécessaires : le cookie `__Secure-ev_refresh` est posé par hôte, pas par port.
|
||||||
Nginx renvoie vers `https://$host` sans port, c'est-à-dire vers la production.
|
|
||||||
|
Aucune stack ne publie hors de la boucle locale. Le frontal `infra/front`, sur le réseau de
|
||||||
|
l'hôte, écoute 80 et 443 : il redirige le premier, et aiguille le second d'après le nom demandé
|
||||||
|
(SNI) vers l'écouteur PROXY protocol de la stack visée, sans déchiffrer le TLS. Chaque stack
|
||||||
|
garde son certificat Let's Encrypt, obtenu par défi DNS-01 (`make tls-dns01`) et renouvelé à
|
||||||
|
chaque déploiement ainsi que chaque nuit par `/etc/cron.d/enervision-tls`.
|
||||||
|
|
||||||
Le déploiement est décrit dans [50-cicd.md](50-cicd.md) : un runner GitHub Actions installé sur
|
Le déploiement est décrit dans [50-cicd.md](50-cicd.md) : un runner GitHub Actions installé sur
|
||||||
la VM aligne le dossier sur la branche poussée et lance `make stack-up`.
|
la VM aligne le dossier sur la branche poussée et lance `make stack-up`.
|
||||||
@@ -267,7 +282,7 @@ sequenceDiagram
|
|||||||
|
|
||||||
TF->>VM: SSH, get.docker.com puis docker compose version
|
TF->>VM: SSH, get.docker.com puis docker compose version
|
||||||
TF->>VM: copie et exécute scripts/provision-host.sh
|
TF->>VM: copie et exécute scripts/provision-host.sh
|
||||||
VM->>VM: deux clones, deux .env, deux certificats
|
VM->>VM: trois clones, trois .env, trois certificats
|
||||||
TF->>VM: installe actions-runner, config.sh, svc.sh
|
TF->>VM: installe actions-runner, config.sh, svc.sh
|
||||||
VM->>GH: le runner s'enregistre avec le label eni-g3
|
VM->>GH: le runner s'enregistre avec le label eni-g3
|
||||||
```
|
```
|
||||||
@@ -353,11 +368,13 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
|
|||||||
| API | `8000` | Identique en conteneur et hors conteneur |
|
| API | `8000` | Identique en conteneur et hors conteneur |
|
||||||
| Frontend, `ng serve` | `4200` | Boucle de développement. Valeur par défaut d'`APP_CORS_ORIGINS` |
|
| Frontend, `ng serve` | `4200` | Boucle de développement. Valeur par défaut d'`APP_CORS_ORIGINS` |
|
||||||
| Frontend en conteneur | `3000` | Ce qu'écoute le nginx de l'image, en conteneur comme côté hôte |
|
| Frontend en conteneur | `3000` | Ce qu'écoute le nginx de l'image, en conteneur comme côté hôte |
|
||||||
| Reverse proxy | `80` et `443` | Les seuls ports publiés par `docker-compose.prod.yml`, via `PROXY_HTTP_PORT` et `PROXY_HTTPS_PORT`. 80 ne sert que la redirection et le défi ACME. La recette publie `8443` et `127.0.0.1:8081` |
|
| Reverse proxy | `80` et `443`, plus `4443` | Les seuls ports publiés par `docker-compose.prod.yml`, via `PROXY_HTTP_PORT`, `PROXY_HTTPS_PORT` et `PROXY_FRONT_PORT`. 80 ne sert que la redirection et le défi ACME ; 4443 n'accepte que le PROXY protocol du frontal. Sur la VM, tous sur `127.0.0.1` |
|
||||||
|
| Frontal SNI de la VM | `80` et `443` de l'hôte | `infra/front`, seul composant exposé sur le réseau de l'école (ADR 0018) |
|
||||||
| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable |
|
| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable |
|
||||||
| Base applicative | `enervision` | Variable `POSTGRES_DB` |
|
| 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 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` |
|
| 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 |
|
| 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
|
## Le trou vers k3s
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ démarre ne prouve rien sur la base, la première connexion réelle a lieu au pr
|
|||||||
| `APP_LOGIN_MAX_FAILURES_PER_IDENTIFIER` | `50` | Signature d'une attaque distribuée |
|
| `APP_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_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_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 :
|
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
|
secret de moins de 32 caractères ou laissé à sa valeur d'exemple, `debug` en `staging` ou
|
||||||
@@ -440,7 +440,17 @@ Le reste, par ordre de surface :
|
|||||||
|
|
||||||
- Journalisation par `dictConfig` : format console en développement, JSON dès `APP_ENV=prod`.
|
- 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.
|
`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
|
## Tests
|
||||||
|
|
||||||
|
|||||||
+141
-107
@@ -10,8 +10,9 @@ vérifié, ce qui bloque, et ce qui ne l'est pas.
|
|||||||
|
|
||||||
Le **D** de CI/CD est écrit depuis le 21/09 : `deploy.yml` déploie `dev` en recette et `main` en
|
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,
|
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
|
[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)). Depuis le 23/09, il ne part
|
||||||
déployé : la machine n'est pas provisionnée et le runner n'y est pas enregistré. Statut à
|
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
|
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
|
soutenance : les images sont construites sur la machine à chaque déploiement, aucun artefact
|
||||||
n'est publié puis promu d'un environnement à l'autre.
|
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
|
## 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
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
push["push ou pull_request"]
|
evt["pull_request, ou push sur dev et main"]
|
||||||
|
changes["changes<br/>paths-filter : composants touchés"]
|
||||||
|
|
||||||
subgraph back["Backend · .github/workflows/backend.yml"]
|
subgraph comp["Workflows de composant (workflow_call)"]
|
||||||
bv["verification<br/>ruff, mypy, pytest --cov-fail-under=85"]
|
back["backend.yml<br/>lint, typage, tests ≥ 85 %, intégration, pip-audit, bandit"]
|
||||||
bi["integration<br/>TimescaleDB réel + alembic upgrade head"]
|
front["frontend.yml<br/>build et tests, npm audit"]
|
||||||
bd["security-audit<br/>uv export | pip-audit"]
|
mlw["ml.yml<br/>lint, typage, tests, ML ↔ DB, chaîne ML → API, bandit"]
|
||||||
bs["sast<br/>bandit"]
|
afw["airflow.yml<br/>intégrité des DAGs, image"]
|
||||||
|
infw["infra.yml<br/>Terraform, Compose et supervision, actionlint"]
|
||||||
|
e2e["e2e.yml<br/>stack de prod, Playwright, k6 smoke et limitation"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph front["Frontend · frontend.yml"]
|
sonar["sonar<br/>reprend les couvertures du run"]
|
||||||
fb["build<br/>npm ci, npm run build"]
|
ok["CI ok<br/>seul check à exiger"]
|
||||||
ft["test<br/>couverture lcov"]
|
dep["deploy.yml<br/>runner eni-g3, rec ou prod"]
|
||||||
fd["security-audit<br/>npm audit --audit-level=high"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph mlw["ML · ml.yml"]
|
evt --> changes --> back & front & mlw & afw & infw & e2e
|
||||||
mv["verification<br/>ruff, mypy, pytest"]
|
back & front & mlw --> sonar
|
||||||
ms["sast<br/>bandit"]
|
back & front & mlw & afw & infw & e2e & sonar --> ok
|
||||||
end
|
ok -->|"push sur dev ou main"| dep
|
||||||
|
|
||||||
subgraph afw["Airflow · airflow.yml"]
|
planifie["chaque lundi 3h UTC, à la main,<br/>ou PR sur ses fichiers"]
|
||||||
av["verification<br/>ruff, intégrité des DAGs"]
|
dast["dast.yml<br/>seed + scan actif OWASP ZAP"]
|
||||||
ab["image<br/>construction de l'image"]
|
planifie --> dast
|
||||||
end
|
|
||||||
|
|
||||||
subgraph infw["Infra · infra.yml"]
|
|
||||||
it["terraform<br/>fmt -check, init et validate par racine"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph sq["SonarQube · sonarqube.yml"]
|
|
||||||
sb1["build-front / test-front"]
|
|
||||||
sb2["build-back / test-back"]
|
|
||||||
sb3["test-ml"]
|
|
||||||
sscan["sonarqube<br/>quality gate SonarCloud"]
|
|
||||||
end
|
|
||||||
|
|
||||||
push --> bv & bi & bd & bs
|
|
||||||
push --> fb --> ft
|
|
||||||
push --> fd
|
|
||||||
push --> mv & ms
|
|
||||||
push --> av & ab
|
|
||||||
push --> it
|
|
||||||
push --> sb1 & sb2 & sb3 --> sscan
|
|
||||||
|
|
||||||
subgraph cd["Déploiement · deploy.yml"]
|
|
||||||
dep["deploy<br/>runner eni-g3, environnement rec ou prod"]
|
|
||||||
end
|
|
||||||
|
|
||||||
push -->|"push sur dev ou main"| dep
|
|
||||||
|
|
||||||
planifie["chaque lundi 3h UTC,<br/>ou à la main"]
|
|
||||||
subgraph dastw["DAST · dast.yml"]
|
|
||||||
zscan["zap<br/>seed + scan actif OWASP ZAP"]
|
|
||||||
end
|
|
||||||
|
|
||||||
planifie --> zscan
|
|
||||||
push -->|"PR sur dast.yml<br/>ou dast-token.sh"| zscan
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Déclenchement
|
## Déclenchement
|
||||||
|
|
||||||
Les six workflows hébergés par GitHub qui vérifient le code se déclenchent sur `push` **et** sur
|
**Sur une PR**, le job `changes` lit la liste des fichiers modifiés par l'API GitHub
|
||||||
`pull_request`, filtrés par **chemin** : `backend.yml` sur `apps/backend/**`, `frontend.yml` sur
|
(`dorny/paths-filter`, épinglé sur un SHA) et chaque composant n'est appelé que si son filtre
|
||||||
`apps/frontend/**`, `ml.yml` sur `ml/**`, `infra.yml` sur `infra/terraform/**`, `airflow.yml` sur
|
vaut vrai. Une PR de documentation ne joue que `changes` et `CI ok`. Modifier `ci.yml` rejoue
|
||||||
`etl/airflow/**` **plus des chemins de `ml/` et de `apps/backend/`**, chacun incluant son propre
|
tout.
|
||||||
fichier de workflow dans le filtre pour qu'une modification du pipeline déclenche le pipeline.
|
|
||||||
|
|
||||||
`dast.yml` s'en écarte volontairement (détail dans sa propre section plus bas) : aucun
|
**Sur un push vers `dev` ou `main`**, tous les filtres valent vrai. C'est le moment où l'analyse
|
||||||
déclenchement sur `push`, seulement `workflow_dispatch`, une planification hebdomadaire, et
|
Sonar doit couvrir tout le dépôt, et paths-filter comparerait sinon le push à sa base de fusion
|
||||||
`pull_request` restreint à ses deux seuls fichiers. Un scan actif est trop long pour tourner à
|
avec `main`, en retard de 80 commits. Une branche de travail ne déclenche plus rien par un push :
|
||||||
chaque commit.
|
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`,
|
Deux filtres écoutent plus que leur dossier, parce que ce qu'ils testent dépend d'autres modules :
|
||||||
`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.
|
|
||||||
|
|
||||||
**Piège à connaître** : il n'y a **aucun filtre de branche**. Une branche de travail déclenche la
|
- `airflow` inclut `ml/pyproject.toml`, `ml/uv.lock`, `ml/enervision_ml/**`,
|
||||||
CI complète à chaque push, et un merge vers n'importe quelle branche la déclenche aussi. C'est
|
`apps/backend/pyproject.toml`, `apps/backend/uv.lock` et `apps/backend/app/**`. L'image
|
||||||
délibéré pendant le projet (retour au plus tôt, et la CI tournera sur `main` dès la remontée sans
|
Airflow copie le code et les dépendances des deux modules
|
||||||
rien changer), mais ce serait à borner sur un dépôt à forte fréquence de push.
|
([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
|
`dast.yml` reste hors de l'orchestrateur : un scan actif est trop long pour chaque PR. Il se
|
||||||
git avec `cancel-in-progress`, ce qui annule un run devenu obsolète par un push plus récent.
|
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
|
**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
|
de l'image `apache/airflow:3.3.2-python3.12` retenue, et les tests d'intégrité doivent tourner sur
|
||||||
@@ -121,35 +94,52 @@ environnement.
|
|||||||
|
|
||||||
## Déploiement
|
## Déploiement
|
||||||
|
|
||||||
`deploy.yml` est le huitième workflow (`backend`, `frontend`, `ml`, `infra`, `airflow`,
|
`deploy.yml` est le seul workflow qui ne tourne pas chez GitHub : il s'exécute sur un runner
|
||||||
`sonarqube`, `dast`, plus lui-même), et le seul qui ne tourne pas chez GitHub : il s'exécute sur
|
auto-hébergé installé sur la VM ENI, label `eni-g3`, parce que les runners hébergés ne joignent
|
||||||
un runner auto-hébergé installé sur la VM ENI, label `eni-g3`, parce que les runners hébergés ne
|
pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port entrant
|
||||||
joignent pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port
|
n'est ouvert. Il n'a pas de déclencheur propre en dehors de `workflow_dispatch` : c'est le job
|
||||||
entrant n'est ouvert.
|
`deploy` de `ci.yml` qui l'appelle, sur un push, une fois « CI ok » vert.
|
||||||
|
|
||||||
| Événement | Environnement GitHub | Dossier sur la VM | Garde |
|
| Événement | Environnement GitHub | Dossier sur la VM | Garde |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `push` sur `dev` | `rec` | `/srv/enervision/rec` | aucune : la recette suit `dev` |
|
| `push` sur `dev`, « CI ok » vert | `rec` | `/srv/enervision/rec` | aucune de plus : 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 `main`, « CI ok » vert | `prod` | `/srv/enervision/prod` | approbation d'un relecteur dans l'environnement `prod`, branche `main` seule autorisée |
|
||||||
|
| `workflow_dispatch` sur toute autre branche | `dev` | `/srv/enervision/dev` | droit d'écriture sur le dépôt, seul à pouvoir lancer un workflow ([ADR 0017](../adr/0017-environnement-dev-a-la-demande.md)) |
|
||||||
|
|
||||||
Le job aligne le clone sur la branche (`fetch`, `checkout`, `reset --hard`), lance
|
Le job aligne le clone sur **le commit testé** (`fetch`, `checkout`, `reset --hard $GITHUB_SHA`),
|
||||||
|
et non sur la pointe de branche du moment, qui a pu avancer pendant la CI. Il lance
|
||||||
`make stack-up`, qui reconstruit les images, redémarre les conteneurs puis applique les
|
`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
|
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
|
`/api/v1/health/ready` réponde derrière le proxy. Cette sonde ne vérifie que la connexion à la
|
||||||
base et la présence de TimescaleDB : sans la migration, le déploiement serait vert sur une base
|
base et la présence de TimescaleDB : sans la migration, le déploiement serait vert sur une base
|
||||||
sans schéma, et c'est pourquoi `make stack-up` la porte. Un groupe de concurrence par branche,
|
sans schéma, et c'est pourquoi `make stack-up` la porte.
|
||||||
sans annulation, empêche deux déploiements simultanés du même environnement.
|
|
||||||
|
Les CI de deux push rapprochés peuvent finir dans le désordre. Deux gardes empêchent un
|
||||||
|
environnement de reculer ou de sauter un commit :
|
||||||
|
|
||||||
|
- un commit qui **précède** celui déjà déployé depuis la même branche est ignoré, avec une
|
||||||
|
annotation dans le run. Dans `dev`, une autre branche que celle en place est toujours déployée ;
|
||||||
|
- les déploiements d'un même environnement passent un par un sous un verrou `flock` posé dans le
|
||||||
|
clone de la VM, y compris deux branches lancées coup sur coup dans `dev`. Un groupe
|
||||||
|
`concurrency` ne convenait pas : GitHub n'y garde qu'un job en attente, et un troisième arrivé
|
||||||
|
l'annule sans erreur.
|
||||||
|
|
||||||
Le job ne fait pas de `actions/checkout` dans son espace de travail, et c'est voulu : le dossier
|
Le job ne fait pas de `actions/checkout` dans son espace de travail, et c'est voulu : le dossier
|
||||||
de l'environnement est stable, hors du runner, parce que `.env`, certificats et volumes doivent
|
de l'environnement est stable, hors du runner, parce que `.env`, certificats et volumes doivent
|
||||||
survivre d'un déploiement à l'autre.
|
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
|
**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
|
envoie, et une PR de fork peut ajouter son propre workflow qui vise le label `eni-g3`. L'absence
|
||||||
nécessaires : `deploy.yml` ne se déclenche jamais sur `pull_request` ; le runner tourne sous un
|
de `pull_request` dans `deploy.yml` ne suffit donc pas. Ce qui protège vraiment le runner :
|
||||||
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
|
- le dépôt exige l'approbation des workflows de tous les contributeurs externes (Settings,
|
||||||
collaborators »), ce qui reste à activer. Les workflows de CI restent sur `ubuntu-latest`.
|
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
|
Cet utilisateur dédié doit posséder `/srv/enervision` : sinon git refuse les deux clones pour
|
||||||
propriété douteuse et le `.env` en `600` lui échappe. `PROPRIETAIRE=<utilisateur du runner>`
|
propriété douteuse et le `.env` en `600` lui échappe. `PROPRIETAIRE=<utilisateur du runner>`
|
||||||
@@ -157,7 +147,7 @@ passé à `scripts/provision-host.sh` fixe ce propriétaire.
|
|||||||
|
|
||||||
La machine se prépare avec `scripts/provision-host.sh`, qui vérifie Docker et Compose 2.24.4 ou
|
La machine se prépare avec `scripts/provision-host.sh`, qui vérifie Docker et Compose 2.24.4 ou
|
||||||
plus, clone les deux branches, génère les secrets de chaque `.env` et les certificats
|
plus, clone les deux branches, génère les secrets de chaque `.env` et les certificats
|
||||||
auto-signés, et ne démarre rien. Le détail des deux environnements, ports et noms d'hôte, est
|
auto-signés, et ne démarre rien. Le détail des trois environnements, ports et noms d'hôte, est
|
||||||
dans [10-infra.md](10-infra.md).
|
dans [10-infra.md](10-infra.md).
|
||||||
|
|
||||||
## Ce qui bloque un merge
|
## Ce qui bloque un merge
|
||||||
@@ -179,6 +169,14 @@ dans [10-infra.md](10-infra.md).
|
|||||||
| Intégrité des DAGs | airflow | chargement des DAGs sans erreur d'import | Bloque |
|
| 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 |
|
| 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 |
|
| 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 :
|
Deux seuils portent une décision qu'il faut savoir défendre :
|
||||||
|
|
||||||
@@ -218,7 +216,7 @@ qu'évite déjà le choix de l'image `timescaledb-ha` plutôt qu'un `postgres` n
|
|||||||
donc les deux environnements uv, applique `alembic upgrade head`, puis joue `-m integration` côté
|
donc les deux environnements uv, applique `alembic upgrade head`, puis joue `-m integration` côté
|
||||||
`ml/` et `-m chaine` côté backend.
|
`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
|
`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
|
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
|
avant la production**. Le prix est qu'une PR touchant seulement une migration lance aussi le lint
|
||||||
@@ -233,10 +231,18 @@ poste où `ml/` n'est pas installé.
|
|||||||
|
|
||||||
## SonarCloud, et l'incident qui a immobilisé trois PR
|
## SonarCloud, et l'incident qui a immobilisé trois PR
|
||||||
|
|
||||||
Le workflow `sonarqube.yml` exécute cinq jobs de préparation (`build-front`, `test-front`,
|
Le job `sonar` de `ci.yml` ne reconstruit ni ne reteste rien. Les jobs `verification` de
|
||||||
`build-back`, `test-back`, `test-ml`) dont les tests produisent chacun un rapport de couverture en
|
`backend.yml`, `ml.yml` et `frontend.yml` versent leur rapport de couverture en artefact, et
|
||||||
artefact, puis un dernier job qui les télécharge et lance `SonarSource/sonarqube-scan-action@v8`
|
`sonar` les télécharge dans le même run, un par un (backend et ML nomment tous deux le leur
|
||||||
avec le secret `SONAR_TOKEN`. Le périmètre est décrit par `sonar-project.properties` à la racine.
|
`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
|
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
|
ajoutés après coup : ils n'étaient pas analysés, une PR qui ne touchait qu'eux ne lançait pas
|
||||||
@@ -261,9 +267,10 @@ contournée** en désactivant la gate ou en excluant les fichiers gênants.
|
|||||||
|
|
||||||
## Dependabot
|
## Dependabot
|
||||||
|
|
||||||
`.github/dependabot.yml` déclare **six entrées hebdomadaires groupées, sur cinq écosystèmes** :
|
`.github/dependabot.yml` déclare **sept entrées hebdomadaires, sur cinq écosystèmes** : `npm`
|
||||||
`npm` sur `/apps/frontend`, `uv` sur `/apps/backend`, `github-actions` sur `/`, `docker` sur les
|
sur `/apps/frontend` et sur `/tests/e2e`, `uv` sur `/apps/backend`, `github-actions` sur `/`,
|
||||||
deux dossiers d'application, et `docker-compose` sur `/`. Les mises à jour arrivent en PR, donc
|
`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
|
elles traversent les mêmes gates que n'importe quel changement : une montée de version qui casse
|
||||||
les tests ne se merge pas.
|
les tests ne se merge pas.
|
||||||
|
|
||||||
@@ -300,10 +307,10 @@ UTC, et sur une PR qui modifie le scan lui-même. Pas à chaque PR : un scan act
|
|||||||
minutes.
|
minutes.
|
||||||
|
|
||||||
Le job démarre sur le runner la base (même image TimescaleDB que `docker-compose.yml`, base
|
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
|
jetable), applique les migrations, y sème `db/seeds/demo.sql` (sans données, `GET /sites` rend
|
||||||
encore d'outillage de jeu de données pour la CI ; sans données, `GET /sites` rend `[]`, chaque
|
`[]`, chaque `/{site_id}` rend 404, et le scan actif ne frappe que des gestionnaires d'erreur),
|
||||||
`/{site_id}` rend 404, et le scan actif ne frappe que des gestionnaires d'erreur), démarre le
|
démarre le backend, puis `scripts/dast-token.sh` s'appuie sur `scripts/comptes-test.sh` pour
|
||||||
backend, puis `scripts/dast-token.sh` crée un compte **`lecteur`** et rend son jeton.
|
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 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
|
/zap/wrk/openapi.json`) et en importe les 26 opérations **quel que soit le jeton** : c'est le
|
||||||
@@ -392,15 +399,34 @@ pas de TLS, pas de reverse proxy). Il remontera des alertes qui n'existent pas d
|
|||||||
(HSTS absent...) et ne dit **rien** des en-têtes ni du TLS que le proxy pose en production. Un
|
(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.
|
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
|
## Ce qui manque, et pourquoi
|
||||||
|
|
||||||
| Manque | Issue | Conséquence assumée |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
| Tests de charge | #47 | Aucun garde-fou de performance |
|
| 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 `Dockerfile` sont construits en local, pas analysés |
|
| Scan d'image de conteneur | aucune | Les images sont construites par le job E2E, pas analysées |
|
||||||
|
|
||||||
## Reproduire la CI en local
|
## Reproduire la CI en local
|
||||||
|
|
||||||
@@ -417,6 +443,14 @@ make ml-test-integration # pipeline ML, marqueur `integration`
|
|||||||
make test-chaine # vrais binaires ML puis relecture par l'API, marqueur `chaine`
|
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
|
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
|
--confidence-level medium` depuis `apps/backend`, et la même commande sur `enervision_ml` depuis
|
||||||
`ml`.
|
`ml`.
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Observabilité
|
||||||
|
|
||||||
|
Ce document décrit ce qu'on voit du système en fonctionnement : métriques, tableaux de bord,
|
||||||
|
alertes et journaux. Décision dans l'[ADR 0016](../adr/0016-supervision-en-profil-compose.md),
|
||||||
|
mode d'emploi dans [`monitoring/README.md`](../../monitoring/README.md).
|
||||||
|
|
||||||
|
| Brique | Sert à | Statut |
|
||||||
|
|---|---|---|
|
||||||
|
| Métriques de l'API | Débit, erreurs et latences par route | `Fait` |
|
||||||
|
| Collecte et alertes | Prometheus, neuf règles testées, Alertmanager vers Mailpit | `Fait` |
|
||||||
|
| Tableaux de bord | Grafana : API, données et modèle, infrastructure | `Fait` |
|
||||||
|
| Métriques d'Airflow | StatsD ou OpenTelemetry des DAGs | `Cible` |
|
||||||
|
| Journaux centralisés | Loki ou équivalent | `Cible` |
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph projet["Projet Compose de la prod"]
|
||||||
|
api["backend<br/>/metrics"]
|
||||||
|
db[("db<br/>TimescaleDB")]
|
||||||
|
mail["mailpit"]
|
||||||
|
|
||||||
|
subgraph sup["Profil monitoring"]
|
||||||
|
prom["prometheus<br/>15 s, 15 jours"]
|
||||||
|
am["alertmanager"]
|
||||||
|
graf["grafana"]
|
||||||
|
pge["postgres-exporter"]
|
||||||
|
node["node-exporter"]
|
||||||
|
cad["cadvisor"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
hote["Hôte : VM ENI<br/>recette et prod"]
|
||||||
|
|
||||||
|
prom -->|"Bearer APP_METRICS_TOKEN"| api
|
||||||
|
prom --> pge & node & cad
|
||||||
|
pge -->|"rôle supervision"| db
|
||||||
|
node -.->|"/proc, /sys"| hote
|
||||||
|
cad -.->|"cgroups"| hote
|
||||||
|
prom -->|"règles franchies"| am -->|"SMTP"| mail
|
||||||
|
graf --> prom
|
||||||
|
graf -->|"rôle supervision, SQL"| db
|
||||||
|
```
|
||||||
|
|
||||||
|
Tout vit dans le projet Compose de la prod, sur son réseau. La recette n'a pas de supervision
|
||||||
|
propre. node-exporter et cAdvisor voient pourtant tout l'hôte : la mémoire de la VM et de chaque
|
||||||
|
conteneur couvre donc aussi la recette, qu'on distingue au préfixe `enervision-rec-`.
|
||||||
|
|
||||||
|
## Ce que mesure chaque source
|
||||||
|
|
||||||
|
| Source | Métriques utiles | Où les lire |
|
||||||
|
|---|---|---|
|
||||||
|
| API (`prometheus-fastapi-instrumentator`) | `http_requests_total` par route et classe de statut, `http_request_duration_seconds` par route (seaux 50 ms à 2,5 s), `http_request_duration_highr_seconds` global, mémoire du processus | Tableau « API » |
|
||||||
|
| postgres-exporter | `pg_up`, connexions par état, `max_connections`, transactions validées, taille des bases | Tableau « Infrastructure » |
|
||||||
|
| node-exporter | Processeur, mémoire disponible, espace disque de `/` | Tableau « Infrastructure » |
|
||||||
|
| cAdvisor | Mémoire (`working_set`) et processeur par conteneur | Tableau « Infrastructure » |
|
||||||
|
| TimescaleDB, en SQL | Fraîcheur des relevés par site, relevés ingérés par heure, alertes par sévérité, `drift_report` | Tableau « Données et modèle » |
|
||||||
|
|
||||||
|
Deux choix de l'instrumentation se lisent dans ces courbes :
|
||||||
|
|
||||||
|
- **Les sondes `/health/*` et `/metrics` ne sont pas comptées.** La sonde Docker frappe toutes
|
||||||
|
les 30 s : incluse, elle ferait baisser la latence moyenne et gonfler le débit d'une API au
|
||||||
|
repos.
|
||||||
|
- **Les seaux par route encadrent 500 ms**, seuil de charge de l'ADR 0015. Grafana lit ainsi le
|
||||||
|
même p95 que k6 pendant un tir.
|
||||||
|
|
||||||
|
## Alertes
|
||||||
|
|
||||||
|
| Groupe | Alertes | Sévérité |
|
||||||
|
|---|---|---|
|
||||||
|
| API | Indisponible 2 min, 5xx au-delà de 5 %, p95 au-delà d'une seconde | critical, critical, warning |
|
||||||
|
| Base | PostgreSQL injoignable 2 min, connexions au-delà de 80 % | critical, warning |
|
||||||
|
| Hôte | Mémoire au-delà de 90 %, disque sous 10 %, processeur au-delà de 90 % | warning, critical, warning |
|
||||||
|
| Supervision | Un exporteur muet 5 min | warning |
|
||||||
|
|
||||||
|
- **Tests des règles.** Chaque règle a un cas dans `monitoring/prometheus/tests/`, joué par
|
||||||
|
`promtool test rules` dans le job Infra de la CI. Une règle qui ne se déclenche plus, ou se
|
||||||
|
déclenche à tort, casse la CI avant d'atteindre la prod.
|
||||||
|
- **Envoi.** Alertmanager groupe les alertes par nom et sévérité et les envoie par courriel via
|
||||||
|
Mailpit, qui les capture sans rien relayer. Un `critical` est rappelé toutes les heures, un
|
||||||
|
`warning` toutes les douze. Un `critical` masque le `warning` de la même cible.
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
- **Aucune interface exposée.** Prometheus, Alertmanager et Grafana n'écoutent que sur
|
||||||
|
`127.0.0.1`, et rien ne passe par le proxy (ADR 0007). Accès par tunnel SSH.
|
||||||
|
- **`/metrics` gardé par jeton.** Il n'est pas routé par nginx, et Prometheus y présente
|
||||||
|
`APP_METRICS_TOKEN`, que l'API exige dès qu'il est posé. Le jeton lui parvient en secret
|
||||||
|
Compose, jamais en clair dans sa configuration.
|
||||||
|
- **Base en lecture seule.** Grafana et postgres-exporter lisent la base par le rôle
|
||||||
|
`supervision`, en lecture seule, limité aux tables métier (`db/roles/supervision.sql`). Ils
|
||||||
|
n'ont ni `app_user`, ni jetons, ni journal d'audit.
|
||||||
|
- **Grafana verrouillé.** Il refuse de démarrer sans `GRAFANA_ADMIN_PASSWORD`. Inscription,
|
||||||
|
accès anonyme et appels sortants (statistiques d'usage, vérification de mises à jour) y sont
|
||||||
|
désactivés.
|
||||||
|
- **cAdvisor en `privileged`.** Il tourne ainsi pour lire les cgroups, avec des montages en
|
||||||
|
lecture seule et sans port publié.
|
||||||
|
|
||||||
|
## Journaux
|
||||||
|
|
||||||
|
Les journaux restent ceux de Docker : `docker compose logs`, `make stack-logs`,
|
||||||
|
`make monitoring-logs`. L'API écrit du JSON dès `APP_ENV=prod`, caviardé des jetons et des mots
|
||||||
|
de passe (voir [20-backend.md](20-backend.md)). Aucune agrégation centralisée n'est en place.
|
||||||
|
|
||||||
|
## Ce qui manque
|
||||||
|
|
||||||
|
| Manque | Conséquence assumée |
|
||||||
|
|---|---|
|
||||||
|
| Métriques d'Airflow (StatsD, OpenTelemetry) | Un DAG qui échoue ne se voit que dans Airflow ; le tableau « Données » le trahit indirectement par des relevés qui vieillissent |
|
||||||
|
| Alerte sur la fraîcheur des relevés | Visible dans Grafana, mais aucune règle Prometheus ne la porte : il faudrait une métrique calculée par l'API ou un exportateur SQL |
|
||||||
|
| Journaux centralisés | Un incident se diagnostique conteneur par conteneur |
|
||||||
|
| Destinataire réel des alertes | Mailpit capture tout : les alertes se lisent dans son interface, elles ne réveillent personne |
|
||||||
@@ -15,12 +15,12 @@ contredisent, c'est l'ADR qui fait foi et la vue qui est en retard.
|
|||||||
| [31-contrat-authentification.md](31-contrat-authentification.md) | Ce que le frontend doit savoir pour coder la connexion |
|
| [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 |
|
| [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 |
|
| [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
|
La CI/CD a son document : un orchestrateur et ses workflows de composant, c'est assez de
|
||||||
qu'une section de plus dans une autre vue devienne illisible. L'observabilité, elle, n'en a
|
matière pour qu'une section de plus dans une autre vue devienne illisible. L'observabilité a le
|
||||||
toujours pas : `monitoring/` ne contient que des `.gitkeep`. Elle en sortira le jour où elle aura
|
sien depuis l'issue #26, qui lui a donné de la matière : collecte, alertes et tableaux de bord.
|
||||||
de la matière. Un fichier vide de plus n'aide personne.
|
|
||||||
|
|
||||||
L'orchestration Airflow, elle, en a depuis les issues #115 et #116 : trois DAGs, leur image et
|
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).
|
leurs contraintes sont décrits dans [10-infra.md](10-infra.md).
|
||||||
|
|||||||
+13
-5
@@ -8,8 +8,9 @@ Rien ici ne construit d'image ni ne lance de conteneur.
|
|||||||
- `k3s` : installe un cluster k3s single-node sur une machine distante via SSH
|
- `k3s` : installe un cluster k3s single-node sur une machine distante via SSH
|
||||||
(script officiel `get.k3s.io`) et rapatrie le kubeconfig en local.
|
(script officiel `get.k3s.io`) et rapatrie le kubeconfig en local.
|
||||||
- `terraform/environments/<racine>` : une racine par machine provisionnee.
|
- `terraform/environments/<racine>` : une racine par machine provisionnee.
|
||||||
- `vm-eni` : la VM `eadl-2025-nantes-g3`, qui porte les environnements `rec` et `prod`
|
- `vm-eni` : la VM `eadl-2025-nantes-g3`, qui porte les environnements `dev`, `rec` et `prod`
|
||||||
([ADR 0009](../docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md)). Installe Docker,
|
([ADR 0009](../docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md),
|
||||||
|
[ADR 0017](../docs/adr/0017-environnement-dev-a-la-demande.md)). Installe Docker,
|
||||||
execute `scripts/provision-host.sh`, enregistre le runner GitHub Actions.
|
execute `scripts/provision-host.sh`, enregistre le runner GitHub Actions.
|
||||||
- `k3s-cible` : le cluster k3s, cible a terme de `docs/architecture/10-infra.md`. Jamais
|
- `k3s-cible` : le cluster k3s, cible a terme de `docs/architecture/10-infra.md`. Jamais
|
||||||
applique.
|
applique.
|
||||||
@@ -32,9 +33,16 @@ terraform apply
|
|||||||
Parametres du depot, Actions, Runners, New self-hosted runner. Seul un administrateur du depot
|
Parametres du depot, Actions, Runners, New self-hosted runner. Seul un administrateur du depot
|
||||||
peut le creer.
|
peut le creer.
|
||||||
|
|
||||||
Apres l'apply, la machine porte `/srv/enervision/rec` et `/srv/enervision/prod`, chacun avec son
|
Apres l'apply, la machine porte `/srv/enervision/dev`, `/srv/enervision/rec` et
|
||||||
`.env` et son certificat. Le premier demarrage reste manuel, `make stack-up` dans chaque dossier ;
|
`/srv/enervision/prod`, chacun avec son `.env` et son certificat. Le premier demarrage reste
|
||||||
les suivants sont joues par le runner a chaque push sur `dev` et sur `main`.
|
manuel, `make stack-up` dans chaque dossier ; les suivants sont joues par le runner a chaque push
|
||||||
|
sur `dev` et sur `main`, et a chaque lancement manuel d'une autre branche pour `dev`.
|
||||||
|
|
||||||
|
Noms et certificats (ADR 0018) : avant l'apply, la zone `domaine` doit exister chez dynv6 et son
|
||||||
|
jeton se trouver dans `<racine>/dns.token` (600, proprietaire). L'apply fait alors pointer la
|
||||||
|
zone, `prod`, `rec` et `dev` vers la machine, obtient un certificat Let's Encrypt par environnement et planifie leur
|
||||||
|
renouvellement ; sans jeton, chaque environnement garde un certificat auto-signe. Le frontal SNI (`infra/front`)
|
||||||
|
se demarre une fois depuis le dossier de la prod, `make front-up`.
|
||||||
|
|
||||||
Retirer le runner se fait a la main, depuis les parametres du depot : `terraform destroy` ne le
|
Retirer le runner se fait a la main, depuis les parametres du depot : `terraform destroy` ne le
|
||||||
desinscrit pas.
|
desinscrit pas.
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Pourquoi : le réseau de l'hôte, parce que les trois stacks publient leur écouteur PROXY protocol
|
||||||
|
# sur 127.0.0.1 et que seul un conteneur sur l'hôte joint cette boucle locale (ADR 0018).
|
||||||
|
|
||||||
|
name: enervision-front
|
||||||
|
|
||||||
|
services:
|
||||||
|
front:
|
||||||
|
image: nginx:1.31-alpine
|
||||||
|
network_mode: host
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Pourquoi : trois environnements sur une seule IP, des URL sans port (ADR 0018). Ce frontal lit
|
||||||
|
# le nom demandé dans le ClientHello (SNI) et relaie le flux TLS intact vers le proxy de la
|
||||||
|
# stack visée : il ne détient aucun certificat, chaque stack garde le sien et ses en-têtes.
|
||||||
|
# Piège : relayé tel quel, le flux arriverait avec l'IP du frontal, et les limitations de débit
|
||||||
|
# de nginx et du backend deviendraient globales. D'où `proxy_protocol on`, reçu sur l'écouteur
|
||||||
|
# 4443 de chaque stack (infra/proxy/conf.d/enervision.conf), qui y restaure l'IP du client.
|
||||||
|
# Contrainte : ces ports sont ceux que `scripts/provision-host.sh` donne à PROXY_FRONT_PORT.
|
||||||
|
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
stream {
|
||||||
|
log_format aiguillage '$remote_addr [$time_local] $ssl_preread_server_name '
|
||||||
|
'-> $upstream_addr $status $session_time';
|
||||||
|
access_log /var/log/nginx/access.log aiguillage;
|
||||||
|
|
||||||
|
map $ssl_preread_server_name $stack {
|
||||||
|
~^rec\. 127.0.0.1:8444;
|
||||||
|
~^dev\. 127.0.0.1:9444;
|
||||||
|
default 127.0.0.1:10444;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443;
|
||||||
|
ssl_preread on;
|
||||||
|
proxy_pass $stack;
|
||||||
|
proxy_protocol on;
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
server_tokens off;
|
||||||
|
access_log off;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
server_name _;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-2
@@ -76,8 +76,28 @@ Renouvellement, à passer en tâche planifiée sur la machine :
|
|||||||
17 3 * * * cd /srv/enervision && make tls-renew >> /var/log/enervision-tls.log 2>&1
|
17 3 * * * cd /srv/enervision && make tls-renew >> /var/log/enervision-tls.log 2>&1
|
||||||
```
|
```
|
||||||
|
|
||||||
Pour un domaine sans port 80 entrant, le défi DNS-01 est l'alternative : elle demande un
|
### Let's Encrypt par DNS-01, le mode de la VM
|
||||||
greffon certbot propre au fournisseur DNS et un jeton d'API, hors périmètre à ce jour.
|
|
||||||
|
La VM n'a qu'une IP privée : le défi HTTP-01 y est impossible. Ses trois noms sont chez dynv6,
|
||||||
|
dont l'API pose l'enregistrement TXT du défi DNS-01, et acme.sh le fait sans rien ouvrir
|
||||||
|
([ADR 0018](../../docs/adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md)).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make tls-dns01 # PUBLIC_HOST lu dans .env, jeton dans ../dns.token (600)
|
||||||
|
```
|
||||||
|
|
||||||
|
Autre fournisseur : `DNS01_API` et `DNS01_JETON_VAR` nomment le greffon acme.sh et sa variable
|
||||||
|
(`dns_cf` et `CF_Token` pour Cloudflare, par exemple). La cible est rejouable : acme.sh ne renouvelle qu'à trente jours de l'échéance, installe le
|
||||||
|
résultat dans `tls/` et recharge le proxy s'il tourne. Son état vit dans `acme/`, ignoré par git.
|
||||||
|
`deploy.yml` la rejoue avant chaque `make stack-up`, et `/etc/cron.d/enervision-tls` chaque nuit.
|
||||||
|
|
||||||
|
## Écouteur PROXY protocol
|
||||||
|
|
||||||
|
Sur la VM, le frontal `infra/front` relaie les connexions TLS sans les déchiffrer. Reçues sur
|
||||||
|
443, elles porteraient son adresse, et `limit_req` comme `get_client_ip()` compteraient tous les
|
||||||
|
postes comme un seul. Le port 4443 ne les accepte qu'avec l'en-tête PROXY protocol, d'où
|
||||||
|
`real_ip_header proxy_protocol` tire l'IP du client ; seules les adresses des réseaux Docker ont
|
||||||
|
le droit de l'annoncer, et le port n'est publié que sur `127.0.0.1` (`PROXY_FRONT_PORT`).
|
||||||
|
|
||||||
## Vérifier la configuration sans démarrer la stack
|
## Vérifier la configuration sans démarrer la stack
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
# variable et le résolveur interne de Docker : la résolution redevient dynamique.
|
# variable et le résolveur interne de Docker : la résolution redevient dynamique.
|
||||||
# Pourquoi : la redirection 80 vers 443 conserve `$host` plutôt qu'un nom canonique, faute de
|
# Pourquoi : la redirection 80 vers 443 conserve `$host` plutôt qu'un nom canonique, faute de
|
||||||
# quoi l'accès par IP cesserait de fonctionner sur la cible. Risque acté dans l'ADR 0007.
|
# quoi l'accès par IP cesserait de fonctionner sur la cible. Risque acté dans l'ADR 0007.
|
||||||
|
# Piège : 4443 n'accepte que le PROXY protocol du frontal (infra/front, ADR 0018), qui y porte
|
||||||
|
# l'IP du client. Seules les adresses des réseaux Docker ont le droit de l'annoncer.
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80 default_server;
|
listen 80 default_server;
|
||||||
@@ -23,9 +25,14 @@ server {
|
|||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl default_server;
|
listen 443 ssl default_server;
|
||||||
|
listen 4443 ssl proxy_protocol default_server;
|
||||||
http2 on;
|
http2 on;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
|
set_real_ip_from 172.16.0.0/12;
|
||||||
|
set_real_ip_from 192.168.0.0/16;
|
||||||
|
real_ip_header proxy_protocol;
|
||||||
|
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/tls/fullchain.pem;
|
ssl_certificate /etc/nginx/tls/fullchain.pem;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
# Contrainte : pas de provisioner `destroy` sur le runner. Il imposerait une connexion ne lisant
|
# Contrainte : pas de provisioner `destroy` sur le runner. Il imposerait une connexion ne lisant
|
||||||
# que `self`, donc le chemin de la cle SSH dans le state, et `svc.sh uninstall` ne desinscrit pas
|
# que `self`, donc le chemin de la cle SSH dans le state, et `svc.sh uninstall` ne desinscrit pas
|
||||||
# le runner cote GitHub : le retrait reste manuel, depuis les parametres du depot.
|
# le runner cote GitHub : le retrait reste manuel, depuis les parametres du depot.
|
||||||
# Ref : ADR 0009 pour les deux environnements, `scripts/provision-host.sh` pour leur contenu.
|
# Ref : ADR 0009 et 0017 pour les trois environnements, `scripts/provision-host.sh` pour leur contenu.
|
||||||
|
|
||||||
locals {
|
locals {
|
||||||
sudo = var.ssh_user == "root" ? "" : "sudo "
|
sudo = var.ssh_user == "root" ? "" : "sudo "
|
||||||
@@ -60,6 +60,7 @@ resource "null_resource" "environnements" {
|
|||||||
script = filesha256(local.provisionneur)
|
script = filesha256(local.provisionneur)
|
||||||
racine = var.racine
|
racine = var.racine
|
||||||
depot = var.depot_url
|
depot = var.depot_url
|
||||||
|
domaine = var.domaine
|
||||||
}
|
}
|
||||||
|
|
||||||
connection {
|
connection {
|
||||||
@@ -83,6 +84,7 @@ resource "null_resource" "environnements" {
|
|||||||
${local.sudo}env RACINE='${var.racine}' \
|
${local.sudo}env RACINE='${var.racine}' \
|
||||||
REPO_URL='${var.depot_url}' \
|
REPO_URL='${var.depot_url}' \
|
||||||
PROPRIETAIRE='${var.proprietaire}' \
|
PROPRIETAIRE='${var.proprietaire}' \
|
||||||
|
DOMAINE='${var.domaine}' \
|
||||||
PUBLIC_IP='${var.adresse_publique}' \
|
PUBLIC_IP='${var.adresse_publique}' \
|
||||||
bash /tmp/provision-host.sh
|
bash /tmp/provision-host.sh
|
||||||
rm -f /tmp/provision-host.sh
|
rm -f /tmp/provision-host.sh
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
variable "ssh_host" {
|
variable "ssh_host" {
|
||||||
type = string
|
type = string
|
||||||
description = "Adresse de la VM ENI qui porte les deux environnements (ADR 0009)."
|
description = "Adresse de la VM ENI qui porte les trois environnements (ADR 0009, ADR 0017)."
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "ssh_port" {
|
variable "ssh_port" {
|
||||||
@@ -43,6 +43,12 @@ variable "depot_url" {
|
|||||||
default = "https://github.com/ineszang/ProjetPiscine_EnerVision.git"
|
default = "https://github.com/ineszang/ProjetPiscine_EnerVision.git"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "domaine" {
|
||||||
|
type = string
|
||||||
|
description = "Zone dynv6 des trois environnements, prod., rec. et dev. en sous-domaines (ADR 0018). Son jeton doit se trouver dans <racine>/dns.token sur la machine : provision-host.sh y fait pointer la zone et ses trois sous-domaines vers la machine."
|
||||||
|
default = "enervision-g3.dynv6.net"
|
||||||
|
}
|
||||||
|
|
||||||
variable "adresse_publique" {
|
variable "adresse_publique" {
|
||||||
type = string
|
type = string
|
||||||
description = "Adresse annoncee dans les certificats auto-signes. Vide : la premiere adresse de la VM."
|
description = "Adresse annoncee dans les certificats auto-signes. Vide : la premiere adresse de la VM."
|
||||||
|
|||||||
+79
-7
@@ -1,10 +1,82 @@
|
|||||||
# Monitoring
|
# Supervision
|
||||||
|
|
||||||
Prometheus, Grafana et Alertmanager. Non initialise, voir le ticket dedie.
|
Prometheus, Alertmanager, Grafana et trois exporteurs, sous le profil Compose `monitoring`.
|
||||||
|
Issue #26, décisions dans l'ADR 0016, vue d'architecture dans
|
||||||
|
`docs/architecture/60-observabilite.md`.
|
||||||
|
|
||||||
- `prometheus` : configuration de collecte et regles d'alerte.
|
| Service | Image | Rôle | Accès |
|
||||||
- `grafana/provisioning` : sources de donnees et fournisseurs de dashboards.
|
|---|---|---|---|
|
||||||
- `grafana/dashboards` : dashboards versionnes au format JSON.
|
| `prometheus` | `prom/prometheus` | Collecte toutes les 15 s, évalue les règles, garde 15 jours (1 Go au plus) | `127.0.0.1:${PROMETHEUS_PORT:-9090}` |
|
||||||
- `alertmanager` : routage et inhibition des alertes.
|
| `alertmanager` | `prom/alertmanager` | Groupe les alertes et les envoie par courriel à Mailpit | `127.0.0.1:${ALERTMANAGER_PORT:-9093}` |
|
||||||
|
| `grafana` | `grafana/grafana` | Trois tableaux de bord provisionnés, dossier « EnerVision » | `127.0.0.1:${GRAFANA_PORT:-3001}` |
|
||||||
|
| `postgres-exporter` | `prometheuscommunity/postgres-exporter` | Connexions, transactions, taille des bases | réseau interne |
|
||||||
|
| `node-exporter` | `prom/node-exporter` | Processeur, mémoire et disque de l'hôte | réseau interne |
|
||||||
|
| `cadvisor` | `gcr.io/cadvisor/cadvisor` | Mémoire et processeur par conteneur | réseau interne |
|
||||||
|
|
||||||
Le backend expose deja ses metriques sur `/metrics` au format Prometheus.
|
Les interfaces n'écoutent que sur `127.0.0.1`. Depuis un poste, on passe par un tunnel SSH,
|
||||||
|
comme pour Airflow :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -L 3001:127.0.0.1:3001 -L 9090:127.0.0.1:9090 enervision@10.101.200.37
|
||||||
|
```
|
||||||
|
|
||||||
|
## Démarrer
|
||||||
|
|
||||||
|
- **Prod.** `COMPOSE_PROFILES=monitoring` dans le `.env` : `make stack-up`, donc chaque
|
||||||
|
déploiement, démarre la supervision et pose le rôle `supervision` après les migrations.
|
||||||
|
- **Recette et poste.** À la demande, sur une stack déjà démarrée : `make monitoring-up`. Les
|
||||||
|
services partent en `--no-deps`, sans toucher aux autres.
|
||||||
|
|
||||||
|
Trois secrets sont requis, et `make stack-up` comme `make monitoring-up` refusent de démarrer
|
||||||
|
s'il en manque un. `scripts/provision-host.sh` les génère pour un nouvel environnement.
|
||||||
|
|
||||||
|
| Variable | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| `APP_METRICS_TOKEN` | Jeton que Prometheus présente sur `/metrics`, et que l'API exige dès qu'il est posé |
|
||||||
|
| `GRAFANA_ADMIN_PASSWORD` | Compte `admin` de Grafana. Sans lui, le conteneur refuse de démarrer |
|
||||||
|
| `SUPERVISION_DB_PASSWORD` | Rôle PostgreSQL `supervision`, en lecture seule (`db/roles/supervision.sql`) |
|
||||||
|
|
||||||
|
L'API doit tourner en conteneur (`make stack-up`, ou `docker compose up -d backend`) :
|
||||||
|
Prometheus la joint en `backend:8000`, sur le réseau du projet. Une API lancée par `make dev`
|
||||||
|
sur l'hôte reste hors de sa portée, et l'alerte `ApiIndisponible` le signale.
|
||||||
|
|
||||||
|
## Tableaux de bord
|
||||||
|
|
||||||
|
| Tableau | Source | Contenu |
|
||||||
|
|---|---|---|
|
||||||
|
| EnerVision · API | Prometheus | Débit, erreurs 5xx, latences p50/p95/p99, globales et par route. C'est lui qu'on regarde pendant un tir k6 |
|
||||||
|
| EnerVision · Données et modèle | TimescaleDB | Fraîcheur des relevés par site, relevés ingérés, alertes par sévérité, dérive du modèle (`drift_report`) |
|
||||||
|
| EnerVision · Infrastructure | Prometheus | Hôte, mémoire et processeur par conteneur (recette et prod comprises), PostgreSQL |
|
||||||
|
|
||||||
|
Les fichiers JSON de `grafana/dashboards` sont la source : Grafana les recharge et refuse de
|
||||||
|
les modifier depuis l'interface. Pour changer un tableau, l'exporter en JSON depuis Grafana et
|
||||||
|
remplacer le fichier.
|
||||||
|
|
||||||
|
## Alertes
|
||||||
|
|
||||||
|
`prometheus/rules/enervision.yml` définit les règles, et `prometheus/tests/enervision.test.yml`
|
||||||
|
porte un cas par règle, joué par `promtool test rules`.
|
||||||
|
|
||||||
|
| Alerte | Condition | Sévérité |
|
||||||
|
|---|---|---|
|
||||||
|
| `ApiIndisponible` | `/metrics` injoignable pendant 2 min | critical |
|
||||||
|
| `ApiErreursServeur` | Plus de 5 % de 5xx sur 5 min | critical |
|
||||||
|
| `ApiLatenceElevee` | p95 au-delà d'une seconde pendant 10 min | warning |
|
||||||
|
| `BaseIndisponible` | Exportateur sans connexion pendant 2 min | critical |
|
||||||
|
| `BaseConnexionsSaturees` | Plus de 80 % de `max_connections` | warning |
|
||||||
|
| `HoteMemoireSaturee` | Mémoire au-delà de 90 % pendant 10 min | warning |
|
||||||
|
| `HoteDisquePlein` | Moins de 10 % libres sur `/` | critical |
|
||||||
|
| `HoteCpuSature` | Processeur au-delà de 90 % pendant 15 min | warning |
|
||||||
|
| `CibleInjoignable` | Un exporteur muet pendant 5 min | warning |
|
||||||
|
|
||||||
|
Alertmanager envoie les courriels à `supervision@enervision.fr` via Mailpit, qui les capture :
|
||||||
|
ils se lisent dans son interface. Un `critical` masque le `warning` de la même cible.
|
||||||
|
|
||||||
|
## Vérifier la configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make monitoring-check # promtool check config et test rules, amtool check-config, JSON des tableaux
|
||||||
|
```
|
||||||
|
|
||||||
|
La CI joue les mêmes commandes (job « Validation des fichiers Compose et de la supervision »)
|
||||||
|
dès que `monitoring/` ou un fichier Compose change.
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Contrainte : Mailpit est le seul serveur SMTP de la stack, et il ne relaie rien vers l'extérieur
|
||||||
|
# - alertmanager.yml. Les alertes se lisent dans son interface (MAILPIT_UI_PORT), comme les
|
||||||
|
# courriels de réinitialisation de l'API.
|
||||||
|
|
||||||
|
global:
|
||||||
|
smtp_smarthost: mailpit:1025
|
||||||
|
smtp_from: supervision@enervision.fr
|
||||||
|
smtp_require_tls: false
|
||||||
|
|
||||||
|
route:
|
||||||
|
receiver: equipe
|
||||||
|
group_by: [alertname, severity]
|
||||||
|
group_wait: 30s
|
||||||
|
group_interval: 5m
|
||||||
|
repeat_interval: 12h
|
||||||
|
routes:
|
||||||
|
- matchers: ['severity="critical"']
|
||||||
|
receiver: equipe
|
||||||
|
group_wait: 10s
|
||||||
|
repeat_interval: 1h
|
||||||
|
|
||||||
|
receivers:
|
||||||
|
- name: equipe
|
||||||
|
email_configs:
|
||||||
|
- to: supervision@enervision.fr
|
||||||
|
send_resolved: true
|
||||||
|
|
||||||
|
inhibit_rules:
|
||||||
|
- source_matchers: ['severity="critical"']
|
||||||
|
target_matchers: ['severity="warning"']
|
||||||
|
equal: [instance]
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
{
|
||||||
|
"uid": "enervision-api",
|
||||||
|
"title": "EnerVision · API",
|
||||||
|
"description": "Débit, erreurs et latences vus par l'API elle-même (prometheus-fastapi-instrumentator).",
|
||||||
|
"tags": [
|
||||||
|
"enervision"
|
||||||
|
],
|
||||||
|
"timezone": "browser",
|
||||||
|
"editable": false,
|
||||||
|
"graphTooltip": 1,
|
||||||
|
"refresh": "10s",
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"version": 1,
|
||||||
|
"time": {
|
||||||
|
"from": "now-1h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"templating": {
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"annotations": {
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"links": [],
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "API",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"w": 6,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "up{job=\"backend\"}",
|
||||||
|
"legendFormat": "",
|
||||||
|
"range": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
},
|
||||||
|
"mappings": [
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"options": {
|
||||||
|
"0": {
|
||||||
|
"text": "Injoignable",
|
||||||
|
"color": "red"
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"text": "Joignable",
|
||||||
|
"color": "green"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "area",
|
||||||
|
"textMode": "auto"
|
||||||
|
},
|
||||||
|
"description": "Prometheus joint-il /metrics ?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Débit",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 6,
|
||||||
|
"y": 0,
|
||||||
|
"w": 6,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "sum(rate(http_requests_total{job=\"backend\"}[5m]))",
|
||||||
|
"legendFormat": "",
|
||||||
|
"range": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "reqps",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "blue",
|
||||||
|
"value": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "area",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Erreurs 5xx",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 0,
|
||||||
|
"w": 6,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "sum(rate(http_requests_total{job=\"backend\", status=\"5xx\"}[5m])) / sum(rate(http_requests_total{job=\"backend\"}[5m]))",
|
||||||
|
"legendFormat": "",
|
||||||
|
"range": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percentunit",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "orange",
|
||||||
|
"value": 0.01
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 0.05
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "area",
|
||||||
|
"textMode": "auto"
|
||||||
|
},
|
||||||
|
"description": "Seuil d'alerte : 5 % pendant 5 minutes."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Latence p95",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 18,
|
||||||
|
"y": 0,
|
||||||
|
"w": 6,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "histogram_quantile(0.95, sum by (le) (rate(http_request_duration_highr_seconds_bucket{job=\"backend\"}[5m])))",
|
||||||
|
"legendFormat": "",
|
||||||
|
"range": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "s",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "orange",
|
||||||
|
"value": 0.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "area",
|
||||||
|
"textMode": "auto"
|
||||||
|
},
|
||||||
|
"description": "Seuil de charge : 500 ms (ADR 0015). Alerte au-delà d'une seconde pendant 10 minutes."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Débit par route",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 4,
|
||||||
|
"w": 12,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "sum by (handler) (rate(http_requests_total{job=\"backend\"}[1m]))",
|
||||||
|
"legendFormat": "{{handler}}",
|
||||||
|
"range": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "reqps"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Latence de l'API",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 4,
|
||||||
|
"w": 12,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "histogram_quantile(0.50, sum by (le) (rate(http_request_duration_highr_seconds_bucket{job=\"backend\"}[1m])))",
|
||||||
|
"legendFormat": "p50",
|
||||||
|
"range": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"refId": "B",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "histogram_quantile(0.95, sum by (le) (rate(http_request_duration_highr_seconds_bucket{job=\"backend\"}[1m])))",
|
||||||
|
"legendFormat": "p95",
|
||||||
|
"range": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"refId": "C",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "histogram_quantile(0.99, sum by (le) (rate(http_request_duration_highr_seconds_bucket{job=\"backend\"}[1m])))",
|
||||||
|
"legendFormat": "p99",
|
||||||
|
"range": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "s"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Latence p95 par route",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 12,
|
||||||
|
"w": 12,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "histogram_quantile(0.95, sum by (le, handler) (rate(http_request_duration_seconds_bucket{job=\"backend\"}[5m])))",
|
||||||
|
"legendFormat": "{{handler}}",
|
||||||
|
"range": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "s"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Estimée sur les seaux 50 ms à 2,5 s de http_request_duration_seconds."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Réponses par classe de statut",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 12,
|
||||||
|
"w": 12,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "sum by (status) (rate(http_requests_total{job=\"backend\"}[1m]))",
|
||||||
|
"legendFormat": "{{status}}",
|
||||||
|
"range": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "reqps"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Mémoire du processus API",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 20,
|
||||||
|
"w": 12,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "process_resident_memory_bytes{job=\"backend\"}",
|
||||||
|
"legendFormat": "résidente",
|
||||||
|
"range": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "bytes"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Requêtes en cours de traitement",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 20,
|
||||||
|
"w": 12,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "sum(rate(http_request_duration_highr_seconds_sum{job=\"backend\"}[1m]))",
|
||||||
|
"legendFormat": "secondes de traitement par seconde",
|
||||||
|
"range": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Loi de Little : durée moyenne multipliée par le débit, soit le nombre moyen de requêtes simultanées."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
{
|
||||||
|
"uid": "enervision-donnees",
|
||||||
|
"title": "EnerVision · Données et modèle",
|
||||||
|
"description": "Fraîcheur des relevés, alertes métier et dérive du modèle, lus dans TimescaleDB par le rôle supervision.",
|
||||||
|
"tags": [
|
||||||
|
"enervision"
|
||||||
|
],
|
||||||
|
"timezone": "browser",
|
||||||
|
"editable": false,
|
||||||
|
"graphTooltip": 1,
|
||||||
|
"refresh": "1m",
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"version": 1,
|
||||||
|
"time": {
|
||||||
|
"from": "now-7d",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"templating": {
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"annotations": {
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"links": [],
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Sites suivis",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"w": 6,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana-postgresql-datasource",
|
||||||
|
"uid": "timescaledb"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana-postgresql-datasource",
|
||||||
|
"uid": "timescaledb"
|
||||||
|
},
|
||||||
|
"rawSql": "SELECT count(*) AS \"Sites\" FROM site",
|
||||||
|
"format": "table",
|
||||||
|
"rawQuery": true,
|
||||||
|
"editorMode": "code"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "blue",
|
||||||
|
"value": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "area",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Relevés sur 24 h",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 6,
|
||||||
|
"y": 0,
|
||||||
|
"w": 6,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana-postgresql-datasource",
|
||||||
|
"uid": "timescaledb"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana-postgresql-datasource",
|
||||||
|
"uid": "timescaledb"
|
||||||
|
},
|
||||||
|
"rawSql": "SELECT count(*) AS \"Relevés\" FROM reading WHERE timestamp > now() - interval '24 hours'",
|
||||||
|
"format": "table",
|
||||||
|
"rawQuery": true,
|
||||||
|
"editorMode": "code"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "area",
|
||||||
|
"textMode": "auto"
|
||||||
|
},
|
||||||
|
"description": "Zéro : l'import de la Mock API ne tourne plus."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Alertes sur 24 h",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 0,
|
||||||
|
"w": 6,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana-postgresql-datasource",
|
||||||
|
"uid": "timescaledb"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana-postgresql-datasource",
|
||||||
|
"uid": "timescaledb"
|
||||||
|
},
|
||||||
|
"rawSql": "SELECT count(*) AS \"Alertes\" FROM alert WHERE timestamp > now() - interval '24 hours'",
|
||||||
|
"format": "table",
|
||||||
|
"rawQuery": true,
|
||||||
|
"editorMode": "code"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "orange",
|
||||||
|
"value": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "area",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Sites en dérive",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 18,
|
||||||
|
"y": 0,
|
||||||
|
"w": 6,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana-postgresql-datasource",
|
||||||
|
"uid": "timescaledb"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana-postgresql-datasource",
|
||||||
|
"uid": "timescaledb"
|
||||||
|
},
|
||||||
|
"rawSql": "SELECT count(*) AS \"Sites\" FROM (SELECT DISTINCT ON (site_id) status FROM drift_report WHERE site_id IS NOT NULL ORDER BY site_id, computed_at DESC) AS derniers WHERE status = 'derive'",
|
||||||
|
"format": "table",
|
||||||
|
"rawQuery": true,
|
||||||
|
"editorMode": "code"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "area",
|
||||||
|
"textMode": "auto"
|
||||||
|
},
|
||||||
|
"description": "Dernier rapport de dérive de chaque site (ADR 0013)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"type": "table",
|
||||||
|
"title": "Fraîcheur des relevés par site",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 4,
|
||||||
|
"w": 12,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana-postgresql-datasource",
|
||||||
|
"uid": "timescaledb"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana-postgresql-datasource",
|
||||||
|
"uid": "timescaledb"
|
||||||
|
},
|
||||||
|
"rawSql": "SELECT s.site_name AS \"Site\", max(r.timestamp) AS \"Dernier relevé\", round(extract(epoch FROM now() - max(r.timestamp)) / 60) AS \"Âge (min)\"\nFROM site AS s\nLEFT JOIN reading AS r ON r.site_id = s.site_id AND r.timestamp > now() - interval '7 days'\nGROUP BY s.site_name\nORDER BY 3 DESC NULLS FIRST",
|
||||||
|
"format": "table",
|
||||||
|
"rawQuery": true,
|
||||||
|
"editorMode": "code"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"showHeader": true
|
||||||
|
},
|
||||||
|
"description": "Un site sans relevé depuis sept jours apparaît vide, en tête de liste."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"type": "table",
|
||||||
|
"title": "Dérive du modèle, dernier rapport",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 4,
|
||||||
|
"w": 12,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana-postgresql-datasource",
|
||||||
|
"uid": "timescaledb"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana-postgresql-datasource",
|
||||||
|
"uid": "timescaledb"
|
||||||
|
},
|
||||||
|
"rawSql": "SELECT DISTINCT ON (d.site_id) coalesce(s.site_name, 'Tout le parc') AS \"Site\", d.status AS \"Statut\", round(d.mae::numeric, 2) AS \"MAE\", round(d.reference_mae::numeric, 2) AS \"MAE de référence\", round(d.bias::numeric, 2) AS \"Biais\", d.computed_at AS \"Calculé le\"\nFROM drift_report AS d\nLEFT JOIN site AS s ON s.site_id = d.site_id\nORDER BY d.site_id, d.computed_at DESC",
|
||||||
|
"format": "table",
|
||||||
|
"rawQuery": true,
|
||||||
|
"editorMode": "code"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"showHeader": true
|
||||||
|
},
|
||||||
|
"description": "Une ligne par site, plus la ligne globale (ADR 0013)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Consommation par site",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 12,
|
||||||
|
"w": 12,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana-postgresql-datasource",
|
||||||
|
"uid": "timescaledb"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana-postgresql-datasource",
|
||||||
|
"uid": "timescaledb"
|
||||||
|
},
|
||||||
|
"rawSql": "SELECT $__timeGroupAlias(r.timestamp, $__interval), s.site_name AS metric, avg(r.consumption_kw) AS value\nFROM reading AS r\nJOIN site AS s ON s.site_id = r.site_id\nWHERE $__timeFilter(r.timestamp)\nGROUP BY 1, 2\nORDER BY 1",
|
||||||
|
"format": "time_series",
|
||||||
|
"rawQuery": true,
|
||||||
|
"editorMode": "code"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "kwatt"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Relevés ingérés par heure",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 12,
|
||||||
|
"w": 12,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana-postgresql-datasource",
|
||||||
|
"uid": "timescaledb"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana-postgresql-datasource",
|
||||||
|
"uid": "timescaledb"
|
||||||
|
},
|
||||||
|
"rawSql": "SELECT time_bucket('1 hour', timestamp) AS time, count(*) AS \"Relevés\"\nFROM reading\nWHERE $__timeFilter(timestamp)\nGROUP BY 1\nORDER BY 1",
|
||||||
|
"format": "time_series",
|
||||||
|
"rawQuery": true,
|
||||||
|
"editorMode": "code"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Alertes par sévérité",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 20,
|
||||||
|
"w": 12,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana-postgresql-datasource",
|
||||||
|
"uid": "timescaledb"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana-postgresql-datasource",
|
||||||
|
"uid": "timescaledb"
|
||||||
|
},
|
||||||
|
"rawSql": "SELECT time_bucket('1 hour', timestamp) AS time, severity AS metric, count(*) AS value\nFROM alert\nWHERE $__timeFilter(timestamp)\nGROUP BY 1, 2\nORDER BY 1",
|
||||||
|
"format": "time_series",
|
||||||
|
"rawQuery": true,
|
||||||
|
"editorMode": "code"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Erreur absolue moyenne du modèle",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 20,
|
||||||
|
"w": 12,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana-postgresql-datasource",
|
||||||
|
"uid": "timescaledb"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana-postgresql-datasource",
|
||||||
|
"uid": "timescaledb"
|
||||||
|
},
|
||||||
|
"rawSql": "SELECT d.computed_at AS time, coalesce(s.site_name, 'Tout le parc') AS metric, d.mae AS value\nFROM drift_report AS d\nLEFT JOIN site AS s ON s.site_id = d.site_id\nWHERE $__timeFilter(d.computed_at)\nORDER BY 1",
|
||||||
|
"format": "time_series",
|
||||||
|
"rawQuery": true,
|
||||||
|
"editorMode": "code"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "kwatt"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
{
|
||||||
|
"uid": "enervision-infra",
|
||||||
|
"title": "EnerVision · Infrastructure",
|
||||||
|
"description": "Hôte (node-exporter), conteneurs (cAdvisor) et PostgreSQL (postgres-exporter). L'hôte porte la recette et la prod.",
|
||||||
|
"tags": [
|
||||||
|
"enervision"
|
||||||
|
],
|
||||||
|
"timezone": "browser",
|
||||||
|
"editable": false,
|
||||||
|
"graphTooltip": 1,
|
||||||
|
"refresh": "30s",
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"version": 1,
|
||||||
|
"time": {
|
||||||
|
"from": "now-6h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"templating": {
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"annotations": {
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"links": [],
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Mémoire de l'hôte",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"w": 6,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes",
|
||||||
|
"legendFormat": "",
|
||||||
|
"range": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percentunit",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "orange",
|
||||||
|
"value": 0.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 0.9
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "area",
|
||||||
|
"textMode": "auto"
|
||||||
|
},
|
||||||
|
"description": "Alerte au-delà de 90 % pendant 10 minutes."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Processeur de l'hôte",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 6,
|
||||||
|
"y": 0,
|
||||||
|
"w": 6,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "1 - avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m]))",
|
||||||
|
"legendFormat": "",
|
||||||
|
"range": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percentunit",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "orange",
|
||||||
|
"value": 0.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 0.9
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "area",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Disque libre",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 0,
|
||||||
|
"w": 6,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "node_filesystem_avail_bytes{mountpoint=\"/\", fstype!~\"tmpfs|overlay\"} / node_filesystem_size_bytes{mountpoint=\"/\", fstype!~\"tmpfs|overlay\"}",
|
||||||
|
"legendFormat": "",
|
||||||
|
"range": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percentunit",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "orange",
|
||||||
|
"value": 0.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": 0.2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "area",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "PostgreSQL",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 18,
|
||||||
|
"y": 0,
|
||||||
|
"w": 6,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "pg_up",
|
||||||
|
"legendFormat": "",
|
||||||
|
"range": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
},
|
||||||
|
"mappings": [
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"options": {
|
||||||
|
"0": {
|
||||||
|
"text": "Injoignable",
|
||||||
|
"color": "red"
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"text": "Joignable",
|
||||||
|
"color": "green"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "area",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Mémoire par conteneur",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 4,
|
||||||
|
"w": 12,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "sum by (name) (container_memory_working_set_bytes{name!=\"\"})",
|
||||||
|
"legendFormat": "{{name}}",
|
||||||
|
"range": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "bytes"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Recette et prod partagent l'hôte : leurs conteneurs se distinguent par le préfixe du projet Compose."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Processeur par conteneur",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 4,
|
||||||
|
"w": 12,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "sum by (name) (rate(container_cpu_usage_seconds_total{name!=\"\"}[5m]))",
|
||||||
|
"legendFormat": "{{name}}",
|
||||||
|
"range": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Mémoire de l'hôte",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 12,
|
||||||
|
"w": 12,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes",
|
||||||
|
"legendFormat": "utilisée",
|
||||||
|
"range": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"refId": "B",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "node_memory_MemTotal_bytes",
|
||||||
|
"legendFormat": "totale",
|
||||||
|
"range": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "bytes"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Connexions PostgreSQL par état",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 12,
|
||||||
|
"w": 12,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "sum by (state) (pg_stat_activity_count)",
|
||||||
|
"legendFormat": "{{state}}",
|
||||||
|
"range": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"refId": "B",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "max(pg_settings_max_connections)",
|
||||||
|
"legendFormat": "maximum",
|
||||||
|
"range": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Transactions validées par base",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 20,
|
||||||
|
"w": 12,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "sum by (datname) (rate(pg_stat_database_xact_commit{datname!~\"template.*|postgres\"}[5m]))",
|
||||||
|
"legendFormat": "{{datname}}",
|
||||||
|
"range": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "ops"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Taille des bases",
|
||||||
|
"gridPos": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 20,
|
||||||
|
"w": 12,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"expr": "pg_database_size_bytes{datname!~\"template.*|postgres\"}",
|
||||||
|
"legendFormat": "{{datname}}",
|
||||||
|
"range": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "bytes"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: enervision
|
||||||
|
folder: EnerVision
|
||||||
|
type: file
|
||||||
|
disableDeletion: true
|
||||||
|
allowUiUpdates: false
|
||||||
|
options:
|
||||||
|
path: /etc/grafana/dashboards
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Pourquoi : Grafana lit la base avec le rôle `supervision`, en lecture seule sur les seules tables
|
||||||
|
# métier (db/roles/supervision.sql) - datasources.yml. Jamais le compte applicatif.
|
||||||
|
|
||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
uid: prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
editable: false
|
||||||
|
|
||||||
|
- name: TimescaleDB
|
||||||
|
uid: timescaledb
|
||||||
|
type: grafana-postgresql-datasource
|
||||||
|
access: proxy
|
||||||
|
url: db:5432
|
||||||
|
user: supervision
|
||||||
|
jsonData:
|
||||||
|
database: ${POSTGRES_DB}
|
||||||
|
sslmode: disable
|
||||||
|
postgresVersion: 1700
|
||||||
|
timescaledb: true
|
||||||
|
secureJsonData:
|
||||||
|
password: ${SUPERVISION_DB_PASSWORD}
|
||||||
|
editable: false
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Contrainte : Prometheus ne lit pas les variables d'environnement dans ce fichier - prometheus.yml.
|
||||||
|
# Le jeton de `/metrics` lui parvient en secret Compose (`metrics_token`, tiré d'APP_METRICS_TOKEN) ;
|
||||||
|
# vide, l'API n'en exige aucun (app/api/security.py).
|
||||||
|
|
||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
evaluation_interval: 15s
|
||||||
|
|
||||||
|
rule_files:
|
||||||
|
- /etc/prometheus/rules/*.yml
|
||||||
|
|
||||||
|
alerting:
|
||||||
|
alertmanagers:
|
||||||
|
- static_configs:
|
||||||
|
- targets: ["alertmanager:9093"]
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: backend
|
||||||
|
authorization:
|
||||||
|
type: Bearer
|
||||||
|
credentials_file: /run/secrets/metrics_token
|
||||||
|
static_configs:
|
||||||
|
- targets: ["backend:8000"]
|
||||||
|
|
||||||
|
- job_name: prometheus
|
||||||
|
static_configs:
|
||||||
|
- targets: ["localhost:9090"]
|
||||||
|
|
||||||
|
- job_name: alertmanager
|
||||||
|
static_configs:
|
||||||
|
- targets: ["alertmanager:9093"]
|
||||||
|
|
||||||
|
- job_name: postgres
|
||||||
|
static_configs:
|
||||||
|
- targets: ["postgres-exporter:9187"]
|
||||||
|
|
||||||
|
- job_name: node
|
||||||
|
static_configs:
|
||||||
|
- targets: ["node-exporter:9100"]
|
||||||
|
|
||||||
|
- job_name: cadvisor
|
||||||
|
static_configs:
|
||||||
|
- targets: ["cadvisor:8080"]
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Contrainte : chaque règle a son cas dans tests/enervision.test.yml, joué par `promtool test
|
||||||
|
# rules` en CI - enervision.yml. Une règle modifiée sans son test fait échouer le job Infra.
|
||||||
|
# Pourquoi : `critical` réveille (mail immédiat, rappel toutes les heures), `warning` se lit le
|
||||||
|
# lendemain ; alertmanager.yml masque le warning d'une cible déjà en critical.
|
||||||
|
|
||||||
|
groups:
|
||||||
|
- name: api
|
||||||
|
rules:
|
||||||
|
- alert: ApiIndisponible
|
||||||
|
expr: up{job="backend"} == 0
|
||||||
|
for: 2m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "L'API ne répond plus"
|
||||||
|
description: "Prometheus ne joint plus {{ $labels.instance }} depuis 2 minutes."
|
||||||
|
|
||||||
|
- alert: ApiErreursServeur
|
||||||
|
expr: |
|
||||||
|
sum(rate(http_requests_total{job="backend", status="5xx"}[5m]))
|
||||||
|
/ sum(rate(http_requests_total{job="backend"}[5m])) > 0.05
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Plus de 5 % de réponses 5xx"
|
||||||
|
description: "{{ $value | humanizePercentage }} des réponses de l'API sont des erreurs serveur."
|
||||||
|
|
||||||
|
- alert: ApiLatenceElevee
|
||||||
|
expr: |
|
||||||
|
histogram_quantile(0.95,
|
||||||
|
sum by (le) (rate(http_request_duration_highr_seconds_bucket{job="backend"}[5m]))
|
||||||
|
) > 1
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "p95 de l'API au-dessus d'une seconde"
|
||||||
|
description: "Le p95 des réponses vaut {{ $value | humanizeDuration }} depuis 10 minutes."
|
||||||
|
|
||||||
|
- name: base
|
||||||
|
rules:
|
||||||
|
- alert: BaseIndisponible
|
||||||
|
expr: pg_up == 0
|
||||||
|
for: 2m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "PostgreSQL ne répond plus"
|
||||||
|
description: "L'exportateur ne se connecte plus à la base depuis 2 minutes."
|
||||||
|
|
||||||
|
- alert: BaseConnexionsSaturees
|
||||||
|
expr: |
|
||||||
|
sum by (instance) (pg_stat_activity_count)
|
||||||
|
/ max by (instance) (pg_settings_max_connections) > 0.8
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Connexions PostgreSQL au-delà de 80 %"
|
||||||
|
description: "{{ $value | humanizePercentage }} des connexions autorisées sont ouvertes."
|
||||||
|
|
||||||
|
- name: hote
|
||||||
|
rules:
|
||||||
|
- alert: HoteMemoireSaturee
|
||||||
|
expr: 1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes > 0.9
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Mémoire de l'hôte au-delà de 90 %"
|
||||||
|
description: "{{ $value | humanizePercentage }} de la mémoire est utilisée, recette et prod comprises."
|
||||||
|
|
||||||
|
- alert: HoteDisquePlein
|
||||||
|
expr: |
|
||||||
|
node_filesystem_avail_bytes{mountpoint="/", fstype!~"tmpfs|overlay"}
|
||||||
|
/ node_filesystem_size_bytes{mountpoint="/", fstype!~"tmpfs|overlay"} < 0.1
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Moins de 10 % de disque libre"
|
||||||
|
description: "Il reste {{ $value | humanizePercentage }} d'espace sur la racine de l'hôte."
|
||||||
|
|
||||||
|
- alert: HoteCpuSature
|
||||||
|
expr: 1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) > 0.9
|
||||||
|
for: 15m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Processeur de l'hôte au-delà de 90 %"
|
||||||
|
description: "Le processeur est occupé à {{ $value | humanizePercentage }} depuis 15 minutes."
|
||||||
|
|
||||||
|
- name: supervision
|
||||||
|
rules:
|
||||||
|
- alert: CibleInjoignable
|
||||||
|
expr: up{job!="backend"} == 0
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Cible de supervision injoignable"
|
||||||
|
description: "Prometheus ne joint plus {{ $labels.job }} ({{ $labels.instance }}) depuis 5 minutes."
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
rule_files:
|
||||||
|
- ../rules/enervision.yml
|
||||||
|
|
||||||
|
evaluation_interval: 1m
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- name: l'API injoignable déclenche une alerte critique au bout de 2 minutes
|
||||||
|
interval: 1m
|
||||||
|
input_series:
|
||||||
|
- series: 'up{job="backend", instance="backend:8000"}'
|
||||||
|
values: "1 0 0 0 0"
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 2m
|
||||||
|
alertname: ApiIndisponible
|
||||||
|
exp_alerts: []
|
||||||
|
- eval_time: 3m
|
||||||
|
alertname: ApiIndisponible
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
severity: critical
|
||||||
|
job: backend
|
||||||
|
instance: backend:8000
|
||||||
|
exp_annotations:
|
||||||
|
summary: "L'API ne répond plus"
|
||||||
|
description: "Prometheus ne joint plus backend:8000 depuis 2 minutes."
|
||||||
|
|
||||||
|
- name: une API qui répond ne déclenche rien
|
||||||
|
interval: 1m
|
||||||
|
input_series:
|
||||||
|
- series: 'up{job="backend", instance="backend:8000"}'
|
||||||
|
values: "1x10"
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 10m
|
||||||
|
alertname: ApiIndisponible
|
||||||
|
exp_alerts: []
|
||||||
|
|
||||||
|
- name: dix pour cent de 5xx déclenchent l'alerte d'erreurs serveur
|
||||||
|
interval: 1m
|
||||||
|
input_series:
|
||||||
|
- series: 'http_requests_total{job="backend", handler="/api/v1/sites", method="GET", status="5xx"}'
|
||||||
|
values: "0+10x20"
|
||||||
|
- series: 'http_requests_total{job="backend", handler="/api/v1/sites", method="GET", status="2xx"}'
|
||||||
|
values: "0+90x20"
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 12m
|
||||||
|
alertname: ApiErreursServeur
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
severity: critical
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Plus de 5 % de réponses 5xx"
|
||||||
|
description: "10% des réponses de l'API sont des erreurs serveur."
|
||||||
|
|
||||||
|
- name: un p95 au-delà d'une seconde déclenche l'alerte de latence
|
||||||
|
interval: 1m
|
||||||
|
input_series:
|
||||||
|
- series: 'http_request_duration_highr_seconds_bucket{job="backend", le="0.5"}'
|
||||||
|
values: "0x30"
|
||||||
|
- series: 'http_request_duration_highr_seconds_bucket{job="backend", le="1"}'
|
||||||
|
values: "0x30"
|
||||||
|
- series: 'http_request_duration_highr_seconds_bucket{job="backend", le="2"}'
|
||||||
|
values: "0+10x30"
|
||||||
|
- series: 'http_request_duration_highr_seconds_bucket{job="backend", le="+Inf"}'
|
||||||
|
values: "0+10x30"
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 20m
|
||||||
|
alertname: ApiLatenceElevee
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
severity: warning
|
||||||
|
exp_annotations:
|
||||||
|
summary: "p95 de l'API au-dessus d'une seconde"
|
||||||
|
description: "Le p95 des réponses vaut 1.95s depuis 10 minutes."
|
||||||
|
|
||||||
|
- name: la mémoire de l'hôte au-delà de 90 % déclenche un avertissement
|
||||||
|
interval: 1m
|
||||||
|
input_series:
|
||||||
|
- series: 'node_memory_MemAvailable_bytes{job="node", instance="node-exporter:9100"}'
|
||||||
|
values: "500000000x15"
|
||||||
|
- series: 'node_memory_MemTotal_bytes{job="node", instance="node-exporter:9100"}'
|
||||||
|
values: "10000000000x15"
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 12m
|
||||||
|
alertname: HoteMemoireSaturee
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
severity: warning
|
||||||
|
job: node
|
||||||
|
instance: node-exporter:9100
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Mémoire de l'hôte au-delà de 90 %"
|
||||||
|
description: "95% de la mémoire est utilisée, recette et prod comprises."
|
||||||
|
|
||||||
|
- name: un exportateur muet déclenche l'alerte de cible, pas celle de l'API
|
||||||
|
interval: 1m
|
||||||
|
input_series:
|
||||||
|
- series: 'up{job="postgres", instance="postgres-exporter:9187"}'
|
||||||
|
values: "0x10"
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 6m
|
||||||
|
alertname: CibleInjoignable
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
severity: warning
|
||||||
|
job: postgres
|
||||||
|
instance: postgres-exporter:9187
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Cible de supervision injoignable"
|
||||||
|
description: "Prometheus ne joint plus postgres (postgres-exporter:9187) depuis 5 minutes."
|
||||||
|
- eval_time: 6m
|
||||||
|
alertname: ApiIndisponible
|
||||||
|
exp_alerts: []
|
||||||
Executable
+101
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Contrainte : réservé à une base JETABLE (CI, e2e, charge sur le poste) - comptes-test.sh. Crée
|
||||||
|
# un administrateur par la CLI, puis un lecteur et un opérateur, et écrit leurs identifiants en
|
||||||
|
# JSON dans $COMPTES_FICHIER.
|
||||||
|
# Piège : un compte neuf est en `must_change_password`, que toute route gardée refuse. Chaque
|
||||||
|
# compte passe donc le changement de mot de passe avant d'être écrit dans le fichier.
|
||||||
|
# Piège : `create-admin` refuse un second administrateur actif. ADMIN_SUPPLEMENTAIRE=1 passe
|
||||||
|
# `--force`, pour la base du poste ; jamais en recette, où les comptes se créent par l'interface.
|
||||||
|
#
|
||||||
|
# BASE_URL vise l'API (http://localhost:8000 par défaut, ou le proxy en https). APP_CLI lance la
|
||||||
|
# CLI du backend : depuis apps/backend par défaut, ou `docker compose ... exec -T backend python
|
||||||
|
# -m app.cli` contre la stack conteneurisée.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
# Sans lui, une erreur dans `$(...)` ne s'arrête pas : la substitution rendrait un mot de passe vide.
|
||||||
|
shopt -s inherit_errexit
|
||||||
|
|
||||||
|
BASE_URL="${BASE_URL:-http://localhost:8000}"
|
||||||
|
API="$BASE_URL/api/v1"
|
||||||
|
COMPTES_FICHIER="${COMPTES_FICHIER:?COMPTES_FICHIER=chemin du fichier JSON requis}"
|
||||||
|
read -ra CLI <<<"${APP_CLI:-uv run --frozen --no-sync --no-build python -m app.cli}"
|
||||||
|
SUFFIXE="$(openssl rand -hex 4)"
|
||||||
|
|
||||||
|
CURL=(curl -fsS)
|
||||||
|
[[ "$BASE_URL" == https://* ]] && CURL+=(--insecure)
|
||||||
|
|
||||||
|
# Classes exigées par le validateur : majuscule, minuscule, chiffre, caractère spécial.
|
||||||
|
nouveau_mot_de_passe() { echo "Test-$(openssl rand -hex 12)-Aa1!"; }
|
||||||
|
|
||||||
|
journal() { echo "comptes-test: $*" >&2; }
|
||||||
|
|
||||||
|
connexion() {
|
||||||
|
local email="$1" mot_de_passe="$2"
|
||||||
|
"${CURL[@]}" -X POST "$API/auth/login" -H 'Content-Type: application/json' \
|
||||||
|
-d "$(jq -n --arg e "$email" --arg p "$mot_de_passe" '{email:$e, password:$p}')" \
|
||||||
|
| jq -r '.access_token'
|
||||||
|
}
|
||||||
|
|
||||||
|
# `/auth/password` rend un nouveau jeton d'accès : pas de reconnexion, donc pas de second hachage
|
||||||
|
# Argon2id ni de requête de plus dans la zone `auth` de nginx (30 par minute).
|
||||||
|
changer_mot_de_passe() {
|
||||||
|
local jeton="$1" ancien="$2" nouveau="$3"
|
||||||
|
"${CURL[@]}" -X POST "$API/auth/password" \
|
||||||
|
-H "Authorization: Bearer $jeton" -H 'Content-Type: application/json' \
|
||||||
|
-d "$(jq -n --arg a "$ancien" --arg n "$nouveau" '{current_password:$a, new_password:$n}')" \
|
||||||
|
| jq -r '.access_token'
|
||||||
|
}
|
||||||
|
|
||||||
|
creer_compte() {
|
||||||
|
local jeton_admin="$1" email="$2" role="$3" reponse temporaire
|
||||||
|
reponse="$("${CURL[@]}" -X POST "$API/users" -H "Authorization: Bearer $jeton_admin" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$(jq -n --arg e "$email" --arg r "$role" '{email:$e, role:$r}')")"
|
||||||
|
temporaire="$(jq -r '.temporary_password // empty' <<<"$reponse")"
|
||||||
|
[[ -n "$temporaire" ]] || { journal "mot de passe temporaire absent de la réponse : $reponse"; exit 1; }
|
||||||
|
echo "$temporaire"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rend le mot de passe définitif d'un compte créé par `creer_compte`.
|
||||||
|
activer_compte() {
|
||||||
|
local email="$1" temporaire="$2" definitif jeton
|
||||||
|
definitif="$(nouveau_mot_de_passe)"
|
||||||
|
jeton="$(connexion "$email" "$temporaire")"
|
||||||
|
changer_mot_de_passe "$jeton" "$temporaire" "$definitif" >/dev/null
|
||||||
|
echo "$definitif"
|
||||||
|
}
|
||||||
|
|
||||||
|
EMAIL_ADMIN="test-admin-$SUFFIXE@enervision.fr"
|
||||||
|
journal "création de l'administrateur $EMAIL_ADMIN"
|
||||||
|
OPTIONS_ADMIN=(--email "$EMAIL_ADMIN" --generate)
|
||||||
|
[[ "${ADMIN_SUPPLEMENTAIRE:-0}" == "1" ]] && OPTIONS_ADMIN+=(--force)
|
||||||
|
if ! SORTIE="$("${CLI[@]}" create-admin "${OPTIONS_ADMIN[@]}")"; then
|
||||||
|
journal "la création de l'administrateur a échoué : $SORTIE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
MDP_TEMPORAIRE_ADMIN="$(sed -n 's/^Mot de passe généré, il ne sera plus affiché : //p' <<<"$SORTIE" | tr -d '\r')"
|
||||||
|
[[ -n "$MDP_TEMPORAIRE_ADMIN" ]] || { journal "mot de passe introuvable dans la sortie : $SORTIE"; exit 1; }
|
||||||
|
|
||||||
|
MDP_ADMIN="$(nouveau_mot_de_passe)"
|
||||||
|
JETON_ADMIN="$(connexion "$EMAIL_ADMIN" "$MDP_TEMPORAIRE_ADMIN")"
|
||||||
|
JETON_ADMIN="$(changer_mot_de_passe "$JETON_ADMIN" "$MDP_TEMPORAIRE_ADMIN" "$MDP_ADMIN")"
|
||||||
|
|
||||||
|
EMAIL_LECTEUR="test-lecteur-$SUFFIXE@enervision.fr"
|
||||||
|
EMAIL_OPERATEUR="test-operateur-$SUFFIXE@enervision.fr"
|
||||||
|
journal "création du lecteur et de l'opérateur"
|
||||||
|
TEMPORAIRE_LECTEUR="$(creer_compte "$JETON_ADMIN" "$EMAIL_LECTEUR" lecteur)"
|
||||||
|
MDP_LECTEUR="$(activer_compte "$EMAIL_LECTEUR" "$TEMPORAIRE_LECTEUR")"
|
||||||
|
TEMPORAIRE_OPERATEUR="$(creer_compte "$JETON_ADMIN" "$EMAIL_OPERATEUR" operateur)"
|
||||||
|
MDP_OPERATEUR="$(activer_compte "$EMAIL_OPERATEUR" "$TEMPORAIRE_OPERATEUR")"
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
jq -n \
|
||||||
|
--arg ae "$EMAIL_ADMIN" --arg ap "$MDP_ADMIN" \
|
||||||
|
--arg le "$EMAIL_LECTEUR" --arg lp "$MDP_LECTEUR" \
|
||||||
|
--arg oe "$EMAIL_OPERATEUR" --arg op "$MDP_OPERATEUR" \
|
||||||
|
'{
|
||||||
|
admin: {email: $ae, password: $ap},
|
||||||
|
lecteur: {email: $le, password: $lp},
|
||||||
|
operateur: {email: $oe, password: $op}
|
||||||
|
}' >"$COMPTES_FICHIER"
|
||||||
|
journal "identifiants écrits dans $COMPTES_FICHIER"
|
||||||
+12
-66
@@ -1,78 +1,24 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Prépare le scan DAST : crée un compte `lecteur` sur une API déjà démarrée, lui fait passer le
|
# Contrainte : prépare le scan DAST sur une base JETABLE - dast-token.sh. Crée les comptes de
|
||||||
# changement de mot de passe obligatoire, et écrit son jeton d'accès sur la sortie standard.
|
# test (scripts/comptes-test.sh, mêmes variables) et écrit sur la sortie standard le seul jeton
|
||||||
#
|
# d'accès du `lecteur`.
|
||||||
# Piège : un compte neuf est en `must_change_password`, et toute route gardée le refuse tant que
|
|
||||||
# le mot de passe n'a pas été changé. Sans cette étape, ZAP ne verrait que 403 sur les routes
|
|
||||||
# gardées et le scan ne testerait rien de l'API authentifiée.
|
|
||||||
#
|
|
||||||
# Contrainte : le compte du scan est `lecteur`, jamais `admin`. Un scan actif avec un jeton admin
|
# Contrainte : le compte du scan est `lecteur`, jamais `admin`. Un scan actif avec un jeton admin
|
||||||
# frapperait POST /users ou la réinitialisation de mots de passe pour de bon.
|
# frapperait POST /users ou la réinitialisation de mots de passe pour de bon.
|
||||||
#
|
|
||||||
# L'administrateur n'existe que pour créer ce compte (l'API n'a pas d'inscription publique).
|
|
||||||
# À lancer depuis apps/backend, dans un environnement où DATABASE_URL et APP_SECRET_KEY visent
|
|
||||||
# une base JETABLE : le script y crée deux comptes.
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
shopt -s inherit_errexit
|
||||||
|
|
||||||
|
ICI="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
BASE_URL="${BASE_URL:-http://localhost:8000}"
|
BASE_URL="${BASE_URL:-http://localhost:8000}"
|
||||||
API="$BASE_URL/api/v1"
|
COMPTES="$(mktemp)"
|
||||||
SUFFIXE="$(openssl rand -hex 4)"
|
trap 'rm -f "$COMPTES"' EXIT
|
||||||
EMAIL_ADMIN="dast-admin-$SUFFIXE@enervision.fr"
|
|
||||||
EMAIL_LECTEUR="dast-lecteur-$SUFFIXE@enervision.fr"
|
|
||||||
|
|
||||||
# Classes exigées par le validateur : majuscule, minuscule, chiffre, caractère spécial.
|
COMPTES_FICHIER="$COMPTES" BASE_URL="$BASE_URL" "$ICI/comptes-test.sh"
|
||||||
nouveau_mot_de_passe() { echo "Dast-$(openssl rand -hex 12)-Aa1!"; }
|
|
||||||
|
|
||||||
# Tout ce qui n'est pas la sortie finale part sur stderr : la sortie standard ne porte que le jeton.
|
JETON="$(curl -fsS -X POST "$BASE_URL/api/v1/auth/login" -H 'Content-Type: application/json' \
|
||||||
journal() { echo "dast-token: $*" >&2; }
|
-d "$(jq '.lecteur | {email, password}' "$COMPTES")" | jq -r '.access_token')"
|
||||||
|
|
||||||
connexion() {
|
CODE="$(curl -sS -o /dev/null -w '%{http_code}' "$BASE_URL/api/v1/sites" -H "Authorization: Bearer $JETON")"
|
||||||
local email="$1" mot_de_passe="$2"
|
[[ "$CODE" == "200" ]] || { echo "dast-token: GET /sites répond $CODE avec le jeton du lecteur, attendu 200" >&2; exit 1; }
|
||||||
curl -fsS -X POST "$API/auth/login" -H 'Content-Type: application/json' \
|
|
||||||
-d "$(jq -n --arg e "$email" --arg p "$mot_de_passe" '{email:$e, password:$p}')" \
|
|
||||||
| jq -r '.access_token'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Rend le nouveau jeton d'accès : `/auth/password` en émet un (avec l'`iat` de la session en
|
|
||||||
# cours, cf. le piège documenté dans `app/api/deps.py`), pas seulement une confirmation. S'y fier
|
|
||||||
# évite une reconnexion, donc un second hachage Argon2id (19456 Kio) et un aller-retour de
|
|
||||||
# refresh-token superflus sur le chemin critique de la CI.
|
|
||||||
changer_mot_de_passe() {
|
|
||||||
local jeton="$1" ancien="$2" nouveau="$3"
|
|
||||||
curl -fsS -X POST "$API/auth/password" \
|
|
||||||
-H "Authorization: Bearer $jeton" -H 'Content-Type: application/json' \
|
|
||||||
-d "$(jq -n --arg a "$ancien" --arg n "$nouveau" '{current_password:$a, new_password:$n}')" \
|
|
||||||
| jq -r '.access_token'
|
|
||||||
}
|
|
||||||
|
|
||||||
journal "création de l'administrateur $EMAIL_ADMIN"
|
|
||||||
if ! SORTIE="$(uv run --frozen --no-sync --no-build python -m app.cli create-admin --email "$EMAIL_ADMIN" --generate)"; then
|
|
||||||
journal "la création de l'administrateur a échoué :"
|
|
||||||
journal "$SORTIE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
MDP_ADMIN="$(sed -n 's/^Mot de passe généré, il ne sera plus affiché : //p' <<<"$SORTIE")"
|
|
||||||
[[ -n "$MDP_ADMIN" ]] || { journal "mot de passe administrateur introuvable dans la sortie :"; journal "$SORTIE"; exit 1; }
|
|
||||||
|
|
||||||
JETON="$(connexion "$EMAIL_ADMIN" "$MDP_ADMIN")"
|
|
||||||
NOUVEAU_ADMIN="$(nouveau_mot_de_passe)"
|
|
||||||
JETON="$(changer_mot_de_passe "$JETON" "$MDP_ADMIN" "$NOUVEAU_ADMIN")"
|
|
||||||
|
|
||||||
journal "création du lecteur $EMAIL_LECTEUR"
|
|
||||||
REPONSE="$(curl -fsS -X POST "$API/users" -H "Authorization: Bearer $JETON" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d "$(jq -n --arg e "$EMAIL_LECTEUR" '{email:$e, role:"lecteur"}')")"
|
|
||||||
MDP_TEMPORAIRE="$(jq -r '.temporary_password // empty' <<<"$REPONSE")"
|
|
||||||
[[ -n "$MDP_TEMPORAIRE" ]] || { journal "mot de passe temporaire introuvable dans la réponse de POST /users :"; journal "$REPONSE"; exit 1; }
|
|
||||||
|
|
||||||
JETON="$(connexion "$EMAIL_LECTEUR" "$MDP_TEMPORAIRE")"
|
|
||||||
NOUVEAU_LECTEUR="$(nouveau_mot_de_passe)"
|
|
||||||
JETON="$(changer_mot_de_passe "$JETON" "$MDP_TEMPORAIRE" "$NOUVEAU_LECTEUR")"
|
|
||||||
|
|
||||||
# Vérifie que le jeton ouvre bien une route gardée avant de le rendre.
|
|
||||||
CODE="$(curl -sS -o /dev/null -w '%{http_code}' "$API/sites" -H "Authorization: Bearer $JETON")"
|
|
||||||
[[ "$CODE" == "200" ]] || { journal "GET /sites répond $CODE avec le jeton du lecteur, attendu 200"; exit 1; }
|
|
||||||
|
|
||||||
journal "jeton du lecteur prêt"
|
|
||||||
echo "$JETON"
|
echo "$JETON"
|
||||||
|
|||||||
+160
-45
@@ -1,23 +1,39 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Pourquoi : la machine porte deux environnements, chacun un clone du dépôt, un `.env` et un
|
# Pourquoi : la machine porte trois environnements, chacun un clone du dépôt, un `.env` et un
|
||||||
# projet Compose (ADR 0009). Ce script prépare la machine et les deux dossiers sans rien
|
# projet Compose (ADR 0009, 0017), derrière un frontal SNI sur 443 (ADR 0018). Ce script prépare
|
||||||
# démarrer : construction des images et démarrage restent à l'opérateur, puis au runner GitHub.
|
# la machine et les trois dossiers sans démarrer aucune stack : c'est le rôle du runner.
|
||||||
# Rejouable : un dossier déjà cloné est réaligné sur sa branche, un `.env` existant n'est jamais
|
# Piège : le script, et non le `.env.example` du clone, fait foi pour les secrets (GENERATEURS)
|
||||||
# réécrit, un certificat présent n'est jamais régénéré.
|
# et l'adressage (tableau du bas). Un `.env` existant garde ses secrets, reçoit ceux qui lui
|
||||||
|
# manquent et voit son adressage réaligné : sans ça, un `.env` né avant une clé ne la reçoit
|
||||||
|
# jamais, et le clone de la prod, en retard sur `main`, ne connaîtrait pas les nouvelles.
|
||||||
|
# Piège : lancé en root, git refuse un clone déjà chowné au runner (propriété douteuse). D'où
|
||||||
|
# `safe.directory` passé en ligne de commande, seule portée où git l'accepte - preparer().
|
||||||
|
# Rejouable : un certificat n'est refait que s'il ne couvre plus l'hôte, et celui de Let's Encrypt
|
||||||
|
# n'est renouvelé qu'à échéance.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
DEPOT="${REPO_URL:-https://github.com/ineszang/ProjetPiscine_EnerVision.git}"
|
DEPOT="${REPO_URL:-https://github.com/ineszang/ProjetPiscine_EnerVision.git}"
|
||||||
RACINE="${RACINE:-/srv/enervision}"
|
RACINE="${RACINE:-/srv/enervision}"
|
||||||
|
DOMAINE="${DOMAINE:-enervision-g3.dynv6.net}"
|
||||||
ADRESSE="${PUBLIC_IP:-$(hostname -I | awk '{print $1}')}"
|
ADRESSE="${PUBLIC_IP:-$(hostname -I | awk '{print $1}')}"
|
||||||
PROPRIETAIRE="${PROPRIETAIRE:-${SUDO_USER:-}}"
|
PROPRIETAIRE="${PROPRIETAIRE:-${SUDO_USER:-}}"
|
||||||
|
JETON_DNS="$RACINE/dns.token"
|
||||||
COMPOSE_MINIMALE="2.24.4"
|
COMPOSE_MINIMALE="2.24.4"
|
||||||
|
|
||||||
erreur() { echo "erreur : $*" >&2; exit 1; }
|
erreur() { echo "erreur : $*" >&2; exit 1; }
|
||||||
secret() { openssl rand -base64 48 | tr -d '/+=\n' | cut -c1-48; }
|
secret() { openssl rand -base64 48 | tr -d '/+=\n' | cut -c1-48; }
|
||||||
|
court() { secret | cut -c1-20; }
|
||||||
# Clé Fernet : 32 octets en base64 urlsafe, padding compris.
|
# Clé Fernet : 32 octets en base64 urlsafe, padding compris.
|
||||||
fernet() { openssl rand -base64 32 | tr '+/' '-_'; }
|
fernet() { openssl rand -base64 32 | tr '+/' '-_'; }
|
||||||
|
|
||||||
|
declare -A GENERATEURS=(
|
||||||
|
[POSTGRES_PASSWORD]=secret [APP_SECRET_KEY]=secret [AIRFLOW_FERNET_KEY]=fernet
|
||||||
|
[AIRFLOW_API_SECRET_KEY]=secret [AIRFLOW_JWT_SECRET]=secret [AIRFLOW_ADMIN_PASSWORD]=court
|
||||||
|
[AIRFLOW_APP_SECRET_KEY]=secret [APP_METRICS_TOKEN]=secret [GRAFANA_ADMIN_PASSWORD]=court
|
||||||
|
[SUPERVISION_DB_PASSWORD]=secret
|
||||||
|
)
|
||||||
|
|
||||||
verifier_outils() {
|
verifier_outils() {
|
||||||
for outil in git make openssl curl; do
|
for outil in git make openssl curl; do
|
||||||
command -v "$outil" >/dev/null || erreur "$outil absent (apt-get install $outil)"
|
command -v "$outil" >/dev/null || erreur "$outil absent (apt-get install $outil)"
|
||||||
@@ -33,81 +49,180 @@ verifier_outils() {
|
|||||||
echo "docker compose $version, sortie Internet : ok"
|
echo "docker compose $version, sortie Internet : ok"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
valeur() { sed -n "s/^$2=//p" "$1" | tail -1; }
|
||||||
|
|
||||||
|
poser() {
|
||||||
|
local fichier="$1" cle="$2" contenu="$3"
|
||||||
|
if grep -q "^$cle=" "$fichier"; then
|
||||||
|
CLE="$cle" CONTENU="$contenu" awk -F= '
|
||||||
|
$1 == ENVIRON["CLE"] { print ENVIRON["CLE"] "=" ENVIRON["CONTENU"]; next } { print }
|
||||||
|
' "$fichier" > "$fichier.tmp"
|
||||||
|
mv "$fichier.tmp" "$fichier"
|
||||||
|
else
|
||||||
|
printf '%s=%s\n' "$cle" "$contenu" >> "$fichier"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
preparer() {
|
preparer() {
|
||||||
local env="$1" branche="$2" hote="$3" origine="$4"
|
local env="$1" branche="$2" hote="$3"
|
||||||
local port_https="$5" port_http="$6" port_pg="$7" port_mailpit="$8" port_airflow="$9"
|
local port_https="$4" port_http="$5" port_front="$6" port_pg="$7" port_mailpit="$8"
|
||||||
|
local port_airflow="$9" profils="${10}" port_grafana="${11}" port_prometheus="${12}"
|
||||||
|
local port_alertmanager="${13}"
|
||||||
local dossier="$RACINE/$env"
|
local dossier="$RACINE/$env"
|
||||||
|
local fichier="$dossier/.env" brouillon="$dossier/.env.brouillon" cle oubliees ajoutees=""
|
||||||
|
|
||||||
if [[ -d "$dossier/.git" ]]; then
|
if [[ -d "$dossier/.git" ]]; then
|
||||||
git -C "$dossier" fetch --quiet origin "$branche"
|
local git=(git -c "safe.directory=$dossier" -C "$dossier")
|
||||||
git -C "$dossier" checkout --quiet "$branche"
|
"${git[@]}" fetch --quiet origin "$branche"
|
||||||
git -C "$dossier" reset --quiet --hard "origin/$branche"
|
"${git[@]}" checkout --quiet "$branche"
|
||||||
|
"${git[@]}" reset --quiet --hard "origin/$branche"
|
||||||
else
|
else
|
||||||
git clone --quiet --branch "$branche" "$DEPOT" "$dossier"
|
git clone --quiet --branch "$branche" "$DEPOT" "$dossier"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$dossier/.env" ]]; then
|
local masque
|
||||||
local brouillon="$dossier/.env.brouillon" oubliees
|
masque="$(umask)"
|
||||||
sed -e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(secret)|" \
|
umask 077
|
||||||
-e "s|^POSTGRES_PORT=.*|POSTGRES_PORT=$port_pg|" \
|
if [[ -f "$fichier" ]]; then
|
||||||
-e "s|^APP_SECRET_KEY=.*|APP_SECRET_KEY=$(secret)|" \
|
cp -p "$fichier" "$brouillon"
|
||||||
-e "s|^MAILPIT_UI_PORT=.*|MAILPIT_UI_PORT=$port_mailpit|" \
|
awk -F= 'NR == FNR { connues[$1]; next } /^[A-Z_][A-Z0-9_]*=/ && !($1 in connues)' \
|
||||||
-e "s|^AIRFLOW_PORT=.*|AIRFLOW_PORT=$port_airflow|" \
|
"$fichier" "$dossier/.env.example" >> "$brouillon"
|
||||||
-e "s|^AIRFLOW_FERNET_KEY=.*|AIRFLOW_FERNET_KEY=$(fernet)|" \
|
else
|
||||||
-e "s|^AIRFLOW_API_SECRET_KEY=.*|AIRFLOW_API_SECRET_KEY=$(secret)|" \
|
cp "$dossier/.env.example" "$brouillon"
|
||||||
-e "s|^AIRFLOW_JWT_SECRET=.*|AIRFLOW_JWT_SECRET=$(secret)|" \
|
fi
|
||||||
-e "s|^AIRFLOW_ADMIN_PASSWORD=.*|AIRFLOW_ADMIN_PASSWORD=$(secret | cut -c1-20)|" \
|
|
||||||
-e "s|^AIRFLOW_APP_SECRET_KEY=.*|AIRFLOW_APP_SECRET_KEY=$(secret)|" \
|
for cle in "${!GENERATEURS[@]}"; do
|
||||||
-e "s|^PUBLIC_HOST=.*|PUBLIC_HOST=$hote|" \
|
case "$(valeur "$brouillon" "$cle")" in
|
||||||
-e "s|^PUBLIC_ORIGIN=.*|PUBLIC_ORIGIN=$origine|" \
|
"" | change_me) poser "$brouillon" "$cle" "$("${GENERATEURS[$cle]}")"; ajoutees+=" $cle" ;;
|
||||||
-e "s|^COMPOSE_PROJECT_NAME=.*|COMPOSE_PROJECT_NAME=enervision-$env|" \
|
esac
|
||||||
-e "s|^PROXY_HTTP_PORT=.*|PROXY_HTTP_PORT=$port_http|" \
|
|
||||||
-e "s|^PROXY_HTTPS_PORT=.*|PROXY_HTTPS_PORT=$port_https|" \
|
|
||||||
"$dossier/.env.example" > "$brouillon"
|
|
||||||
# Branche antérieure à l'ADR 0009 : ces clés manquent alors dans .env.example.
|
|
||||||
for cle in "COMPOSE_PROJECT_NAME=enervision-$env" "PUBLIC_ORIGIN=$origine" \
|
|
||||||
"PROXY_HTTP_PORT=$port_http" "PROXY_HTTPS_PORT=$port_https"; do
|
|
||||||
grep -q "^${cle%%=*}=" "$brouillon" || echo "$cle" >> "$brouillon"
|
|
||||||
done
|
done
|
||||||
# Piège : une clé renommée en amont garde sa valeur d'exemple, que le `:?` du compose ne
|
|
||||||
# voit pas puisqu'elle n'est pas vide. Cas vécu : AIRFLOW_WEBSERVER_SECRET_KEY, Airflow 3.
|
declare -A adressage=(
|
||||||
|
[PUBLIC_HOST]="$hote" [PUBLIC_ORIGIN]="https://$hote" [COMPOSE_PROJECT_NAME]="enervision-$env"
|
||||||
|
[PROXY_HTTPS_PORT]="$port_https" [PROXY_HTTP_PORT]="$port_http" [PROXY_FRONT_PORT]="$port_front"
|
||||||
|
[POSTGRES_PORT]="$port_pg" [MAILPIT_UI_PORT]="$port_mailpit" [AIRFLOW_PORT]="$port_airflow"
|
||||||
|
[COMPOSE_PROFILES]="$profils" [GRAFANA_PORT]="$port_grafana"
|
||||||
|
[PROMETHEUS_PORT]="$port_prometheus" [ALERTMANAGER_PORT]="$port_alertmanager"
|
||||||
|
)
|
||||||
|
for cle in "${!adressage[@]}"; do
|
||||||
|
poser "$brouillon" "$cle" "${adressage[$cle]}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Piège : une clé renommée en amont garde sa valeur d'exemple, que le `:?` du compose ne voit
|
||||||
|
# pas puisqu'elle n'est pas vide. Cas vécu : AIRFLOW_WEBSERVER_SECRET_KEY, Airflow 3.
|
||||||
oubliees="$(grep '=change_me$' "$brouillon" | grep -v '^APP_MOCK_API_' | cut -d= -f1 | tr '\n' ' ' || true)"
|
oubliees="$(grep '=change_me$' "$brouillon" | grep -v '^APP_MOCK_API_' | cut -d= -f1 | tr '\n' ' ' || true)"
|
||||||
if [[ -n "$oubliees" ]]; then
|
if [[ -n "$oubliees" ]]; then
|
||||||
rm -f "$brouillon"
|
rm -f "$brouillon"
|
||||||
erreur "$env : secrets non générés, .env non écrit : $oubliees"
|
erreur "$env : clés sans générateur, .env inchangé : $oubliees"
|
||||||
fi
|
fi
|
||||||
chmod 600 "$brouillon"
|
chmod 600 "$brouillon"
|
||||||
mv "$brouillon" "$dossier/.env"
|
if [[ -f "$fichier" ]]; then
|
||||||
|
cat "$brouillon" > "$fichier"
|
||||||
|
rm -f "$brouillon"
|
||||||
|
echo "$env : .env réaligné sur le tableau${ajoutees:+, secrets ajoutés :$ajoutees}"
|
||||||
|
else
|
||||||
|
mv "$brouillon" "$fichier"
|
||||||
echo "$env : .env généré. Reste à renseigner APP_MOCK_API_USERNAME et APP_MOCK_API_PASSWORD."
|
echo "$env : .env généré. Reste à renseigner APP_MOCK_API_USERNAME et APP_MOCK_API_PASSWORD."
|
||||||
fi
|
fi
|
||||||
|
umask "$masque"
|
||||||
|
|
||||||
if [[ ! -f "$dossier/infra/proxy/tls/fullchain.pem" ]]; then
|
if ! openssl x509 -in "$dossier/infra/proxy/tls/fullchain.pem" -noout -checkhost "$hote" 2>/dev/null \
|
||||||
(cd "$dossier" && PUBLIC_HOST="$hote" PUBLIC_IP="$ADRESSE" ./scripts/tls-selfsigned.sh)
|
| grep -q " does match"; then
|
||||||
|
(cd "$dossier" && PUBLIC_HOST="$hote" PUBLIC_IP="$ADRESSE" ./scripts/tls-selfsigned.sh --force)
|
||||||
fi
|
fi
|
||||||
echo "$env : $dossier sur $branche, $origine"
|
if [[ -r "$JETON_DNS" && "$hote" != *.local ]]; then
|
||||||
|
make -C "$dossier" --no-print-directory tls-dns01 PUBLIC_HOST="$hote" \
|
||||||
|
|| echo "$env : pas de certificat Let's Encrypt, l'auto-signé reste en place" >&2
|
||||||
|
fi
|
||||||
|
echo "$env : $dossier sur $branche, https://$hote"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pourquoi : dynv6 est le fournisseur que le filtrage de l'école laisse passer (ADR 0018). Le
|
||||||
|
# jeton passe par l'environnement du seul processus Python, jamais par `argv`.
|
||||||
|
publier_dns() {
|
||||||
|
[[ -r "$JETON_DNS" ]] || { echo "pas de jeton $JETON_DNS : ni DNS ni Let's Encrypt"; return 0; }
|
||||||
|
DNS_TOKEN="$(tr -d '[:space:]' < "$JETON_DNS")" python3 - "$DOMAINE" "$ADRESSE" prod rec dev <<'PY' \
|
||||||
|
|| echo "DNS : dynv6 refuse la mise à jour de $DOMAINE, enregistrements inchangés" >&2
|
||||||
|
import json, os, sys, time, urllib.request
|
||||||
|
|
||||||
|
domaine, adresse, *sous_noms = sys.argv[1:]
|
||||||
|
|
||||||
|
def appel(methode, chemin, corps=None):
|
||||||
|
requete = urllib.request.Request(
|
||||||
|
f"https://dynv6.com/api/v2/{chemin}", method=methode,
|
||||||
|
data=None if corps is None else json.dumps(corps).encode(),
|
||||||
|
headers={"Authorization": f"Bearer {os.environ['DNS_TOKEN']}", "User-Agent": "enervision-provision",
|
||||||
|
"Content-Type": "application/json", "Accept": "application/json"})
|
||||||
|
with urllib.request.urlopen(requete, timeout=60) as reponse:
|
||||||
|
contenu = reponse.read()
|
||||||
|
return json.loads(contenu) if contenu else None
|
||||||
|
|
||||||
|
def synchroniser():
|
||||||
|
zone = appel("GET", f"zones/by-name/{domaine}")
|
||||||
|
if zone.get("ipv4address") != adresse:
|
||||||
|
appel("PATCH", f"zones/{zone['id']}", {"ipv4address": adresse})
|
||||||
|
existants = {(r["type"], r["name"]): r for r in appel("GET", f"zones/{zone['id']}/records")}
|
||||||
|
for nom in sous_noms:
|
||||||
|
actuel = existants.get(("A", nom))
|
||||||
|
if actuel is None:
|
||||||
|
appel("POST", f"zones/{zone['id']}/records", {"type": "A", "name": nom, "data": adresse})
|
||||||
|
elif actuel["data"] != adresse:
|
||||||
|
appel("PATCH", f"zones/{zone['id']}/records/{actuel['id']}", {"data": adresse})
|
||||||
|
|
||||||
|
# dynv6 laisse parfois une écriture sans réponse, appliquée ou non : chaque essai relit l'état
|
||||||
|
# avant d'écrire, si bien qu'une création aboutie malgré le délai n'est jamais dupliquée.
|
||||||
|
for essai in range(3):
|
||||||
|
try:
|
||||||
|
synchroniser()
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
if essai == 2:
|
||||||
|
raise
|
||||||
|
time.sleep(5)
|
||||||
|
print(f"DNS : {domaine}, {', '.join(sous_noms)} visent {adresse}")
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
planifier_renouvellement() {
|
||||||
|
[[ "$(id -u)" -eq 0 && -n "$PROPRIETAIRE" && -d /etc/cron.d ]] || return 0
|
||||||
|
cat > /etc/cron.d/enervision-tls <<CRON
|
||||||
|
# Renouvellement Let's Encrypt des trois environnements (ADR 0018), écrit par provision-host.sh.
|
||||||
|
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
23 4 * * * $PROPRIETAIRE for e in prod rec dev; do make -C $RACINE/\$e --no-print-directory tls-dns01; done 2>&1 | logger -t enervision-tls
|
||||||
|
CRON
|
||||||
|
chmod 644 /etc/cron.d/enervision-tls
|
||||||
|
echo "renouvellement planifié : /etc/cron.d/enervision-tls"
|
||||||
}
|
}
|
||||||
|
|
||||||
verifier_outils
|
verifier_outils
|
||||||
mkdir -p "$RACINE"
|
mkdir -p "$RACINE"
|
||||||
|
publier_dns
|
||||||
|
|
||||||
# env branche hôte origine https http pg mailpit airflow
|
# Supervision active en prod seulement (ADR 0016). La prod vit sur `prod.` et non à la racine :
|
||||||
preparer prod main enervision.local https://enervision.local 443 80 5433 8025 8080
|
# dynv6 ne sert pas de façon fiable un TXT `_acme-challenge` à la racine de la zone (ADR 0018).
|
||||||
preparer rec dev rec.enervision.local https://rec.enervision.local:8443 8443 127.0.0.1:8081 5434 8026 8082
|
# env branche hôte https http front pg mailpit airflow profils grafana prometheus alertmanager
|
||||||
|
preparer prod main "prod.$DOMAINE" 127.0.0.1:10443 127.0.0.1:10080 127.0.0.1:10444 5433 8025 8080 monitoring 3001 9090 9093
|
||||||
|
preparer rec dev "rec.$DOMAINE" 127.0.0.1:8443 127.0.0.1:8081 127.0.0.1:8444 5434 8026 8082 "" 3002 9091 9094
|
||||||
|
preparer dev dev "dev.$DOMAINE" 127.0.0.1:9443 127.0.0.1:8083 127.0.0.1:9444 5435 8027 8084 "" 3003 9092 9095
|
||||||
|
|
||||||
|
planifier_renouvellement
|
||||||
if [[ -n "$PROPRIETAIRE" && "$(id -u)" -eq 0 ]]; then
|
if [[ -n "$PROPRIETAIRE" && "$(id -u)" -eq 0 ]]; then
|
||||||
chown -R "$PROPRIETAIRE" "$RACINE"
|
chown -R "$PROPRIETAIRE" "$RACINE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cat <<FIN
|
cat <<FIN
|
||||||
|
|
||||||
Démarrage, dans chaque dossier : make stack-up, qui applique aussi les migrations.
|
Démarrage, dans chaque dossier : make stack-up, qui applique aussi les migrations. Puis, une
|
||||||
|
fois, depuis $RACINE/prod : make front-up, que chaque déploiement de la prod rejoue ensuite.
|
||||||
Premier administrateur, stack démarrée, dans chaque dossier :
|
Premier administrateur, stack démarrée, dans chaque dossier :
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend \\
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend \\
|
||||||
python -m app.cli create-admin --email <adresse>
|
python -m app.cli create-admin --email <adresse>
|
||||||
Le runner GitHub Actions (label eni-g3) rejouera le déploiement à chaque push sur dev et main.
|
Le runner GitHub Actions (label eni-g3) rejouera le déploiement à chaque push sur dev et main,
|
||||||
|
et déploiera dans dev toute autre branche lancée à la main depuis l'onglet Actions.
|
||||||
L'installer sous le propriétaire de $RACINE, sinon git refuse ces dépôts et le .env en 600 lui
|
L'installer sous le propriétaire de $RACINE, sinon git refuse ces dépôts et le .env en 600 lui
|
||||||
échappe : relancer au besoin ce script avec PROPRIETAIRE=<utilisateur du runner>.
|
échappe : relancer au besoin ce script avec PROPRIETAIRE=<utilisateur du runner>.
|
||||||
Données historiques : git ne porte pas data/raw, déposer les fichiers dans chaque dossier avant
|
Données historiques : git ne porte pas data/raw, déposer les fichiers dans chaque dossier avant
|
||||||
de déclencher le DAG historical_import.
|
de déclencher le DAG historical_import.
|
||||||
Depuis un poste : ajouter « $ADRESSE enervision.local rec.enervision.local » à /etc/hosts.
|
Noms et certificats : le jeton dynv6 de la zone $DOMAINE doit se trouver dans $JETON_DNS (600,
|
||||||
|
propriétaire du runner). Sans lui, ni enregistrement DNS ni Let's Encrypt : auto-signé.
|
||||||
FIN
|
FIN
|
||||||
|
|||||||
@@ -2,18 +2,16 @@ sonar.projectKey=ProjetPiscine_EnerVision
|
|||||||
sonar.organization=groupe3-ener-vision
|
sonar.organization=groupe3-ener-vision
|
||||||
sonar.sourceEncoding=UTF-8
|
sonar.sourceEncoding=UTF-8
|
||||||
|
|
||||||
# Dossier contenant le code source
|
|
||||||
sonar.sources=apps/frontend/src,apps/backend,ml,etl/airflow
|
sonar.sources=apps/frontend/src,apps/backend,ml,etl/airflow
|
||||||
# Dossier contenant les tests
|
|
||||||
sonar.tests=apps/frontend/src,apps/backend/tests,ml/tests,etl/airflow/tests
|
sonar.tests=apps/frontend/src,apps/backend/tests,ml/tests,etl/airflow/tests
|
||||||
sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py
|
sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/test_*.py,**/*_test.py
|
||||||
|
|
||||||
# Liste des fichiers et dossiers à exclure de l'analyse
|
# Piège : un motif sans `**` (`alembic`, `tests`) ne vise que la racine du dépôt. Les dossiers de
|
||||||
sonar.exclusions=.pytest_cache,.venv,.airflow_home,alembic,tests,ml/data/**,ml/models/**,ml/mlruns/**,ml/mlartifacts/**,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py,**/*.spec.ts
|
# tests restent donc exclus des sources ici, sans quoi leurs fixtures comptent en code non couvert.
|
||||||
|
sonar.exclusions=**/.pytest_cache/**,**/.venv/**,**/.airflow_home/**,**/node_modules/**,**/dist/**,**/build/**,**/htmlcov/**,**/coverage/**,**/tests/**,**/alembic/**,ml/data/**,ml/models/**,ml/mlruns/**,ml/mlartifacts/**,**/*.spec.ts,**/*.test.ts,**/test_*.py,**/*_test.py
|
||||||
|
|
||||||
# Chemin vers le rapport de couverture de code
|
# Rapports versés par les jobs de backend.yml, ml.yml et frontend.yml, repris par le job sonar de ci.yml.
|
||||||
# Fichier généré par Pytest
|
|
||||||
sonar.python.coverage.reportPaths=apps/backend/coverage.xml,ml/coverage.xml
|
sonar.python.coverage.reportPaths=apps/backend/coverage.xml,ml/coverage.xml
|
||||||
# Les DAGs n'ont pas de couverture mesurable : leurs tests ne font que les charger (DagBag)
|
|
||||||
sonar.coverage.exclusions=etl/airflow/**
|
|
||||||
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info
|
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info
|
||||||
|
# Les DAGs n'ont pas de couverture mesurable : leurs tests ne font que les charger (DagBag).
|
||||||
|
sonar.coverage.exclusions=etl/airflow/**
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Tests de bout en bout (Playwright)
|
||||||
|
|
||||||
|
Parcours utilisateur joués dans Chromium contre une stack qui tourne : frontend, API, base et,
|
||||||
|
en CI, le reverse proxy TLS. Issue #46, décisions dans l'ADR 0015.
|
||||||
|
|
||||||
|
## Ce qui est couvert
|
||||||
|
|
||||||
|
| Fichier | Parcours |
|
||||||
|
|---|---|
|
||||||
|
| `authentification.spec.ts` | Redirection sans session, identifiants refusés, connexion, session conservée au rechargement, déconnexion |
|
||||||
|
| `premiere-connexion.spec.ts` | Compte neuf : changement du mot de passe temporaire imposé avant le tableau de bord |
|
||||||
|
| `roles.spec.ts` | Lecteur et opérateur sans supervision ni génération, page admin refusée ; admin sur la santé des capteurs |
|
||||||
|
| `sites.spec.ts` | Liste des sites, détail (mesure instantanée, historique), recommandations filtrées sur le site |
|
||||||
|
| `recommandations.spec.ts` | Génération par l'admin, bilan, isolement d'une alerte (`?alert=`) |
|
||||||
|
| `alertes.spec.ts` | Pagination du fil (« Afficher plus »), filtres par sévérité et par site, fil vide |
|
||||||
|
| `mot-de-passe-oublie.spec.ts` | Lien invalide refusé ; réinitialisation par le lien reçu dans Mailpit |
|
||||||
|
|
||||||
|
Les parcours s'appuient sur le jeu `db/seeds/demo.sql` (sites `demo-*`) et sur les comptes créés
|
||||||
|
par `scripts/comptes-test.sh`.
|
||||||
|
|
||||||
|
## Lancer en local, contre `make dev`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make e2e-install # une fois : dépendances et Chromium
|
||||||
|
make dev # dans un autre terminal
|
||||||
|
make e2e-prepare # sème demo.sql et crée les comptes test-* dans la base de dev
|
||||||
|
make e2e # joue les parcours contre http://localhost:4200
|
||||||
|
```
|
||||||
|
|
||||||
|
`make e2e-prepare` écrit dans la base de `make dev` : trois sites `demo-*`, quatorze alertes et
|
||||||
|
des comptes `test-*`, dont un administrateur supplémentaire. Ne jamais le lancer contre la
|
||||||
|
recette ou la prod.
|
||||||
|
|
||||||
|
Rapport HTML : `cd tests/e2e && npx playwright show-report`.
|
||||||
|
|
||||||
|
## En CI
|
||||||
|
|
||||||
|
Le workflow `e2e.yml`, appelé par `ci.yml` dès que le frontend, l'API, le proxy, les fichiers
|
||||||
|
Compose ou ces tests changent :
|
||||||
|
|
||||||
|
1. construit et démarre `db`, `mailpit`, `backend`, `frontend` et `proxy` avec
|
||||||
|
`docker-compose.prod.yml`, sur `https://localhost` et un certificat auto-signé ;
|
||||||
|
2. migre, sème `demo.sql`, crée les comptes ;
|
||||||
|
3. joue les parcours, puis publie `playwright-report` en artefact (traces au premier réessai).
|
||||||
|
|
||||||
|
## Règles d'écriture
|
||||||
|
|
||||||
|
- Sélecteurs par rôle, libellé ou `data-testid`, jamais par classe CSS de mise en page.
|
||||||
|
- Une session par fichier (`ouvrirSession` dans `beforeAll`, mode `serial`). Rejouer un cookie
|
||||||
|
de refresh dans un autre contexte révoque la famille de session, donc pas de `storageState`
|
||||||
|
partagé.
|
||||||
|
- Un seul worker : la zone `auth` de nginx admet 30 connexions par minute.
|
||||||
|
- Un parcours qui consomme un compte (premier login, réinitialisation) le crée lui-même par
|
||||||
|
l'API (`creerCompteTemporaire`), pour qu'un nouvel essai ne retombe pas sur un compte déjà
|
||||||
|
activé.
|
||||||
|
|
||||||
|
| Variable | Défaut | Rôle |
|
||||||
|
|---|---|---|
|
||||||
|
| `E2E_BASE_URL` | `http://localhost:4200` | Origine du frontend. Doit figurer dans `APP_CORS_ORIGINS` |
|
||||||
|
| `E2E_COMPTES` | aucun, requis | JSON écrit par `scripts/comptes-test.sh` |
|
||||||
|
| `E2E_MAILPIT_URL` | `http://localhost:8025` | API de Mailpit, pour le lien de réinitialisation |
|
||||||
Generated
+454
@@ -0,0 +1,454 @@
|
|||||||
|
{
|
||||||
|
"name": "enervision-e2e",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "enervision-e2e",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.63.0",
|
||||||
|
"@types/node": "^26.6.2",
|
||||||
|
"typescript": "~7.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.63.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "26.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz",
|
||||||
|
"integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~8.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-darwin-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-arm": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-loong64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-s390x": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-sunos-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-win32-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-win32-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.63.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||||
|
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-arm": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-win32-x64": "7.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "8.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
||||||
|
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "enervision-e2e",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"test": "playwright test",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"report": "playwright show-report"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.63.0",
|
||||||
|
"@types/node": "^26.6.2",
|
||||||
|
"typescript": "~7.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
import { BASE_URL } from './support/environnement';
|
||||||
|
|
||||||
|
const enCi = Boolean(process.env.CI);
|
||||||
|
|
||||||
|
// Piège : un seul worker. La zone `auth` de nginx admet 30 connexions par minute, et deux
|
||||||
|
// fichiers en parallèle dépasseraient sa rafale de 20 dès le démarrage de la suite.
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './specs',
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
forbidOnly: enCi,
|
||||||
|
retries: enCi ? 1 : 0,
|
||||||
|
timeout: 30_000,
|
||||||
|
expect: { timeout: 10_000 },
|
||||||
|
reporter: enCi
|
||||||
|
? [['list'], ['github'], ['html', { open: 'never' }]]
|
||||||
|
: [['list'], ['html', { open: 'never' }]],
|
||||||
|
use: {
|
||||||
|
baseURL: BASE_URL,
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
locale: 'fr-FR',
|
||||||
|
timezoneId: 'Europe/Paris',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import { comptes } from '../support/comptes';
|
||||||
|
import { fermerSession, ouvrirSession } from '../support/session';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
let page: Page;
|
||||||
|
|
||||||
|
const fil = () => page.getByRole('complementary', { name: 'Alertes actives' });
|
||||||
|
const alertes = () => fil().locator('li.alert-feed__item');
|
||||||
|
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
page = await ouvrirSession(browser, comptes.lecteur);
|
||||||
|
});
|
||||||
|
test.afterAll(async () => fermerSession(page));
|
||||||
|
|
||||||
|
test('affiche dix alertes, puis les suivantes à la demande', async () => {
|
||||||
|
await expect(alertes()).toHaveCount(10);
|
||||||
|
const plus = fil().getByTestId('show-more');
|
||||||
|
await expect(plus).toBeVisible();
|
||||||
|
|
||||||
|
await plus.click();
|
||||||
|
|
||||||
|
await expect.poll(async () => alertes().count()).toBeGreaterThan(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filtre par sévérité', async () => {
|
||||||
|
await fil().getByTestId('severity-filter').selectOption('critical');
|
||||||
|
|
||||||
|
await expect(alertes().first()).toBeVisible();
|
||||||
|
const severites = await alertes().evaluateAll((items) =>
|
||||||
|
items.map((item) => item.className.includes('alert-feed__item--critical')),
|
||||||
|
);
|
||||||
|
expect(severites.every(Boolean)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('annonce un fil vide quand aucun critère ne correspond', async () => {
|
||||||
|
await fil().getByTestId('site-filter').selectOption('demo-ecole');
|
||||||
|
|
||||||
|
await expect(fil().getByText('Aucune alerte pour ces critères.')).toBeVisible();
|
||||||
|
await expect(alertes()).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { comptes } from '../support/comptes';
|
||||||
|
import { seConnecter } from '../support/session';
|
||||||
|
|
||||||
|
test('renvoie vers la connexion sans session', async ({ page }) => {
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Connexion' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuse des identifiants incorrects', async ({ page }) => {
|
||||||
|
await seConnecter(page, { email: comptes.lecteur.email, password: 'Mauvais-mot-de-passe-1!' });
|
||||||
|
|
||||||
|
await expect(page.getByRole('alert')).toContainText('Email ou mot de passe incorrect.');
|
||||||
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ouvre le tableau de bord, garde la session au rechargement puis la ferme', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await seConnecter(page, comptes.lecteur);
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
await expect(page.getByRole('heading', { name: "Vue d'ensemble" })).toBeVisible();
|
||||||
|
await expect(page.getByRole('region', { name: 'Indicateurs du parc' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('complementary', { name: 'Alertes actives' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole('heading', { name: "Vue d'ensemble" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Déconnexion' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { creerCompteTemporaire, nouveauMotDePasse } from '../support/api';
|
||||||
|
import { jetonDeReinitialisation } from '../support/mailpit';
|
||||||
|
|
||||||
|
test('refuse un lien de réinitialisation invalide', async ({ page }) => {
|
||||||
|
await page.goto('/reset-password?token=lien-invalide');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login\?motif=lien-expire$/);
|
||||||
|
await expect(
|
||||||
|
page.getByText('Ce lien de réinitialisation est invalide ou a expiré.'),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('réinitialise le mot de passe par le lien reçu par courriel', async ({ page }) => {
|
||||||
|
const compte = await creerCompteTemporaire('lecteur');
|
||||||
|
|
||||||
|
await page.goto('/forgot-password');
|
||||||
|
await page.getByLabel('Email').fill(compte.email);
|
||||||
|
await page.getByRole('button', { name: 'Envoyer le lien' }).click();
|
||||||
|
await expect(page.getByText('Si un compte existe pour cet email')).toBeVisible();
|
||||||
|
|
||||||
|
const jeton = await jetonDeReinitialisation(compte.email);
|
||||||
|
await page.goto(`/reset-password?token=${encodeURIComponent(jeton)}`);
|
||||||
|
await page.getByLabel('Nouveau mot de passe').fill(nouveauMotDePasse());
|
||||||
|
await page.getByRole('button', { name: 'Valider' }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
await expect(page.getByRole('heading', { name: "Vue d'ensemble" })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { creerCompteTemporaire, nouveauMotDePasse } from '../support/api';
|
||||||
|
import { seConnecter } from '../support/session';
|
||||||
|
|
||||||
|
test('impose le changement du mot de passe temporaire avant le tableau de bord', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const compte = await creerCompteTemporaire('lecteur');
|
||||||
|
|
||||||
|
await seConnecter(page, compte);
|
||||||
|
await expect(page).toHaveURL(/\/change-password$/);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Nouveau mot de passe' })).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByLabel('Mot de passe actuel')).toHaveCount(0);
|
||||||
|
await page.getByLabel('Nouveau mot de passe').fill(nouveauMotDePasse());
|
||||||
|
await page.getByRole('button', { name: 'Valider' }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
await expect(page.getByRole('heading', { name: "Vue d'ensemble" })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import { comptes } from '../support/comptes';
|
||||||
|
import { fermerSession, ouvrirSession } from '../support/session';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
let page: Page;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
page = await ouvrirSession(browser, comptes.admin);
|
||||||
|
});
|
||||||
|
test.afterAll(async () => fermerSession(page));
|
||||||
|
|
||||||
|
test('génère les recommandations à partir des alertes', async () => {
|
||||||
|
await page.goto('/recommendations');
|
||||||
|
await page.getByTestId('generate').click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('alert').filter({ hasText: /alertes? examinées?/ })).toBeVisible();
|
||||||
|
await expect(page.locator('[id^="alerte-"]').first()).toBeVisible();
|
||||||
|
await expect(page.locator('.reco__action').first()).not.toBeEmpty();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isole les recommandations d'une alerte", async () => {
|
||||||
|
const carte = page.locator('[id^="alerte-"]').first();
|
||||||
|
const identifiant = (await carte.getAttribute('id'))?.replace('alerte-', '');
|
||||||
|
expect(identifiant).toBeTruthy();
|
||||||
|
|
||||||
|
await page.goto(`/recommendations?alert=${identifiant}`);
|
||||||
|
|
||||||
|
await expect(page.getByText(`Alerte n° ${identifiant}`)).toBeVisible();
|
||||||
|
await expect(page.locator(`#alerte-${identifiant}`)).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import { comptes } from '../support/comptes';
|
||||||
|
import { fermerSession, ouvrirSession } from '../support/session';
|
||||||
|
|
||||||
|
test.describe('lecteur', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
let page: Page;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
page = await ouvrirSession(browser, comptes.lecteur);
|
||||||
|
});
|
||||||
|
test.afterAll(async () => fermerSession(page));
|
||||||
|
|
||||||
|
test('ne voit ni la supervision des capteurs ni la génération', async () => {
|
||||||
|
await expect(page.getByRole('link', { name: 'Voir les sites' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Supervision des capteurs' })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'Recommandations', exact: true }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Recommandations' })).toBeVisible();
|
||||||
|
await expect(page.getByTestId('generate')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('est renvoyé vers la connexion sur la page réservée aux administrateurs', async () => {
|
||||||
|
await page.goto('/monitoring/sensors');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('opérateur', () => {
|
||||||
|
test('voit le tableau de bord sans la supervision des capteurs', async ({ browser }) => {
|
||||||
|
const page = await ouvrirSession(browser, comptes.operateur);
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: "Vue d'ensemble" })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Supervision des capteurs' })).toHaveCount(0);
|
||||||
|
|
||||||
|
await fermerSession(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('administrateur', () => {
|
||||||
|
test('suit la santé des capteurs de chaque site', async ({ browser }) => {
|
||||||
|
const page = await ouvrirSession(browser, comptes.admin);
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'Supervision des capteurs' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Supervision des capteurs' })).toBeVisible();
|
||||||
|
|
||||||
|
const cartes = page.getByTestId('site-card');
|
||||||
|
await expect(cartes.filter({ hasText: 'Siège Part-Dieu' })).toContainText('ok');
|
||||||
|
await expect(cartes.filter({ hasText: 'Groupe scolaire Gratte-Ciel' })).toContainText(
|
||||||
|
'degraded',
|
||||||
|
);
|
||||||
|
|
||||||
|
await fermerSession(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import { comptes } from '../support/comptes';
|
||||||
|
import { fermerSession, ouvrirSession } from '../support/session';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
let page: Page;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
page = await ouvrirSession(browser, comptes.lecteur);
|
||||||
|
});
|
||||||
|
test.afterAll(async () => fermerSession(page));
|
||||||
|
|
||||||
|
test('liste les sites du parc', async () => {
|
||||||
|
await page.getByRole('link', { name: 'Voir les sites' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sites' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('row', { name: /Siège Part-Dieu/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('détaille un site : mesure instantanée et historique', async () => {
|
||||||
|
await page
|
||||||
|
.getByRole('row', { name: /Siège Part-Dieu/ })
|
||||||
|
.getByRole('link', { name: 'Détail' })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/sites\/demo-siege$/);
|
||||||
|
await expect(page.getByRole('heading', { level: 1, name: 'Siège Part-Dieu' })).toBeVisible();
|
||||||
|
await expect(page.getByText('Mesure instantanée')).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Historique de consommation' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ouvre les recommandations filtrées sur ce site', async () => {
|
||||||
|
await page.getByRole('link', { name: 'Voir dans la vue recommandations' }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/recommendations\?site=demo-siege$/);
|
||||||
|
await expect(page.getByTestId('site-filter')).toHaveValue('demo-siege');
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { request } from '@playwright/test';
|
||||||
|
|
||||||
|
import { comptes, type Compte } from './comptes';
|
||||||
|
import { API_URL } from './environnement';
|
||||||
|
|
||||||
|
// Crée par l'API un compte neuf, encore sur son mot de passe temporaire. Un compte par appel :
|
||||||
|
// un nouvel essai après échec ne retombe jamais sur un compte déjà activé.
|
||||||
|
export async function creerCompteTemporaire(role: 'lecteur' | 'operateur'): Promise<Compte> {
|
||||||
|
const api = await request.newContext({ baseURL: API_URL, ignoreHTTPSErrors: true });
|
||||||
|
try {
|
||||||
|
const connexion = await api.post('auth/login', { data: comptes.admin });
|
||||||
|
if (!connexion.ok()) {
|
||||||
|
throw new Error(`Connexion administrateur refusée : ${connexion.status()}`);
|
||||||
|
}
|
||||||
|
const { access_token: jeton } = (await connexion.json()) as { access_token: string };
|
||||||
|
|
||||||
|
const email = `e2e-${role}-${Date.now()}@enervision.fr`;
|
||||||
|
const creation = await api.post('users', {
|
||||||
|
data: { email, role },
|
||||||
|
headers: { Authorization: `Bearer ${jeton}` },
|
||||||
|
});
|
||||||
|
if (!creation.ok()) {
|
||||||
|
throw new Error(`Création du compte refusée : ${creation.status()}`);
|
||||||
|
}
|
||||||
|
const { temporary_password: password } = (await creation.json()) as {
|
||||||
|
temporary_password: string;
|
||||||
|
};
|
||||||
|
return { email, password };
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nouveauMotDePasse(): string {
|
||||||
|
return `E2e-${Math.random().toString(36).slice(2, 14)}-Aa1!`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
export interface Compte {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComptesDeTest {
|
||||||
|
admin: Compte;
|
||||||
|
lecteur: Compte;
|
||||||
|
operateur: Compte;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lireComptes(): ComptesDeTest {
|
||||||
|
const chemin = process.env.E2E_COMPTES;
|
||||||
|
if (!chemin) {
|
||||||
|
throw new Error('E2E_COMPTES doit désigner le JSON écrit par scripts/comptes-test.sh');
|
||||||
|
}
|
||||||
|
return JSON.parse(readFileSync(chemin, 'utf-8')) as ComptesDeTest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const comptes = lireComptes();
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:4200';
|
||||||
|
export const API_URL = new URL('/api/v1/', BASE_URL).toString();
|
||||||
|
export const MAILPIT_URL = process.env.E2E_MAILPIT_URL ?? 'http://localhost:8025';
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { expect, request } from '@playwright/test';
|
||||||
|
|
||||||
|
import { MAILPIT_URL } from './environnement';
|
||||||
|
|
||||||
|
interface Recherche {
|
||||||
|
messages: { ID: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le courriel part en tâche de fond après la réponse de l'API : on l'attend dans Mailpit.
|
||||||
|
export async function jetonDeReinitialisation(email: string): Promise<string> {
|
||||||
|
const mailpit = await request.newContext({ baseURL: MAILPIT_URL });
|
||||||
|
try {
|
||||||
|
let identifiant = '';
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const reponse = await mailpit.get('/api/v1/search', { params: { query: `to:${email}` } });
|
||||||
|
const { messages } = (await reponse.json()) as Recherche;
|
||||||
|
identifiant = messages[0]?.ID ?? '';
|
||||||
|
return identifiant;
|
||||||
|
},
|
||||||
|
{ message: `aucun courriel reçu pour ${email}`, timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.not.toBe('');
|
||||||
|
|
||||||
|
const message = (await (await mailpit.get(`/api/v1/message/${identifiant}`)).json()) as {
|
||||||
|
Text: string;
|
||||||
|
};
|
||||||
|
const jeton = /reset-password\?token=([^\s"<>&]+)/.exec(message.Text)?.[1];
|
||||||
|
if (!jeton) {
|
||||||
|
throw new Error('lien de réinitialisation introuvable dans le courriel');
|
||||||
|
}
|
||||||
|
return decodeURIComponent(jeton);
|
||||||
|
} finally {
|
||||||
|
await mailpit.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { expect, type Browser, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import type { Compte } from './comptes';
|
||||||
|
import { BASE_URL } from './environnement';
|
||||||
|
|
||||||
|
export async function seConnecter(page: Page, compte: Compte): Promise<void> {
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.getByLabel('Email').fill(compte.email);
|
||||||
|
await page.getByLabel('Mot de passe').fill(compte.password);
|
||||||
|
await page.getByRole('button', { name: 'Se connecter' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pourquoi : une session par fichier, pas par test. Rejouer un même cookie de refresh dans deux
|
||||||
|
// contextes révoque toute sa famille (ADR 0002), d'où ni `storageState` partagé ni reconnexions.
|
||||||
|
export async function ouvrirSession(browser: Browser, compte: Compte): Promise<Page> {
|
||||||
|
const contexte = await browser.newContext({
|
||||||
|
baseURL: BASE_URL,
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
locale: 'fr-FR',
|
||||||
|
timezoneId: 'Europe/Paris',
|
||||||
|
});
|
||||||
|
const page = await contexte.newPage();
|
||||||
|
await seConnecter(page, compte);
|
||||||
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fermerSession(page: Page): Promise<void> {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Tests de charge (k6)
|
||||||
|
|
||||||
|
Scénarios k6 contre l'API EnerVision. Issue #47, décisions dans l'ADR 0015.
|
||||||
|
|
||||||
|
## Scénarios
|
||||||
|
|
||||||
|
| Script | Cible | Profil | Quand |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `smoke.js` | API directe | 2 utilisateurs pendant 1 min, chaque route de lecture | Chaque PR (job E2E), `make load-smoke` |
|
||||||
|
| `charge.js` | API directe | 50 utilisateurs simultanés : montée 2 min, plateau 5 min, descente 1 min | `make load-test`, avant une livraison |
|
||||||
|
| `stress.js` | API directe | Débit croissant de 5 à 200 req/s, arrêt au-delà de 10 % d'erreurs | `make load-stress`, pour situer la rupture |
|
||||||
|
| `limitation-debit.js` | Proxy nginx | 80 req/s pendant 15 s depuis une seule adresse | Chaque PR (job E2E), `make load-limits` |
|
||||||
|
|
||||||
|
### Hypothèses de charge
|
||||||
|
|
||||||
|
Le cahier des charges ne chiffre ni volume d'utilisateurs ni temps de réponse. Les hypothèses
|
||||||
|
retenues :
|
||||||
|
|
||||||
|
- **50 utilisateurs simultanés.** C'est un gestionnaire d'énergie par site suivi, plus
|
||||||
|
l'exploitation, avec de la marge.
|
||||||
|
- **40 gardent le tableau de bord ouvert.** Le frontend interroge `/stats/summary` toutes les
|
||||||
|
10 s et `/alerts` toutes les 60 s, et charge `/predictions` une fois (`dashboard.ts`,
|
||||||
|
`alert-feed.ts`).
|
||||||
|
- **10 explorent les sites.** Ils ouvrent la liste, puis un détail avec sa mesure courante, ses
|
||||||
|
relevés sur 24 h et ses recommandations, avec 3 à 8 s de lecture entre deux pages.
|
||||||
|
|
||||||
|
Soit environ 12 req/s en régime établi, avec des pointes au démarrage des sessions.
|
||||||
|
|
||||||
|
### Seuils
|
||||||
|
|
||||||
|
Les seuils sont posés par l'ADR 0015 et partagés par `lib/config.js`.
|
||||||
|
|
||||||
|
| Seuil | Valeur | Raison |
|
||||||
|
|---|---|---|
|
||||||
|
| `http_req_failed` | < 1 % | Aucune erreur attendue en charge nominale |
|
||||||
|
| `checks` | > 99 % | Chaque réponse est un 200 |
|
||||||
|
| Lectures, p95 | < 500 ms | Rafraîchissement du tableau de bord imperceptible |
|
||||||
|
| Lectures, p99 | < 1 s | Borne des cas les plus lents |
|
||||||
|
| `/stats/summary`, p95 | < 500 ms | Route la plus appelée (`DISTINCT ON` sur tout l'historique) |
|
||||||
|
| `/readings`, p95 | < 800 ms | Fenêtre de 24 h, la plus volumineuse |
|
||||||
|
|
||||||
|
Un seuil franchi fait échouer le tir (code de sortie 99). Le test de stress n'a qu'un seuil
|
||||||
|
bloquant, 10 % d'erreurs : il cherche la rupture, pas un niveau de service.
|
||||||
|
|
||||||
|
## Pourquoi k6 ne passe pas par le proxy
|
||||||
|
|
||||||
|
nginx limite chaque adresse IP à 20 req/s, avec une rafale de 40 (ADR 0007). Un tir depuis une
|
||||||
|
seule machine mesurerait cette limite, pas l'API.
|
||||||
|
|
||||||
|
k6 tourne donc en service Compose (profil `load`) sur le réseau du projet, et vise
|
||||||
|
`backend:8000`. Seul `limitation-debit.js` passe par `https://proxy`, précisément pour vérifier
|
||||||
|
que la limite tient : des 429 au-delà du débit autorisé, jamais d'erreur serveur.
|
||||||
|
|
||||||
|
## Lancer un tir
|
||||||
|
|
||||||
|
Il faut une stack démarrée, des données et un compte `lecteur` **déjà activé** (mot de passe
|
||||||
|
définitif).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Poste, stack de `make dev` (API sur l'hôte) : jeu de démonstration et comptes de test
|
||||||
|
make e2e-prepare
|
||||||
|
make load-smoke K6_BASE_URL=http://host.docker.internal:8000 \
|
||||||
|
K6_EMAIL="$(jq -r .lecteur.email tests/e2e/.comptes.json)" \
|
||||||
|
K6_PASSWORD="$(jq -r .lecteur.password tests/e2e/.comptes.json)"
|
||||||
|
|
||||||
|
# Poste, stack conteneurisée (make stack-up) : l'API est joignable en backend:8000
|
||||||
|
make load-test K6_EMAIL=... K6_PASSWORD=...
|
||||||
|
|
||||||
|
# Recette, sur la VM, dans /srv/enervision/rec : compte lecteur créé depuis l'interface
|
||||||
|
make load-test K6_EMAIL=charge@enervision.fr K6_PASSWORD=...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recette et prod partagent la VM** (ADR 0009). `make load-test` y reste raisonnable.
|
||||||
|
`make load-stress` sature l'hôte et ralentit la prod : ne le lancer qu'en accord avec l'équipe,
|
||||||
|
hors démonstration.
|
||||||
|
|
||||||
|
`scripts/comptes-test.sh` ne sert qu'aux bases jetables. En recette, un administrateur crée le
|
||||||
|
compte `lecteur` du tir depuis l'interface, puis on s'y connecte une fois pour changer le mot de
|
||||||
|
passe temporaire.
|
||||||
|
|
||||||
|
## Lire les résultats
|
||||||
|
|
||||||
|
Chaque tir écrit dans `tests/load/results/` (ignoré par git) :
|
||||||
|
|
||||||
|
- `<scénario>-<horodatage>.html` : rapport du tableau de bord web de k6 (courbes de débit, de
|
||||||
|
latence et de VUs), à joindre au dossier de preuves ;
|
||||||
|
- `<scénario>-<horodatage>.md` : synthèse et état de chaque seuil, aussi affichée en console et,
|
||||||
|
en CI, dans le résumé du job ;
|
||||||
|
- `<scénario>-<horodatage>.json` : métriques brutes.
|
||||||
|
|
||||||
|
Pendant un tir, le tableau de bord Grafana « API » (profil `monitoring`) montre le débit et les
|
||||||
|
latences vus par l'API elle-même.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Charge nominale : 50 utilisateurs simultanés. 40 gardent le tableau de bord ouvert, qui
|
||||||
|
// interroge l'API au rythme du frontend ; 10 explorent les sites.
|
||||||
|
import { sleep } from 'k6';
|
||||||
|
|
||||||
|
import { auHasard, consulterSite, lire, preparer } from './lib/api.js';
|
||||||
|
import { SEUILS, STATISTIQUES } from './lib/config.js';
|
||||||
|
import { rapport } from './lib/rapport.js';
|
||||||
|
|
||||||
|
const paliers = (cible) => [
|
||||||
|
{ duration: '2m', target: cible },
|
||||||
|
{ duration: '5m', target: cible },
|
||||||
|
{ duration: '1m', target: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
insecureSkipTLSVerify: true,
|
||||||
|
scenarios: {
|
||||||
|
tableau_de_bord: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
stages: paliers(40),
|
||||||
|
exec: 'tableauDeBord',
|
||||||
|
gracefulRampDown: '30s',
|
||||||
|
},
|
||||||
|
exploration: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
stages: paliers(10),
|
||||||
|
exec: 'exploration',
|
||||||
|
gracefulRampDown: '30s',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: SEUILS,
|
||||||
|
summaryTrendStats: STATISTIQUES,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setup = preparer;
|
||||||
|
|
||||||
|
// Une itération vaut une minute d'écran ouvert : `/stats/summary` toutes les 10 s, les alertes
|
||||||
|
// toutes les 60 s, les prévisions une fois (dashboard.ts, alert-feed.ts).
|
||||||
|
export function tableauDeBord(data) {
|
||||||
|
lire('/predictions', 'predictions', data.jeton);
|
||||||
|
lire('/alerts', 'alerts', data.jeton);
|
||||||
|
for (let i = 0; i < 6; i += 1) {
|
||||||
|
lire('/stats/summary', 'stats_summary', data.jeton);
|
||||||
|
sleep(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exploration(data) {
|
||||||
|
lire('/sites', 'sites', data.jeton);
|
||||||
|
consulterSite(auHasard(data.sites), data.jeton);
|
||||||
|
sleep(3 + Math.random() * 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleSummary(data) {
|
||||||
|
return rapport('charge', data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { check, fail } from 'k6';
|
||||||
|
import http from 'k6/http';
|
||||||
|
|
||||||
|
import { API, COMPTE } from './config.js';
|
||||||
|
|
||||||
|
export function connecter() {
|
||||||
|
if (!COMPTE.email || !COMPTE.password) {
|
||||||
|
fail('K6_EMAIL et K6_PASSWORD désignent un compte lecteur déjà activé');
|
||||||
|
}
|
||||||
|
const reponse = http.post(`${API}/auth/login`, JSON.stringify(COMPTE), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
tags: { name: 'auth_login', type: 'auth' },
|
||||||
|
});
|
||||||
|
if (!check(reponse, { 'connexion acceptée': (r) => r.status === 200 })) {
|
||||||
|
fail(`connexion refusée : ${reponse.status} ${reponse.body}`);
|
||||||
|
}
|
||||||
|
return reponse.json('access_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jeton propre à chaque VU, repris sur un 401 : un tir plus long que le TTL du jeton (15 min)
|
||||||
|
// ne doit pas finir en erreurs. Jamais de refresh, dont la rotation révoquerait la session.
|
||||||
|
let jetonDuVu = null;
|
||||||
|
|
||||||
|
export function lire(chemin, nom, jetonInitial) {
|
||||||
|
const envoyer = () =>
|
||||||
|
http.get(`${API}${chemin}`, {
|
||||||
|
headers: { Authorization: `Bearer ${jetonDuVu || jetonInitial}` },
|
||||||
|
tags: { name: nom, type: 'lecture' },
|
||||||
|
});
|
||||||
|
let reponse = envoyer();
|
||||||
|
if (reponse.status === 401) {
|
||||||
|
jetonDuVu = connecter();
|
||||||
|
reponse = envoyer();
|
||||||
|
}
|
||||||
|
check(reponse, { [`${nom} répond 200`]: (r) => r.status === 200 });
|
||||||
|
return reponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preparer() {
|
||||||
|
const jeton = connecter();
|
||||||
|
const sites = lire('/sites', 'sites', jeton).json();
|
||||||
|
if (!Array.isArray(sites) || sites.length === 0) {
|
||||||
|
fail('aucun site en base : semer db/seeds/demo.sql ou importer des relevés avant le tir');
|
||||||
|
}
|
||||||
|
return { jeton, sites: sites.map((site) => site.site_id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function auHasard(liste) {
|
||||||
|
return liste[Math.floor(Math.random() * liste.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Même fenêtre que le détail d'un site dans le frontend : les 24 heures qui précèdent sa
|
||||||
|
// dernière mesure, pas celles qui précèdent l'instant présent.
|
||||||
|
export function consulterSite(siteId, jeton) {
|
||||||
|
lire(`/sites/${siteId}`, 'site', jeton);
|
||||||
|
const courant = lire(`/sites/${siteId}/current`, 'site_current', jeton);
|
||||||
|
const horodatage = courant.status === 200 ? courant.json('timestamp') : null;
|
||||||
|
const fin = horodatage ? new Date(horodatage) : new Date();
|
||||||
|
const debut = new Date(fin.getTime() - 24 * 3600 * 1000);
|
||||||
|
const fenetre = `start=${encodeURIComponent(debut.toISOString())}&end=${encodeURIComponent(fin.toISOString())}`;
|
||||||
|
lire(`/readings?site_id=${siteId}&${fenetre}`, 'readings', jeton);
|
||||||
|
lire(`/recommendations?site_id=${siteId}`, 'recommendations', jeton);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export const BASE_URL = __ENV.K6_BASE_URL || 'http://backend:8000';
|
||||||
|
export const API = `${BASE_URL}/api/v1`;
|
||||||
|
export const PROXY_URL = __ENV.K6_PROXY_URL || 'https://proxy';
|
||||||
|
|
||||||
|
export const COMPTE = {
|
||||||
|
email: __ENV.K6_EMAIL,
|
||||||
|
password: __ENV.K6_PASSWORD,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seuils posés par l'ADR 0015, faute d'exigence chiffrée dans le cahier des charges.
|
||||||
|
export const SEUILS = {
|
||||||
|
http_req_failed: ['rate<0.01'],
|
||||||
|
checks: ['rate>0.99'],
|
||||||
|
'http_req_duration{type:lecture}': ['p(95)<500', 'p(99)<1000'],
|
||||||
|
'http_req_duration{name:stats_summary}': ['p(95)<500'],
|
||||||
|
'http_req_duration{name:readings}': ['p(95)<800'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATISTIQUES = ['avg', 'min', 'med', 'p(90)', 'p(95)', 'p(99)', 'max'];
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export function rapport(nom, data) {
|
||||||
|
const horodatage = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const markdown = enMarkdown(nom, data);
|
||||||
|
const sorties = {
|
||||||
|
stdout: `${markdown}\n`,
|
||||||
|
[`/results/${nom}-${horodatage}.json`]: JSON.stringify(data, null, 2),
|
||||||
|
[`/results/${nom}-${horodatage}.md`]: markdown,
|
||||||
|
};
|
||||||
|
if (__ENV.K6_RESUME) {
|
||||||
|
sorties[__ENV.K6_RESUME] = markdown;
|
||||||
|
}
|
||||||
|
return sorties;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valeur(metrique, statistique, unite) {
|
||||||
|
const brut = metrique?.values?.[statistique];
|
||||||
|
if (brut === undefined) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
return unite === '%' ? `${(brut * 100).toFixed(2)} %` : `${brut.toFixed(1)} ${unite}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enMarkdown(nom, data) {
|
||||||
|
const m = data.metrics;
|
||||||
|
const lignes = [
|
||||||
|
`### Tir k6 « ${nom} »`,
|
||||||
|
'',
|
||||||
|
'| Mesure | Valeur |',
|
||||||
|
'|---|---|',
|
||||||
|
`| Requêtes | ${m.http_reqs?.values?.count ?? 0} (${valeur(m.http_reqs, 'rate', 'req/s')}) |`,
|
||||||
|
`| Échecs HTTP | ${valeur(m.http_req_failed, 'rate', '%')} |`,
|
||||||
|
`| Vérifications réussies | ${valeur(m.checks, 'rate', '%')} |`,
|
||||||
|
`| Durée médiane | ${valeur(m.http_req_duration, 'med', 'ms')} |`,
|
||||||
|
`| Durée p95 | ${valeur(m.http_req_duration, 'p(95)', 'ms')} |`,
|
||||||
|
`| Durée p99 | ${valeur(m.http_req_duration, 'p(99)', 'ms')} |`,
|
||||||
|
`| VUs au plus haut | ${m.vus_max?.values?.max ?? '-'} |`,
|
||||||
|
'',
|
||||||
|
'| Seuil | Résultat |',
|
||||||
|
'|---|---|',
|
||||||
|
];
|
||||||
|
for (const [metrique, detail] of Object.entries(m)) {
|
||||||
|
for (const [expression, resultat] of Object.entries(detail.thresholds || {})) {
|
||||||
|
lignes.push(`| \`${metrique}\` ${expression} | ${resultat.ok ? 'tenu' : '**franchi**'} |`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lignes.join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Vérifie par le proxy que la limitation de nginx tient : au-delà de 20 requêtes par seconde et
|
||||||
|
// de la rafale de 40, une même adresse doit recevoir des 429, et jamais une erreur serveur.
|
||||||
|
import { check } from 'k6';
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { Counter } from 'k6/metrics';
|
||||||
|
|
||||||
|
import { PROXY_URL, STATISTIQUES } from './lib/config.js';
|
||||||
|
import { rapport } from './lib/rapport.js';
|
||||||
|
|
||||||
|
const limitees = new Counter('reponses_limitees');
|
||||||
|
|
||||||
|
http.setResponseCallback(http.expectedStatuses(200, 429));
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
insecureSkipTLSVerify: true,
|
||||||
|
scenarios: {
|
||||||
|
rafale: {
|
||||||
|
executor: 'constant-arrival-rate',
|
||||||
|
rate: 80,
|
||||||
|
timeUnit: '1s',
|
||||||
|
duration: '15s',
|
||||||
|
preAllocatedVUs: 20,
|
||||||
|
maxVUs: 80,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
reponses_limitees: ['count>0'],
|
||||||
|
http_req_failed: ['rate<0.01'],
|
||||||
|
},
|
||||||
|
summaryTrendStats: STATISTIQUES,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const reponse = http.get(`${PROXY_URL}/api/v1/health/live`, { tags: { name: 'health_live' } });
|
||||||
|
if (reponse.status === 429) {
|
||||||
|
limitees.add(1);
|
||||||
|
}
|
||||||
|
check(reponse, { 'servie ou limitée, jamais en erreur': (r) => [200, 429].includes(r.status) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleSummary(data) {
|
||||||
|
return rapport('limitation-debit', data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { sleep } from 'k6';
|
||||||
|
|
||||||
|
import { auHasard, consulterSite, lire, preparer } from './lib/api.js';
|
||||||
|
import { SEUILS, STATISTIQUES } from './lib/config.js';
|
||||||
|
import { rapport } from './lib/rapport.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
insecureSkipTLSVerify: true,
|
||||||
|
scenarios: {
|
||||||
|
smoke: { executor: 'constant-vus', vus: 2, duration: '1m' },
|
||||||
|
},
|
||||||
|
thresholds: SEUILS,
|
||||||
|
summaryTrendStats: STATISTIQUES,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setup = preparer;
|
||||||
|
|
||||||
|
export default function (data) {
|
||||||
|
lire('/stats/summary', 'stats_summary', data.jeton);
|
||||||
|
lire('/predictions', 'predictions', data.jeton);
|
||||||
|
lire('/alerts', 'alerts', data.jeton);
|
||||||
|
lire('/sites', 'sites', data.jeton);
|
||||||
|
consulterSite(auHasard(data.sites), data.jeton);
|
||||||
|
sleep(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleSummary(data) {
|
||||||
|
return rapport('smoke', data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Monte le débit jusqu'à la rupture pour situer la limite de l'API. S'arrête de lui-même au-delà
|
||||||
|
// de 10 % d'erreurs : le but est de trouver le point de rupture, pas de s'y maintenir.
|
||||||
|
import { auHasard, consulterSite, lire, preparer } from './lib/api.js';
|
||||||
|
import { STATISTIQUES } from './lib/config.js';
|
||||||
|
import { rapport } from './lib/rapport.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
insecureSkipTLSVerify: true,
|
||||||
|
scenarios: {
|
||||||
|
stress: {
|
||||||
|
executor: 'ramping-arrival-rate',
|
||||||
|
startRate: 5,
|
||||||
|
timeUnit: '1s',
|
||||||
|
preAllocatedVUs: 50,
|
||||||
|
maxVUs: 400,
|
||||||
|
stages: [
|
||||||
|
{ duration: '2m', target: 25 },
|
||||||
|
{ duration: '2m', target: 50 },
|
||||||
|
{ duration: '2m', target: 100 },
|
||||||
|
{ duration: '2m', target: 200 },
|
||||||
|
{ duration: '1m', target: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
http_req_failed: [{ threshold: 'rate<0.10', abortOnFail: true, delayAbortEval: '30s' }],
|
||||||
|
'http_req_duration{type:lecture}': ['p(95)<2000'],
|
||||||
|
},
|
||||||
|
summaryTrendStats: STATISTIQUES,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setup = preparer;
|
||||||
|
|
||||||
|
export default function (data) {
|
||||||
|
if (Math.random() < 0.6) {
|
||||||
|
lire('/stats/summary', 'stats_summary', data.jeton);
|
||||||
|
} else {
|
||||||
|
consulterSite(auHasard(data.sites), data.jeton);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleSummary(data) {
|
||||||
|
return rapport('stress', data);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user