Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f011fce84e | ||
|
|
7730f184e7 | ||
|
|
fa815f49b6 | ||
|
|
0462dd01ba | ||
|
|
e53c7e441c | ||
|
|
0a7a802b38 | ||
|
|
e0089537d0 | ||
|
|
6b41ec900c | ||
|
|
49175d8ff7 | ||
|
|
da7fc52299 | ||
|
|
6c09beeb3c | ||
|
|
14eed08ff5 | ||
|
|
1232646f68 | ||
|
|
cbbfaf4910 | ||
|
|
b78322bd61 | ||
|
|
101ebd404f | ||
|
|
b00c39277b | ||
|
|
c2f360c591 | ||
|
|
933f0a3360 | ||
|
|
dbcd5c4240 | ||
|
|
46d10209f1 | ||
|
|
db6ee6e56d | ||
|
|
84969d3375 | ||
|
|
93d5cad5af | ||
|
|
4d88604a07 | ||
|
|
22a88e193c | ||
|
|
d687d7dc58 | ||
|
|
288970df77 | ||
|
|
985c188106 | ||
|
|
b786f27a4d | ||
|
|
3e871a3e8b | ||
|
|
fe0d4222a5 | ||
|
|
30bb3b838c | ||
|
|
c3ec8b79c7 | ||
|
|
7644bf49ad | ||
|
|
3ca4839a02 | ||
|
|
10cc408b03 | ||
|
|
c7744483b4 | ||
|
|
ac05da7001 | ||
|
|
0284cc0cd8 | ||
|
|
dc952d13aa | ||
|
|
cfc194a3fb | ||
|
|
2ed9e1cee4 | ||
|
|
a197af91ff | ||
|
|
a646635b4c | ||
|
|
d54cd963f1 | ||
|
|
18307e9be3 | ||
|
|
59f050ec5e | ||
|
|
6e9c830557 | ||
|
|
5a3c526856 | ||
|
|
314e3b72c0 | ||
|
|
e118c008bf | ||
|
|
d86224a0f7 | ||
|
|
f21a843fc2 | ||
|
|
6b3908d321 | ||
|
|
b16861e211 | ||
|
|
57b9735804 | ||
|
|
c007ea01bd | ||
|
|
5472b19504 | ||
|
|
44163bfb98 | ||
|
|
ea8f9d0a3a | ||
|
|
f58fc4ba81 | ||
|
|
2c7ee2c064 | ||
|
|
e220f8f0c6 | ||
|
|
d9103ee4ed | ||
|
|
3ddeb24207 | ||
|
|
8760ebc701 | ||
|
|
a013dfa87f | ||
|
|
6d741e45fb | ||
|
|
1bec2c1376 | ||
|
|
00fab49d80 | ||
|
|
de88c4f156 | ||
|
|
67dcf506e9 | ||
|
|
9cd4f0de1c | ||
|
|
5cc99178c2 | ||
|
|
b41a16364b | ||
|
|
33aeea835b | ||
|
|
9312d3b60f | ||
|
|
268496a8c4 |
@@ -52,6 +52,31 @@ AIRFLOW_ADMIN_EMAIL=admin@enervision.fr
|
|||||||
# python -c "import secrets; print(secrets.token_urlsafe(48))"
|
# python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||||
AIRFLOW_APP_SECRET_KEY=change_me
|
AIRFLOW_APP_SECRET_KEY=change_me
|
||||||
|
|
||||||
|
# Garage, stockage objet S3 par environnement (ADR 0019) : un conteneur par projet Compose, publié
|
||||||
|
# sur 127.0.0.1 seulement. Les six secrets ci-dessous sont exigés par `make services-up` et
|
||||||
|
# `make stack-up` ; scripts/provision-host.sh les génère sur la VM.
|
||||||
|
# 32 octets en hexadécimal, rien d'autre n'est accepté : openssl rand -hex 32
|
||||||
|
GARAGE_RPC_SECRET=change_me
|
||||||
|
# Jetons de l'API d'administration et de /metrics (port 3903). Même générateur qu'APP_SECRET_KEY.
|
||||||
|
GARAGE_ADMIN_TOKEN=change_me
|
||||||
|
GARAGE_METRICS_TOKEN=change_me
|
||||||
|
# Clé S3 créée au premier démarrage (`--default-bucket`). Identifiant : echo "GK$(openssl rand -hex 12)"
|
||||||
|
# Secret : openssl rand -hex 32. Ne plus le changer ensuite, Garage refuserait de démarrer.
|
||||||
|
GARAGE_ACCESS_KEY=change_me
|
||||||
|
GARAGE_SECRET_KEY=change_me
|
||||||
|
GARAGE_BUCKET=enervision-archives
|
||||||
|
# Ports S3 et admin sur 127.0.0.1. Recette : 3910 et 3913, dev : 3920 et 3923.
|
||||||
|
GARAGE_S3_PORT=3900
|
||||||
|
GARAGE_ADMIN_PORT=3903
|
||||||
|
|
||||||
|
# Rétention des mesures (ADR 0019, 0020) : le DAG `retention` exporte chaque nuit vers Garage les
|
||||||
|
# chunks de `reading` plus vieux que cette borne, puis les supprime. L'historique de démonstration
|
||||||
|
# s'arrête fin 2024 : sous 21 mois, la démo disparaîtrait.
|
||||||
|
READING_RETENTION_DAYS=1095
|
||||||
|
# Clé SSE-C des archives, 32 octets en base64 : openssl rand -base64 32. La perdre rend les
|
||||||
|
# archives illisibles ; la sauvegarder hors de la VM.
|
||||||
|
GARAGE_SSE_KEY=change_me
|
||||||
|
|
||||||
# Stack complète derrière le reverse proxy (docker-compose.prod.yml).
|
# Stack complète derrière le reverse proxy (docker-compose.prod.yml).
|
||||||
# PUBLIC_HOST alimente l'origine CORS, le lien de réinitialisation et le certificat.
|
# PUBLIC_HOST alimente l'origine CORS, le lien de réinitialisation et le certificat.
|
||||||
PUBLIC_HOST=enervision.local
|
PUBLIC_HOST=enervision.local
|
||||||
@@ -69,8 +94,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,11 +74,12 @@ 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 deux commandes du DAG `alertes` et la commande du DAG historique sont couvertes.
|
- name: Vérifie que les cinq commandes backend s'importent sans réseau
|
||||||
- name: Vérifie que les trois 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
|
||||||
bash -c "cd /opt/backend
|
bash -c "cd /opt/backend
|
||||||
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.detection.internal_alerts --help
|
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.detection.internal_alerts --help
|
||||||
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.cli generate-recommendations --help
|
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.cli generate-recommendations --help
|
||||||
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.historical_import --help"
|
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.historical_import --help
|
||||||
|
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.mock_api_import --help
|
||||||
|
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.reading_retention --help"
|
||||||
|
|||||||
@@ -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,227 @@
|
|||||||
|
# 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/**"
|
||||||
|
- "infra/garage/**"
|
||||||
|
- "tests/garage/**"
|
||||||
|
- "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
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
name: DAST
|
||||||
|
|
||||||
|
# Scan dynamique OWASP ZAP de l'API (issue #41). Il attaque une API qui tourne : le job démarre
|
||||||
|
# la base et le backend sur le runner, sème le jeu de démonstration (sans ça le scan ne frappe que
|
||||||
|
# des gestionnaires d'erreur), crée des comptes jetables (scripts/dast-token.sh), puis lance ZAP
|
||||||
|
# sur le contrat OpenAPI avec le jeton du `lecteur`.
|
||||||
|
#
|
||||||
|
# Non bloquant pour l'instant sur les alertes (`continue-on-error` sur la seule étape du scan) :
|
||||||
|
# le volume d'un premier passage trié est inconnu. Deux étapes suivantes, elles, bloquent si le
|
||||||
|
# scan n'a rien testé (import du contrat, absence de toute réponse de succès) : un job vert doit
|
||||||
|
# vouloir dire qu'un scan a eu lieu.
|
||||||
|
#
|
||||||
|
# Piège : ce scan tape la configuration par défaut du backend (`APP_ENV=local`, pas de TLS, pas
|
||||||
|
# de reverse proxy). Il ne dit rien des en-têtes ni du TLS posés par le proxy en production, et
|
||||||
|
# remontera des alertes (HSTS absent...) qui n'existent pas derrière lui.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
# Un scan actif est long : hebdomadaire plutôt qu'à chaque PR.
|
||||||
|
- cron: "0 3 * * 1"
|
||||||
|
pull_request:
|
||||||
|
# Ne se lance sur une PR que si le scan lui-même change.
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/dast.yml"
|
||||||
|
- "scripts/dast-token.sh"
|
||||||
|
- "scripts/comptes-test.sh"
|
||||||
|
- "db/seeds/**"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: dast-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
zap:
|
||||||
|
name: Scan OWASP ZAP de l'API
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Généreux face aux ~2 minutes observées de bout en bout : le vrai plafond est
|
||||||
|
# `scanner.maxScanDurationInMins` (étape Scan ZAP), sous le TTL du jeton. Une annulation par
|
||||||
|
# ce timeout-ci n'exécute pas les étapes `always()` : mieux vaut ne jamais l'atteindre.
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
# Même image que docker-compose.yml : la première migration refuse de s'appliquer sans
|
||||||
|
# l'extension TimescaleDB (cf. backend.yml).
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: timescale/timescaledb-ha:pg17
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: enervision
|
||||||
|
POSTGRES_PASSWORD: change_me
|
||||||
|
POSTGRES_DB: enervision_dast
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U enervision -d enervision_dast"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 12
|
||||||
|
--health-start-period 40s
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Base jetable : ZAP y écrira et le script y crée deux comptes.
|
||||||
|
DATABASE_URL: postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_dast
|
||||||
|
APP_SECRET_KEY: secret-de-scan-assez-long-pour-le-validateur
|
||||||
|
APP_ENV: local
|
||||||
|
# Le jeton du lecteur doit survivre à toute la durée du scan (15 minutes par défaut).
|
||||||
|
# 3600 est le plafond accepté par la configuration ; `scanner.maxScanDurationInMins`
|
||||||
|
# (étape Scan ZAP) reste très en dessous, marge comprise pour les étapes qui l'entourent.
|
||||||
|
APP_ACCESS_TOKEN_TTL_SECONDS: "3600"
|
||||||
|
PGPASSWORD: change_me
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Récupère le dépôt
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Installe uv
|
||||||
|
# Épinglé sur le commit du tag v7 (règle Sonar githubactions:S7637 : dépendance tierce,
|
||||||
|
# contrairement à actions/checkout ou actions/upload-artifact, premières parties).
|
||||||
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
cache-dependency-glob: apps/backend/uv.lock
|
||||||
|
# `prune-cache` vaut `true` par défaut (encore sur ce commit) : l'étape de post-job
|
||||||
|
# « Pruning cache » est restée bloquée 5 minutes avant d'échouer (exit code 2) sur un
|
||||||
|
# run où les 16 étapes précédentes passaient, sans lien avec le scan. Le prune n'est
|
||||||
|
# qu'une optimisation de taille de cache entre deux runs, pas une garantie : le
|
||||||
|
# désactiver retire le blocage sans rien changer au comportement du job.
|
||||||
|
prune-cache: false
|
||||||
|
|
||||||
|
- name: Installe l'interpréteur déclaré par .python-version
|
||||||
|
run: uv python install
|
||||||
|
working-directory: apps/backend
|
||||||
|
|
||||||
|
# `--no-build` : aucune dépendance n'est construite depuis ses sources, donc aucun script de
|
||||||
|
# build exécuté (règle Sonar S8541). Le projet lui-même n'est pas installé : il tourne depuis
|
||||||
|
# `apps/backend`, comme dans son Dockerfile. Les `uv run` suivants portent `--frozen
|
||||||
|
# --no-sync` pour ne rien résoudre ni reconstruire (règle S8544).
|
||||||
|
- name: Synchronise les dépendances sans dévier du verrou
|
||||||
|
run: uv sync --locked --no-dev --no-install-project --no-build
|
||||||
|
working-directory: apps/backend
|
||||||
|
|
||||||
|
- name: Active TimescaleDB sur la base du scan
|
||||||
|
run: psql -h localhost -p 5433 -U enervision -d enervision_dast -c "CREATE EXTENSION IF NOT EXISTS timescaledb"
|
||||||
|
|
||||||
|
- name: Applique les migrations
|
||||||
|
run: uv run --frozen --no-sync --no-build alembic upgrade head
|
||||||
|
working-directory: apps/backend
|
||||||
|
|
||||||
|
# Sans données, `GET /sites` rend `[]`, chaque `/{site_id}` rend 404 et le scan actif ne
|
||||||
|
# frappe que des gestionnaires d'erreur plutôt que la logique métier.
|
||||||
|
- name: Sème le jeu de démonstration
|
||||||
|
run: psql -h localhost -p 5433 -U enervision -d enervision_dast -v ON_ERROR_STOP=1 -f db/seeds/demo.sql
|
||||||
|
|
||||||
|
- name: Démarre l'API
|
||||||
|
run: |
|
||||||
|
nohup uv run --frozen --no-sync --no-build uvicorn app.main:create_app --factory \
|
||||||
|
--host 0.0.0.0 --port 8000 > "$RUNNER_TEMP/api.log" 2>&1 &
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
curl -fsS http://localhost:8000/api/v1/health/ready >/dev/null 2>&1 && exit 0
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "L'API ne répond pas sur /health/ready" >&2
|
||||||
|
cat "$RUNNER_TEMP/api.log" >&2
|
||||||
|
exit 1
|
||||||
|
working-directory: apps/backend
|
||||||
|
|
||||||
|
- name: Crée le compte lecteur du scan
|
||||||
|
id: jeton
|
||||||
|
run: |
|
||||||
|
jeton="$(../../scripts/dast-token.sh)"
|
||||||
|
echo "::add-mask::$jeton"
|
||||||
|
echo "jeton=$jeton" >> "$GITHUB_OUTPUT"
|
||||||
|
working-directory: apps/backend
|
||||||
|
|
||||||
|
# Étape distincte du scan lui-même, et sans `continue-on-error` : un `curl` qui échoue ici
|
||||||
|
# (API tombée juste après la sonde de readiness, par exemple) doit rester un échec visible,
|
||||||
|
# pas se travestir en « ZAP n'a importé aucune URL » à l'étape de garde suivante.
|
||||||
|
- name: Prépare le contrat pour ZAP
|
||||||
|
run: |
|
||||||
|
mkdir -p zap-out zap-logs
|
||||||
|
curl -fsS http://localhost:8000/openapi.json -o zap-out/openapi.json
|
||||||
|
# Le dossier passe à l'uid 1000 (utilisateur du conteneur ZAP) : le runner n'y écrit
|
||||||
|
# plus après ce chown, d'où `zap-logs/` (uid du runner) pour les journaux ci-dessous.
|
||||||
|
# Pas de `chmod 777` (règle Sonar S2612).
|
||||||
|
sudo chown -R 1000:1000 zap-out
|
||||||
|
|
||||||
|
# `--network host` : ZAP atteint l'API sur le localhost du runner.
|
||||||
|
#
|
||||||
|
# Piège vécu : la clé du nom d'en-tête est `matchstr`, pas `matchstring`. ZAP accepte
|
||||||
|
# n'importe quelle clé `-config` sans erreur ; avec la mauvaise, il ajoutait à TOUTES les
|
||||||
|
# requêtes un en-tête au nom vide (`: Bearer <jeton>`), qu'uvicorn refuse par un 400
|
||||||
|
# (« Invalid HTTP request received »), y compris sur les routes publiques.
|
||||||
|
#
|
||||||
|
# Le jeton ne passe ni par `${{ }}` dans ce script (il finirait en clair dans le fichier de
|
||||||
|
# commande que GitHub écrit sur le disque du runner pour toute la durée de l'étape), ni par
|
||||||
|
# l'argv de `docker run` (visible par `ps aux` et par `docker inspect zap` tant que le
|
||||||
|
# conteneur existe) : il est écrit dans un fichier de configuration ZAP séparé, monté en
|
||||||
|
# lecture seule hors de `/zap/wrk` pour ne jamais atterrir dans l'artefact publié.
|
||||||
|
#
|
||||||
|
# Les routes d'authentification qui changent l'état du compte du scan sont exclues : un
|
||||||
|
# scan actif y déclencherait la limitation de débit du login, la réinitialisation de mots de
|
||||||
|
# passe et la fermeture des sessions, sans rien apprendre de plus.
|
||||||
|
#
|
||||||
|
# `scanner.maxScanDurationInMins`/`maxRuleDurationInMins` bornent le scan actif, que `-T` ne
|
||||||
|
# couvre pas (il ne borne que le démarrage et le scan passif) : sans ça, une règle qui
|
||||||
|
# traîne peut dépasser le TTL du jeton (401 muets en fin de scan) ou le timeout du job (qui
|
||||||
|
# annule sans exécuter les étapes `always()`, rapport et journaux perdus).
|
||||||
|
- name: Scan ZAP
|
||||||
|
id: zap
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
JETON: ${{ steps.jeton.outputs.jeton }}
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
printf 'replacer.full_list(0).description=auth\nreplacer.full_list(0).enabled=true\nreplacer.full_list(0).matchtype=REQ_HEADER\nreplacer.full_list(0).matchstr=Authorization\nreplacer.full_list(0).regex=false\nreplacer.full_list(0).replacement=Bearer %s\n' "$JETON" > "$RUNNER_TEMP/zap-auth.conf"
|
||||||
|
# Piège vécu : `chmod 600` seul rend le fichier illisible pour le conteneur, qui lit un
|
||||||
|
# montage bind avec son propre uid (1000), distinct de celui du runner qui l'a écrit.
|
||||||
|
# ZAP échoue alors dès le lancement (« File not readable: /zap/auth.conf »), et
|
||||||
|
# `zap-api-scan.py` attend `-T` minutes complètes avant d'abandonner : dix minutes qui
|
||||||
|
# ressemblent à un scan actif, pour un daemon mort depuis le début.
|
||||||
|
#
|
||||||
|
# Piège vécu (numéro deux) : une fois le fichier passé à l'uid 1000 par `sudo chown`,
|
||||||
|
# l'utilisateur du runner n'en est plus propriétaire et un `chmod` sans `sudo` échoue
|
||||||
|
# (« Operation not permitted »). Avec le `-e` implicite de bash sur les étapes GitHub
|
||||||
|
# Actions, cette erreur arrêtait toute l'étape avant même `docker run` : scan « réussi »
|
||||||
|
# en une fraction de seconde, sans le moindre journal ni rapport produit.
|
||||||
|
sudo chown 1000:1000 "$RUNNER_TEMP/zap-auth.conf"
|
||||||
|
sudo chmod 644 "$RUNNER_TEMP/zap-auth.conf"
|
||||||
|
docker run --name zap --network host \
|
||||||
|
-v "$PWD/zap-out:/zap/wrk:rw" \
|
||||||
|
-v "$RUNNER_TEMP/zap-auth.conf:/zap/auth.conf:ro" \
|
||||||
|
ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py \
|
||||||
|
-t /zap/wrk/openapi.json -f openapi -O http://localhost:8000 \
|
||||||
|
-T 10 \
|
||||||
|
-r zap-report.html -J zap-report.json -w zap-report.md \
|
||||||
|
-z "-configfile /zap/auth.conf \
|
||||||
|
-config globalexcludeurl.url_list.url(0).description=auth-etat \
|
||||||
|
-config globalexcludeurl.url_list.url(0).enabled=true \
|
||||||
|
-config globalexcludeurl.url_list.url(0).regex='.*/api/v1/auth/(login|password|logout-all|forgot-password|reset-password).*' \
|
||||||
|
-config scanner.maxScanDurationInMins=15 \
|
||||||
|
-config scanner.maxRuleDurationInMins=5" \
|
||||||
|
2>&1 | tee "$RUNNER_TEMP/zap-stdout.log"
|
||||||
|
|
||||||
|
- name: Récupère les journaux de ZAP
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
mkdir -p zap-logs
|
||||||
|
# ZAP journalise la valeur de chaque `-config`/`-configfile` chargé, y compris le jeton,
|
||||||
|
# à un niveau visible sans `-d` : les copies publiées en artefact sont donc caviardées,
|
||||||
|
# même si `::add-mask::` (posé à la création du jeton) protège déjà le journal du job.
|
||||||
|
masque() { sed -E 's/(Bearer )[A-Za-z0-9._-]+/\1[MASQUE]/Ig'; }
|
||||||
|
[ -f "$RUNNER_TEMP/zap-stdout.log" ] && masque < "$RUNNER_TEMP/zap-stdout.log" > zap-logs/zap-stdout.log
|
||||||
|
docker cp zap:/home/zap/.ZAP/zap.log "$RUNNER_TEMP/zap-internal.log" 2>/dev/null || true
|
||||||
|
[ -f "$RUNNER_TEMP/zap-internal.log" ] && masque < "$RUNNER_TEMP/zap-internal.log" > zap-logs/zap.log
|
||||||
|
[ -f "$RUNNER_TEMP/api.log" ] && masque < "$RUNNER_TEMP/api.log" > zap-logs/api.log
|
||||||
|
rm -f "$RUNNER_TEMP/zap-auth.conf"
|
||||||
|
docker rm -f zap >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# `continue-on-error` sur le scan ne doit pas faire passer pour vert un scan qui n'a rien
|
||||||
|
# testé. Constaté une première fois : 2 URL importées sur 26 opérations, ZAP n'avait envoyé
|
||||||
|
# que des requêtes vouées au 404. Le seuil est dérivé du contrat plutôt que d'un nombre fixe
|
||||||
|
# : un contrat qui grossit ne doit pas rendre la garde plus permissive qu'elle ne l'était.
|
||||||
|
- name: Vérifie que le contrat a bien été importé
|
||||||
|
run: |
|
||||||
|
attendu="$(python3 -c "
|
||||||
|
import json
|
||||||
|
d = json.load(open('zap-out/openapi.json'))
|
||||||
|
methodes = ('get', 'post', 'put', 'patch', 'delete', 'head', 'options')
|
||||||
|
print(sum(1 for chemin in d['paths'].values() for m in chemin if m in methodes))
|
||||||
|
")"
|
||||||
|
minimum=$((attendu * 80 / 100))
|
||||||
|
importees="$(sed -n 's/.*Number of Imported URLs: \([0-9]*\).*/\1/p' "$RUNNER_TEMP/zap-stdout.log" | tail -1)"
|
||||||
|
echo "URL importées depuis le contrat OpenAPI : ${importees:-aucune} (contrat : $attendu opérations, minimum accepté : $minimum)"
|
||||||
|
if [ "${importees:-0}" -lt "$minimum" ]; then
|
||||||
|
echo "::error::ZAP n'a importé que ${importees:-0} URL sur $attendu opérations du contrat OpenAPI (minimum attendu : $minimum, soit 80%). Le scan n'a pas testé l'API, voir zap-logs/zap.log dans l'artefact zap-report."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Deuxième garde-fou : le contrat peut être importé et ZAP n'obtenir que des erreurs
|
||||||
|
# (constaté : base sans données, toutes les routes de site répondaient 404).
|
||||||
|
#
|
||||||
|
# Piège de conception, trouvé en répétant ce job en local avant de l'écrire ici : borner le
|
||||||
|
# pourcentage de 4xx ne marche pas. Un scan actif fuzze délibérément un grand nombre
|
||||||
|
# d'entrées invalides (identifiants inventés, méthodes non supportées...), donc même un scan
|
||||||
|
# sain, contre l'API seedée juste au-dessus, reste à 98% de 4xx avec seulement 1% de 2xx :
|
||||||
|
# c'est la forme normale d'un scan actif, pas un signe d'échec. Le signal qui distingue
|
||||||
|
# vraiment un scan cassé (0% de 2xx, `insight.code.2xx` absent du rapport dans le premier
|
||||||
|
# incident) d'un scan sain (2xx non nul, aussi faible soit-il) est donc l'absence de succès,
|
||||||
|
# pas la part d'échecs. Dérivé de `zap-report.json` (champ structuré `insights[]`) plutôt
|
||||||
|
# que du texte libre du rapport Markdown, qui aurait le même défaut de conception en plus
|
||||||
|
# d'être fragile au format.
|
||||||
|
- name: Vérifie que le scan a obtenu au moins une réponse de succès
|
||||||
|
run: |
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
rapport = json.load(open("zap-out/zap-report.json"))
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("::error::Aucun rapport ZAP produit : le scan n'a rien testé.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
pourcentage_2xx = 0.0
|
||||||
|
for insight in rapport.get("insights", []):
|
||||||
|
if insight.get("key") == "insight.code.2xx":
|
||||||
|
pourcentage_2xx = float(insight.get("statistic", 0))
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"Pourcentage de réponses 2xx : {pourcentage_2xx}%")
|
||||||
|
if pourcentage_2xx <= 0:
|
||||||
|
print(
|
||||||
|
"::error::Aucune réponse 2xx (succès) reçue : le scan n'a atteint aucune route "
|
||||||
|
"réelle de l'API. Voir zap-logs/api.log et zap-logs/zap.log dans l'artefact "
|
||||||
|
"zap-report."
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
PY
|
||||||
|
|
||||||
|
# Uniquement la synthèse (jusqu'à « Alert Detail » exclu) : `$GITHUB_STEP_SUMMARY` est
|
||||||
|
# limité à 1 Mio, et cette étape tourne sous `always()` - son échec ferait échouer le job
|
||||||
|
# après le passage des deux garde-fous, pour une simple raison de mise en forme. Le rapport
|
||||||
|
# complet reste dans l'artefact `zap-report`.
|
||||||
|
- name: Publie le résumé
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -f zap-out/zap-report.md ]; then
|
||||||
|
{
|
||||||
|
awk '/^## Alert Detail/{exit} {print}' zap-out/zap-report.md
|
||||||
|
echo ""
|
||||||
|
echo "Rapport complet (HTML/JSON/Markdown) dans l'artefact \`zap-report\`."
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
else
|
||||||
|
echo "Aucun rapport ZAP produit, voir le journal du job." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Publie les rapports
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: zap-report
|
||||||
|
path: |
|
||||||
|
zap-out/
|
||||||
|
zap-logs/
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
# Diagnostic de dernier recours : les journaux de l'API sont déjà dans l'artefact
|
||||||
|
# (zap-logs/api.log) via l'étape « Récupère les journaux de ZAP » (always()), mais les
|
||||||
|
# afficher directement dans le journal du job évite d'avoir à le télécharger pour un échec
|
||||||
|
# évident (l'API n'a jamais démarré, par exemple).
|
||||||
|
- name: Journal de l'API en cas d'échec
|
||||||
|
if: failure() || steps.zap.outcome == 'failure'
|
||||||
|
run: cat "$RUNNER_TEMP/api.log" || true
|
||||||
@@ -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 ci
|
||||||
- run: npm run build
|
|
||||||
working-directory: apps/frontend
|
|
||||||
|
|
||||||
security-audit:
|
- name: Construit l'application
|
||||||
name: Audit des dépendances
|
run: npm run build
|
||||||
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:
|
# Piège : `npm test --watch=false` garde l'option pour npm, `ng test` ne la reçoit jamais.
|
||||||
needs: build
|
# La couverture lcov vient d'angular.json (`coverage: true`).
|
||||||
runs-on: ubuntu-latest
|
- name: Tests et couverture
|
||||||
steps:
|
run: npm run test:ci
|
||||||
- uses: actions/checkout@v7
|
|
||||||
- uses: actions/setup-node@v7
|
- name: Verse la couverture pour Sonar
|
||||||
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
|
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
|
||||||
|
|||||||
+106
-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,89 @@ 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
|
||||||
|
|
||||||
|
# Garage refuse un rpc_secret qui n'est pas 32 octets hexadécimaux : `change_me` ne suffit pas.
|
||||||
|
- name: Génère les secrets Garage du .env
|
||||||
|
run: |
|
||||||
|
sed -i -e "s|^GARAGE_RPC_SECRET=.*|GARAGE_RPC_SECRET=$(openssl rand -hex 32)|" \
|
||||||
|
-e "s|^GARAGE_ADMIN_TOKEN=.*|GARAGE_ADMIN_TOKEN=$(openssl rand -hex 32)|" \
|
||||||
|
-e "s|^GARAGE_METRICS_TOKEN=.*|GARAGE_METRICS_TOKEN=$(openssl rand -hex 32)|" \
|
||||||
|
-e "s|^GARAGE_ACCESS_KEY=.*|GARAGE_ACCESS_KEY=GK$(openssl rand -hex 12)|" \
|
||||||
|
-e "s|^GARAGE_SECRET_KEY=.*|GARAGE_SECRET_KEY=$(openssl rand -hex 32)|" \
|
||||||
|
-e "s|^GARAGE_SSE_KEY=.*|GARAGE_SSE_KEY=$(openssl rand -base64 32)|" .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
|
||||||
|
|
||||||
|
# Action tierce, épinglée sur le commit du tag (règle Sonar githubactions:S7637).
|
||||||
|
- name: Installe uv
|
||||||
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
|
with:
|
||||||
|
enable-cache: false
|
||||||
|
|
||||||
|
# Même image et même healthcheck qu'en prod : `--wait` ne rend la main qu'une fois le S3 prêt.
|
||||||
|
- name: Démarre Garage
|
||||||
|
run: docker compose up -d --wait --wait-timeout 120 garage
|
||||||
|
|
||||||
|
- name: Fumée S3 sur Garage, SSE-C compris
|
||||||
|
run: |
|
||||||
|
set -a; . ./.env; set +a
|
||||||
|
uvx --no-build --with boto3==1.43.101 pytest==9.1.1 tests/garage -q
|
||||||
|
|
||||||
|
- name: Journaux de Garage en cas d'échec
|
||||||
|
if: failure()
|
||||||
|
run: docker compose logs --tail=100 garage
|
||||||
|
|
||||||
|
- name: Arrête Garage
|
||||||
|
if: always()
|
||||||
|
run: docker compose down --volumes
|
||||||
|
|
||||||
|
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"
|
|
||||||
# Piege : `backend.yml` ne joue jamais `-m chaine`, ce job est le seul. Le test de chaine
|
|
||||||
# traverse tout `apps/backend/app` jusqu'a GET /predictions : restreindre le filtre aux
|
|
||||||
# migrations et aux modeles le laisserait muet sur la PR meme qui le 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/**"
|
|
||||||
- "apps/backend/tests/test_chaine_ml_api.py"
|
|
||||||
- "apps/backend/pyproject.toml"
|
|
||||||
- "apps/backend/uv.lock"
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "ml/**"
|
|
||||||
- ".github/workflows/ml.yml"
|
|
||||||
# Piege : `backend.yml` ne joue jamais `-m chaine`, ce job est le seul. Le test de chaine
|
|
||||||
# traverse tout `apps/backend/app` jusqu'a GET /predictions : restreindre le filtre aux
|
|
||||||
# migrations et aux modeles le laisserait muet sur la PR meme qui le 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/**"
|
|
||||||
- "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 }}
|
|
||||||
+9
-3
@@ -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/
|
||||||
@@ -63,13 +67,15 @@ ml/models/*
|
|||||||
!ml/models/.gitkeep
|
!ml/models/.gitkeep
|
||||||
ml/mlruns/
|
ml/mlruns/
|
||||||
ml/mlartifacts/
|
ml/mlartifacts/
|
||||||
ml/mlflow.db
|
ml/mlflow.db*
|
||||||
|
ml/.env
|
||||||
|
|
||||||
# Airflow : base sqlite locale generee par les tests d'integrite des DAGs (etl/airflow/tests)
|
# Airflow : base sqlite locale generee par les tests d'integrite des DAGs (etl/airflow/tests)
|
||||||
etl/airflow/tests/.airflow_home/
|
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.
|
||||||
@@ -20,6 +21,8 @@ PG_USER := $(or $(strip $(call env-val,POSTGRES_USER)),enervision)
|
|||||||
PG_PASSWORD := $(or $(strip $(call env-val,POSTGRES_PASSWORD)),change_me)
|
PG_PASSWORD := $(or $(strip $(call env-val,POSTGRES_PASSWORD)),change_me)
|
||||||
PG_DB := $(or $(strip $(call env-val,POSTGRES_DB)),enervision)
|
PG_DB := $(or $(strip $(call env-val,POSTGRES_DB)),enervision)
|
||||||
PG_PORT := $(or $(strip $(call env-val,POSTGRES_PORT)),5433)
|
PG_PORT := $(or $(strip $(call env-val,POSTGRES_PORT)),5433)
|
||||||
|
ml-env-val = $(shell sed -n 's/^$(1)=//p' ml/.env 2>/dev/null | tail -1)
|
||||||
|
ML_ENV_DB_PASSWORD := $(call ml-env-val,MLFLOW_DB_PASSWORD)
|
||||||
AIRFLOW_PORT := $(or $(strip $(call env-val,AIRFLOW_PORT)),8080)
|
AIRFLOW_PORT := $(or $(strip $(call env-val,AIRFLOW_PORT)),8080)
|
||||||
MAILPIT_UI_PORT := $(or $(strip $(call env-val,MAILPIT_UI_PORT)),8025)
|
MAILPIT_UI_PORT := $(or $(strip $(call env-val,MAILPIT_UI_PORT)),8025)
|
||||||
ML_DATABASE_URL ?= postgresql+psycopg://$(PG_USER):$(PG_PASSWORD)@localhost:$(PG_PORT)/$(PG_DB)
|
ML_DATABASE_URL ?= postgresql+psycopg://$(PG_USER):$(PG_PASSWORD)@localhost:$(PG_PORT)/$(PG_DB)
|
||||||
@@ -32,6 +35,39 @@ 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 GARAGE_METRICS_TOKEN; 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
|
||||||
|
|
||||||
|
# Piege : l'image Garage n'a pas de shell, elle ne peut pas porter sa garde comme grafana ou
|
||||||
|
# airflow-init. Un secret vide ou laisse a change_me la ferait redemarrer en boucle (ADR 0019).
|
||||||
|
CLES_GARAGE := GARAGE_RPC_SECRET GARAGE_ADMIN_TOKEN GARAGE_METRICS_TOKEN GARAGE_ACCESS_KEY GARAGE_SECRET_KEY GARAGE_SSE_KEY
|
||||||
|
garage-garde = for cle in $(CLES_GARAGE); do \
|
||||||
|
sed -n "s/^$$cle=//p" .env 2>/dev/null | tail -1 | grep -qv '^change_me$$' \
|
||||||
|
|| { echo "$$cle manquant ou laisse a change_me dans .env, requis par Garage (cf. .env.example)"; exit 1; }; \
|
||||||
|
done
|
||||||
|
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
|
||||||
@@ -43,12 +79,14 @@ DEMO_NOW ?= 2024-12-31T00:00:00Z
|
|||||||
test-chaine check \
|
test-chaine check \
|
||||||
openapi docker-build db-up db-down db-reset db-logs db-psql db-wait db-ensure-airflow \
|
openapi docker-build db-up db-down db-reset db-logs db-psql db-wait db-ensure-airflow \
|
||||||
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 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
|
||||||
|
|
||||||
@@ -71,8 +109,9 @@ dev: services-up migrate demo-data ## Lance toute la stack : base, Mailpit, Airf
|
|||||||
$(MAKE) --no-print-directory dev-frontend & \
|
$(MAKE) --no-print-directory dev-frontend & \
|
||||||
wait
|
wait
|
||||||
|
|
||||||
services-up: ## Démarre les services conteneurisés dont `make dev` dépend (base, Mailpit, Airflow)
|
services-up: ## Démarre les services conteneurisés dont `make dev` dépend (base, Mailpit, Garage, Airflow)
|
||||||
docker compose up -d db mailpit
|
@$(garage-garde)
|
||||||
|
docker compose up -d db mailpit garage
|
||||||
@$(MAKE) --no-print-directory db-wait
|
@$(MAKE) --no-print-directory db-wait
|
||||||
@$(MAKE) --no-print-directory db-ensure-airflow
|
@$(MAKE) --no-print-directory db-ensure-airflow
|
||||||
docker compose up -d airflow-init airflow-apiserver airflow-scheduler airflow-dag-processor
|
docker compose up -d airflow-init airflow-apiserver airflow-scheduler airflow-dag-processor
|
||||||
@@ -136,6 +175,14 @@ ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DAT
|
|||||||
ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV= et NOW= optionnels
|
ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV= et NOW= optionnels
|
||||||
cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),) $(if $(NOW),--now $(NOW),)
|
cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),) $(if $(NOW),--now $(NOW),)
|
||||||
|
|
||||||
|
mlflow-up: ## Démarre le serveur MLflow (tracking + registry) en conteneur. ml/.env requis
|
||||||
|
@test -n "$(strip $(ML_ENV_DB_PASSWORD))" \
|
||||||
|
|| { echo "MLFLOW_DB_PASSWORD absente de ml/.env (copier ml/.env.example)"; exit 1; }
|
||||||
|
@echo "$(ML_ENV_DB_PASSWORD)" | grep -qE '^[A-Za-z0-9]+$$' \
|
||||||
|
|| { echo "MLFLOW_DB_PASSWORD doit contenir uniquement lettres et chiffres (interpolee dans l'URI postgresql://)"; exit 1; }
|
||||||
|
cd $(ML) && docker compose -f docker-compose.mlflow.yml up -d --build
|
||||||
|
@echo "mlflow -> http://localhost:5000"
|
||||||
|
|
||||||
detect-alerts: ## Détecte les alertes internes depuis les lectures en base. SITE= et NOW= optionnels
|
detect-alerts: ## Détecte les alertes internes depuis les lectures en base. SITE= et NOW= optionnels
|
||||||
cd $(BACKEND) && uv run python -m app.detection.internal_alerts $(if $(SITE),--site-id $(SITE),) $(if $(NOW),--now $(NOW),)
|
cd $(BACKEND) && uv run python -m app.detection.internal_alerts $(if $(SITE),--site-id $(SITE),) $(if $(NOW),--now $(NOW),)
|
||||||
|
|
||||||
@@ -173,8 +220,11 @@ 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)
|
||||||
|
@$(garage-garde)
|
||||||
$(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
|
||||||
@@ -195,6 +245,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,9 @@ 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 |
|
||||||
|
| Stockage objet | Garage (S3), un par environnement | `infra/garage` | En place, archives de `reading` |
|
||||||
|
| 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,13 +50,15 @@ 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, import, dérive)
|
│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, imports, dérive)
|
||||||
│ ├── plugins/ Operateurs et hooks maison
|
│ ├── plugins/ Operateurs et hooks maison
|
||||||
│ ├── include/ Requetes SQL et ressources des DAGs
|
│ ├── include/ Requetes SQL et ressources des DAGs
|
||||||
│ └── tests/ Tests d'integrite des DAGs
|
│ └── tests/ Tests d'integrite des DAGs
|
||||||
├── infra/
|
├── infra/
|
||||||
|
│ ├── garage/ Stockage objet S3 : configuration sans secret
|
||||||
│ ├── proxy/ Reverse proxy Nginx : terminaison TLS et routage
|
│ ├── proxy/ Reverse proxy Nginx : terminaison TLS et routage
|
||||||
│ └── terraform/
|
│ └── terraform/
|
||||||
│ ├── modules/ Modules reutilisables
|
│ ├── modules/ Modules reutilisables
|
||||||
@@ -64,6 +68,10 @@ 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
|
||||||
|
│ ├── garage/ Tests de fumée S3 joués par la CI contre Garage
|
||||||
|
│ └── load/ Scenarios de charge k6
|
||||||
├── docs/ ADR et vues d'architecture
|
├── docs/ ADR et vues d'architecture
|
||||||
└── scripts/ Outillage local
|
└── scripts/ Outillage local
|
||||||
```
|
```
|
||||||
@@ -96,7 +104,9 @@ et frontend en rechargement a chaud sur le poste.
|
|||||||
Le `.env` doit porter les cles Airflow avant le premier `make dev` : `AIRFLOW_FERNET_KEY`,
|
Le `.env` doit porter les cles Airflow avant le premier `make dev` : `AIRFLOW_FERNET_KEY`,
|
||||||
`AIRFLOW_API_SECRET_KEY`, `AIRFLOW_JWT_SECRET`, `AIRFLOW_APP_SECRET_KEY` et
|
`AIRFLOW_API_SECRET_KEY`, `AIRFLOW_JWT_SECRET`, `AIRFLOW_APP_SECRET_KEY` et
|
||||||
`AIRFLOW_ADMIN_PASSWORD`. Sans elles `airflow-init` refuse de demarrer, et `airflow-apiserver`,
|
`AIRFLOW_ADMIN_PASSWORD`. Sans elles `airflow-init` refuse de demarrer, et `airflow-apiserver`,
|
||||||
`airflow-scheduler` et `airflow-dag-processor` avec lui.
|
`airflow-scheduler` et `airflow-dag-processor` avec lui. Il doit aussi porter les six clés
|
||||||
|
`GARAGE_*` (rpc, jetons, clé S3, clé SSE-C) : `make services-up` refuse sinon de démarrer Garage,
|
||||||
|
où le DAG `retention` archive les mesures anciennes ([ADR 0019](docs/adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)).
|
||||||
|
|
||||||
Les cibles d'origine restent disponibles pour ne demarrer qu'une partie : `make db-up`,
|
Les cibles d'origine restent disponibles pour ne demarrer qu'une partie : `make db-up`,
|
||||||
`make airflow-up`, `make dev-backend`, `make dev-frontend`.
|
`make airflow-up`, `make dev-backend`, `make dev-frontend`.
|
||||||
@@ -148,10 +158,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.
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ SettingsDep = Annotated[Settings, Depends(get_settings)]
|
|||||||
|
|
||||||
CODE_CHANGEMENT_REQUIS = "password_change_required"
|
CODE_CHANGEMENT_REQUIS = "password_change_required"
|
||||||
|
|
||||||
_porteur = HTTPBearer(auto_error=False, scheme_name="Jeton d'accès")
|
# Nom ASCII : un outillage tiers (ZAP, cf. .github/workflows/dast.yml) peut mal analyser un nom
|
||||||
|
# de schéma accentué dans le contrat OpenAPI. Piège vécu, pas anticipé.
|
||||||
|
_porteur = HTTPBearer(auto_error=False, scheme_name="JetonAcces")
|
||||||
CredentialsDep = Annotated[HTTPAuthorizationCredentials | None, Depends(_porteur)]
|
CredentialsDep = Annotated[HTTPAuthorizationCredentials | None, Depends(_porteur)]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ EN_TETES: Final[dict[str, str]] = {
|
|||||||
"X-Content-Type-Options": "nosniff",
|
"X-Content-Type-Options": "nosniff",
|
||||||
"X-Frame-Options": "DENY",
|
"X-Frame-Options": "DENY",
|
||||||
"Referrer-Policy": "no-referrer",
|
"Referrer-Policy": "no-referrer",
|
||||||
|
# same-origin : aucun client ne charge l'API en no-cors depuis une autre origine
|
||||||
|
# (proxy.conf.json en dev, reverse proxy nginx ensuite, cf. docs/architecture/20-backend.md).
|
||||||
|
"Cross-Origin-Resource-Policy": "same-origin",
|
||||||
}
|
}
|
||||||
|
|
||||||
PREFIXE_AUTHENTIFICATION: Final = "/auth"
|
PREFIXE_AUTHENTIFICATION: Final = "/auth"
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ TAGS: Final[list[dict[str, Any]]] = [
|
|||||||
|
|
||||||
cookie_de_rafraichissement = APIKeyCookie(
|
cookie_de_rafraichissement = APIKeyCookie(
|
||||||
name=REFRESH_COOKIE_DEFAUT,
|
name=REFRESH_COOKIE_DEFAUT,
|
||||||
scheme_name="Cookie de rafraîchissement",
|
# Nom ASCII : un outillage tiers (ZAP, cf. .github/workflows/dast.yml) peut mal analyser un
|
||||||
|
# nom de schéma accentué dans le contrat OpenAPI. Piège vécu, pas anticipé.
|
||||||
|
scheme_name="CookieRafraichissement",
|
||||||
description=(
|
description=(
|
||||||
"Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le "
|
"Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le "
|
||||||
"préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers "
|
"préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers "
|
||||||
|
|||||||
@@ -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,29 @@ 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
|
||||||
|
|
||||||
|
s3_endpoint_url: str | None = None
|
||||||
|
s3_region: str = "garage"
|
||||||
|
s3_access_key: str | None = None
|
||||||
|
s3_secret_key: SecretStr | None = None
|
||||||
|
s3_bucket: str | None = None
|
||||||
|
s3_sse_key: SecretStr | None = None
|
||||||
|
reading_retention_days: int = Field(default=1095, ge=30)
|
||||||
|
|
||||||
|
# Compose passe `APP_METRICS_TOKEN` et les réglages S3 vides quand rien n'est posé : vide vaut
|
||||||
|
# absent, sinon `/metrics` exigerait un `Bearer` sans valeur et l'archivage un endpoint vide.
|
||||||
|
@field_validator(
|
||||||
|
"metrics_token",
|
||||||
|
"s3_endpoint_url",
|
||||||
|
"s3_access_key",
|
||||||
|
"s3_secret_key",
|
||||||
|
"s3_bucket",
|
||||||
|
"s3_sse_key",
|
||||||
|
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()]
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
# Contrainte : la réponse de l'API Mock est une entrée hostile, pas une source de confiance.
|
# Contrainte : la réponse de l'API Mock est une entrée hostile, pas une source de confiance.
|
||||||
# Voir OWASP API10 dans docs/architecture/owasp-traceabilite.md. Rien de ce qu'elle renvoie
|
# Voir OWASP API10 dans docs/architecture/owasp-traceabilite.md. Rien de ce qu'elle renvoie
|
||||||
# n'atteint la base sans passer par build_site_row() ou build_reading_row() : seuls les champs
|
# n'atteint la base sans passer par build_site_row() ou build_reading_row() : seuls les champs
|
||||||
# attendus sont recopiés, les grandeurs physiques sont bornées par PHYSICAL_BOUNDS et la taille
|
# attendus sont recopiés, les grandeurs physiques sont bornées par PHYSICAL_BOUNDS, la taille des
|
||||||
# des tableaux est plafonnée par MAX_SITES et par --limit. Une valeur hors bornes devient NULL
|
# tableaux est plafonnée par MAX_SITES et par limit_for_window() (dérivé de la fenêtre, jamais
|
||||||
# et laisse sa trace dans null_reasons plutôt que de lever : le mock émet des anomalies par
|
# fourni par l'appelant), et les lectures dont le timestamp déborde de la fenêtre demandée sont
|
||||||
# construction, et raw_data conserve de toute façon la réponse d'origine intacte.
|
# écartées (fetch_readings). Une valeur hors bornes devient NULL et laisse sa trace dans
|
||||||
|
# null_reasons plutôt que de lever : le mock émet des anomalies par construction, et raw_data
|
||||||
|
# conserve de toute façon la réponse d'origine intacte.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -19,6 +21,7 @@ from sqlalchemy import text
|
|||||||
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
from app.etl.historical_import import SOURCE_NAME as SOURCE_CSV
|
||||||
|
|
||||||
SOURCE_HISTORY = "api_history"
|
SOURCE_HISTORY = "api_history"
|
||||||
|
|
||||||
@@ -45,15 +48,19 @@ CAPACITY_BOUNDS = (0.0, 100_000.0)
|
|||||||
def create_mock_api_client() -> httpx.AsyncClient:
|
def create_mock_api_client() -> httpx.AsyncClient:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
if settings.mock_api_username is None or settings.mock_api_password is None:
|
username = settings.mock_api_username
|
||||||
|
password = (
|
||||||
|
settings.mock_api_password.get_secret_value()
|
||||||
|
if settings.mock_api_password is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if not username or not username.strip() or not password or not password.strip():
|
||||||
raise ValueError("Les identifiants de l'API Mock ne sont pas configurés.")
|
raise ValueError("Les identifiants de l'API Mock ne sont pas configurés.")
|
||||||
|
|
||||||
return httpx.AsyncClient(
|
return httpx.AsyncClient(
|
||||||
base_url=settings.mock_api_base_url.rstrip("/"),
|
base_url=settings.mock_api_base_url.rstrip("/"),
|
||||||
auth=(
|
auth=(username, password),
|
||||||
settings.mock_api_username,
|
|
||||||
settings.mock_api_password.get_secret_value(),
|
|
||||||
),
|
|
||||||
timeout=settings.mock_api_timeout_seconds,
|
timeout=settings.mock_api_timeout_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -177,6 +184,19 @@ async def upsert_sites(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamp_in_window(reading: dict[str, Any], start_time: datetime, end_time: datetime) -> bool:
|
||||||
|
valeur = reading.get("timestamp")
|
||||||
|
if not isinstance(valeur, str):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
instant = parse_datetime(valeur)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return start_time <= instant < end_time
|
||||||
|
|
||||||
|
|
||||||
async def fetch_readings(
|
async def fetch_readings(
|
||||||
client: httpx.AsyncClient,
|
client: httpx.AsyncClient,
|
||||||
site_id: str,
|
site_id: str,
|
||||||
@@ -204,7 +224,22 @@ async def fetch_readings(
|
|||||||
if len(payload) > limit:
|
if len(payload) > limit:
|
||||||
raise ValueError(f"La réponse /api/v1/readings dépasse la limite demandée de {limit}.")
|
raise ValueError(f"La réponse /api/v1/readings dépasse la limite demandée de {limit}.")
|
||||||
|
|
||||||
return payload
|
# Le garde-fou `refuse_if_overlaps_historical_dataset` ne vérifie que la fenêtre demandée :
|
||||||
|
# une réponse (bug du mock, ou hostile) dont les `timestamp` débordent de
|
||||||
|
# `[start_time, end_time)` contournerait ce contrôle et écrirait exactement le doublon
|
||||||
|
# inter-source qu'il doit empêcher. Écarter ces lectures ici rend le contrôle par fenêtre
|
||||||
|
# suffisant.
|
||||||
|
dans_la_fenetre = [
|
||||||
|
lecture
|
||||||
|
for lecture in payload
|
||||||
|
if isinstance(lecture, dict) and _timestamp_in_window(lecture, start_time, end_time)
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(dans_la_fenetre) != len(payload):
|
||||||
|
ecartees = len(payload) - len(dans_la_fenetre)
|
||||||
|
print(f"{site_id}: {ecartees} lecture(s) hors fenêtre écartée(s).")
|
||||||
|
|
||||||
|
return dans_la_fenetre
|
||||||
|
|
||||||
|
|
||||||
def build_reading_row(
|
def build_reading_row(
|
||||||
@@ -240,6 +275,36 @@ def build_reading_row(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# `uq_reading_source` autorise deux lignes au même (site_id, timestamp) dès que `source` diffère :
|
||||||
|
# sans ce garde-fou, importer une fenêtre déjà couverte par le dataset historique (source='csv')
|
||||||
|
# dupliquerait silencieusement chaque point plutôt que de lever une erreur. Ce garde-fou protège
|
||||||
|
# l'ingestion ; il ne dit rien de la lecture (`GET /readings` renvoie les deux lignes en cas de
|
||||||
|
# doublon malgré tout, cf. la section réconciliation de 40-data.md).
|
||||||
|
OVERLAP_CHECK = text(
|
||||||
|
"SELECT count(*) FROM reading WHERE source = :source_csv "
|
||||||
|
"AND timestamp >= :start_time AND timestamp < :end_time"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def refuse_if_overlaps_historical_dataset(
|
||||||
|
connection: AsyncConnection,
|
||||||
|
start_time: datetime,
|
||||||
|
end_time: datetime,
|
||||||
|
) -> None:
|
||||||
|
resultat = await connection.execute(
|
||||||
|
OVERLAP_CHECK,
|
||||||
|
{"source_csv": SOURCE_CSV, "start_time": start_time, "end_time": end_time},
|
||||||
|
)
|
||||||
|
nombre = resultat.scalar_one()
|
||||||
|
|
||||||
|
if nombre > 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"La fenêtre [{start_time.isoformat()}, {end_time.isoformat()}) recouvre "
|
||||||
|
f"{nombre} lecture(s) déjà importée(s) du dataset historique (source='{SOURCE_CSV}') : "
|
||||||
|
"import refusé pour éviter un doublon inter-source."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Le conflit vise l'index unique uq_reading_source plutôt que la table entière : sans cible
|
# Le conflit vise l'index unique uq_reading_source plutôt que la table entière : sans cible
|
||||||
# nommée, DO NOTHING avalerait aussi une violation de clé primaire.
|
# nommée, DO NOTHING avalerait aussi une violation de clé primaire.
|
||||||
READING_INSERT = text(
|
READING_INSERT = text(
|
||||||
@@ -298,41 +363,57 @@ def build_reading_batch(
|
|||||||
return [build_reading_row(reading) for reading in readings]
|
return [build_reading_row(reading) for reading in readings]
|
||||||
|
|
||||||
|
|
||||||
|
def limit_for_window(start_time: datetime, end_time: datetime) -> int:
|
||||||
|
"""Nombre de lectures à demander pour que l'API Mock en rende une par heure, alignée.
|
||||||
|
|
||||||
|
L'API ne renvoie pas un flux à un rythme naturel : elle répartit exactement `limit` lectures,
|
||||||
|
espacées uniformément, sur toute la fenêtre `[start_time, end_time)` demandée, la première
|
||||||
|
au tout début de la fenêtre (vérifié empiriquement). Deux façons d'obtenir une lecture
|
||||||
|
alignée sur l'heure :
|
||||||
|
|
||||||
|
- une fenêtre d'exactement N heures (`start_time` sur l'heure) donne, avec `limit=N`, N
|
||||||
|
lectures espacées d'1h pile, la première à `start_time` : c'est le chemin du backfill
|
||||||
|
manuel (plusieurs jours d'historique en un seul appel).
|
||||||
|
- une fenêtre plus courte qu'une heure, ou qui n'est pas un multiple entier d'heure, ne peut
|
||||||
|
espacer plusieurs lectures d'1h pile (l'espacement de l'API vaut toujours
|
||||||
|
`durée / limit`) : seule `limit=1` reste alignée, la lecture unique atterrissant à
|
||||||
|
`start_time`. C'est le chemin du DAG horaire, dont la fenêtre part de l'heure pile qui
|
||||||
|
précède son déclenchement jusqu'à l'instant du déclenchement lui-même (`:45`), donc plus
|
||||||
|
courte qu'une heure.
|
||||||
|
|
||||||
|
Dans les deux cas, `start_time` doit tomber pile sur l'heure : c'est elle qui ancre
|
||||||
|
l'alignement, jamais `end_time`. Un `limit` plus grand que celui rendu ici fabriquerait des
|
||||||
|
lectures infra-horaires, incompatibles avec les lags positionnels de `build_features`.
|
||||||
|
"""
|
||||||
|
if start_time.minute or start_time.second or start_time.microsecond:
|
||||||
|
raise ValueError(
|
||||||
|
f"La fenêtre doit démarrer pile sur l'heure : {start_time.isoformat()} ne l'est pas."
|
||||||
|
)
|
||||||
|
|
||||||
|
duree = end_time - start_time
|
||||||
|
heures, reste = divmod(duree.total_seconds(), 3600)
|
||||||
|
|
||||||
|
# Fenêtre plus courte qu'une heure, ou pas un multiple entier : aucun `limit` supérieur à 1
|
||||||
|
# n'espacerait ses lectures d'1h pile (l'espacement vaut toujours durée / limit). Seule la
|
||||||
|
# lecture unique, ancrée sur `start_time`, reste alignée.
|
||||||
|
limit = int(heures) if reste == 0 and heures >= 1 else 1
|
||||||
|
|
||||||
|
if limit > MAX_LIMIT:
|
||||||
|
raise ValueError(
|
||||||
|
f"La fenêtre demandée couvre {limit}h, au-delà du plafond de {MAX_LIMIT} "
|
||||||
|
"lectures accepté par l'API Mock."
|
||||||
|
)
|
||||||
|
|
||||||
|
return limit
|
||||||
|
|
||||||
|
|
||||||
async def import_mock_api_history(
|
async def import_mock_api_history(
|
||||||
start_time: datetime,
|
start_time: datetime,
|
||||||
end_time: datetime,
|
end_time: datetime,
|
||||||
limit: int,
|
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
limit = limit_for_window(start_time, end_time)
|
||||||
async with create_mock_api_client() as client:
|
|
||||||
sites = await fetch_sites(client)
|
|
||||||
|
|
||||||
print(f"Sites récupérés : {len(sites)}")
|
|
||||||
|
|
||||||
all_readings: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
for site in sites:
|
|
||||||
site_id = read_text(site, "site_id")
|
|
||||||
|
|
||||||
readings = await fetch_readings(
|
|
||||||
client=client,
|
|
||||||
site_id=site_id,
|
|
||||||
start_time=start_time,
|
|
||||||
end_time=end_time,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"{site_id}: {len(readings)} lectures")
|
|
||||||
|
|
||||||
all_readings.extend(readings)
|
|
||||||
|
|
||||||
print(f"Lectures récupérées : {len(all_readings)}")
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
print("Dry-run terminé : aucune donnée écrite.")
|
|
||||||
return
|
|
||||||
|
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
str(settings.database_url),
|
str(settings.database_url),
|
||||||
@@ -340,6 +421,39 @@ async def import_mock_api_history(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Garde-fou d'abord, y compris en dry-run : il est en lecture seule, et annoncer un
|
||||||
|
# succès pour une fenêtre que l'import réel refusera serait trompeur.
|
||||||
|
async with engine.connect() as connection:
|
||||||
|
await refuse_if_overlaps_historical_dataset(connection, start_time, end_time)
|
||||||
|
|
||||||
|
async with create_mock_api_client() as client:
|
||||||
|
sites = await fetch_sites(client)
|
||||||
|
|
||||||
|
print(f"Sites récupérés : {len(sites)}")
|
||||||
|
|
||||||
|
all_readings: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for site in sites:
|
||||||
|
site_id = read_text(site, "site_id")
|
||||||
|
|
||||||
|
readings = await fetch_readings(
|
||||||
|
client=client,
|
||||||
|
site_id=site_id,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"{site_id}: {len(readings)} lectures")
|
||||||
|
|
||||||
|
all_readings.extend(readings)
|
||||||
|
|
||||||
|
print(f"Lectures récupérées : {len(all_readings)}")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print("Dry-run terminé : aucune donnée écrite.")
|
||||||
|
return
|
||||||
|
|
||||||
async with engine.begin() as connection:
|
async with engine.begin() as connection:
|
||||||
await upsert_sites(
|
await upsert_sites(
|
||||||
connection,
|
connection,
|
||||||
@@ -361,7 +475,14 @@ async def import_mock_api_history(
|
|||||||
|
|
||||||
|
|
||||||
def parse_datetime(value: str) -> datetime:
|
def parse_datetime(value: str) -> datetime:
|
||||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
# Sans fuseau, l'API le traite comme reçu, telle quelle, mais l'encodeur `timestamptz`
|
||||||
|
# d'asyncpg lirait un datetime naif dans le fuseau *local du processus* (correct dans le
|
||||||
|
# conteneur Airflow en UTC, décalé de 1-2h pour un import manuel lancé depuis un poste en
|
||||||
|
# Europe/Paris). Poser `tzinfo=UTC` explicitement, même pattern que `_vers_utc()` dans
|
||||||
|
# `app/services/reading.py`, garantit que la borne envoyée à l'API et celle comparée en SQL
|
||||||
|
# (refuse_if_overlaps_historical_dataset) désignent le même instant.
|
||||||
|
instant = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
@@ -379,12 +500,6 @@ def parse_args() -> argparse.Namespace:
|
|||||||
type=parse_datetime,
|
type=parse_datetime,
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--limit",
|
|
||||||
type=int,
|
|
||||||
default=MAX_LIMIT,
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -396,9 +511,6 @@ def parse_args() -> argparse.Namespace:
|
|||||||
def main() -> None:
|
def main() -> None:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
|
||||||
if args.limit < 1 or args.limit > MAX_LIMIT:
|
|
||||||
raise ValueError(f"--limit doit être compris entre 1 et {MAX_LIMIT}.")
|
|
||||||
|
|
||||||
if args.start_time >= args.end_time:
|
if args.start_time >= args.end_time:
|
||||||
raise ValueError("--start-time doit être antérieur à --end-time.")
|
raise ValueError("--start-time doit être antérieur à --end-time.")
|
||||||
|
|
||||||
@@ -406,7 +518,6 @@ def main() -> None:
|
|||||||
import_mock_api_history(
|
import_mock_api_history(
|
||||||
start_time=args.start_time,
|
start_time=args.start_time,
|
||||||
end_time=args.end_time,
|
end_time=args.end_time,
|
||||||
limit=args.limit,
|
|
||||||
dry_run=args.dry_run,
|
dry_run=args.dry_run,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
# Pourquoi : la suppression n'est pas confiée à add_retention_policy, qui ignorerait l'export.
|
||||||
|
# archive_reading_chunks() exporte chaque chunk vers Garage, le relit, puis le supprime seul.
|
||||||
|
# Piège : drop_chunks pose un verrou exclusif sur reading, site et dataset jusqu'au COMMIT. La
|
||||||
|
# suppression tient donc dans une transaction dédiée et courte, séparée de la lecture du chunk.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import anyio.to_thread
|
||||||
|
import boto3
|
||||||
|
import pandas as pd
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
from pydantic import SecretStr
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
|
||||||
|
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from types_boto3_s3.client import S3Client
|
||||||
|
|
||||||
|
SSE_KEY_LENGTH = 32
|
||||||
|
FORMAT_BORNE = "%Y%m%dT%H%M%SZ"
|
||||||
|
|
||||||
|
ELIGIBLE_CHUNKS = text(
|
||||||
|
"SELECT chunk_schema, chunk_name, range_start, range_end "
|
||||||
|
"FROM timescaledb_information.chunks "
|
||||||
|
"WHERE hypertable_name = 'reading' AND range_end <= :older_than "
|
||||||
|
"ORDER BY range_start"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Lecture via l'hypertable, jamais la table interne : l'exclusion de partition vise le seul chunk.
|
||||||
|
CHUNK_ROWS = text(
|
||||||
|
"SELECT * FROM reading WHERE timestamp >= :start AND timestamp < :end "
|
||||||
|
"ORDER BY timestamp, reading_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Les deux bornes sont inclusives pour drop_chunks : celles du chunk le désignent, et lui seul.
|
||||||
|
DROP_CHUNK = text(
|
||||||
|
"SELECT drop_chunks('reading', "
|
||||||
|
"older_than => CAST(:end AS timestamptz), newer_than => CAST(:start AS timestamptz))"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Chunk:
|
||||||
|
schema: str
|
||||||
|
name: str
|
||||||
|
range_start: datetime
|
||||||
|
range_end: datetime
|
||||||
|
|
||||||
|
@property
|
||||||
|
def qualified_name(self) -> str:
|
||||||
|
return f"{self.schema}.{self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Rapport:
|
||||||
|
chunks_vus: int = 0
|
||||||
|
exportes: int = 0
|
||||||
|
deja_presents: int = 0
|
||||||
|
supprimes: int = 0
|
||||||
|
lignes: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def object_key(chunk: Chunk) -> str:
|
||||||
|
start = chunk.range_start.astimezone(UTC)
|
||||||
|
end = chunk.range_end.astimezone(UTC)
|
||||||
|
return (
|
||||||
|
f"reading/{start.year}/reading_{start.strftime(FORMAT_BORNE)}_"
|
||||||
|
f"{end.strftime(FORMAT_BORNE)}.csv.gz"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def eligible_chunks(conn: AsyncConnection, older_than: datetime) -> list[Chunk]:
|
||||||
|
result = await conn.execute(ELIGIBLE_CHUNKS, {"older_than": older_than})
|
||||||
|
return [
|
||||||
|
Chunk(
|
||||||
|
schema=row["chunk_schema"],
|
||||||
|
name=row["chunk_name"],
|
||||||
|
range_start=row["range_start"],
|
||||||
|
range_end=row["range_end"],
|
||||||
|
)
|
||||||
|
for row in result.mappings().all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def read_chunk_rows(conn: AsyncConnection, chunk: Chunk) -> list[dict[str, Any]]:
|
||||||
|
result = await conn.execute(CHUNK_ROWS, {"start": chunk.range_start, "end": chunk.range_end})
|
||||||
|
return [dict(row) for row in result.mappings().all()]
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_cell(value: object) -> object:
|
||||||
|
if isinstance(value, dict | list):
|
||||||
|
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_csv_gzip(rows: list[dict[str, Any]]) -> bytes:
|
||||||
|
if not rows:
|
||||||
|
raise ValueError("Aucune ligne à sérialiser : un CSV sans colonne ne se relit pas.")
|
||||||
|
|
||||||
|
frame = pd.DataFrame([{name: _csv_cell(value) for name, value in row.items()} for row in rows])
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
frame.to_csv(buffer, mode="wb", index=False, compression={"method": "gzip", "mtime": 0})
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_of(data: bytes) -> str:
|
||||||
|
return hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_missing_object(erreur: ClientError) -> bool:
|
||||||
|
error = erreur.response.get("Error")
|
||||||
|
metadata = erreur.response.get("ResponseMetadata")
|
||||||
|
code = error.get("Code") if error is not None else None
|
||||||
|
status = metadata.get("HTTPStatusCode") if metadata is not None else None
|
||||||
|
return code == "NoSuchKey" or status == 404
|
||||||
|
|
||||||
|
|
||||||
|
class ArchiveStore:
|
||||||
|
def __init__(self, client: S3Client, bucket: str, sse_key: bytes | None) -> None:
|
||||||
|
self._client = client
|
||||||
|
self._bucket = bucket
|
||||||
|
self._sse_key = sse_key
|
||||||
|
|
||||||
|
# boto3 encode lui-même la clé en base64 et calcule son MD5 : la fournir brute, sans MD5.
|
||||||
|
def _sse_headers(self) -> dict[str, Any]:
|
||||||
|
if self._sse_key is None:
|
||||||
|
return {}
|
||||||
|
return {"SSECustomerAlgorithm": "AES256", "SSECustomerKey": self._sse_key}
|
||||||
|
|
||||||
|
def put(self, key: str, body: bytes, metadata: dict[str, str]) -> None:
|
||||||
|
self._client.put_object(
|
||||||
|
Bucket=self._bucket,
|
||||||
|
Key=key,
|
||||||
|
Body=body,
|
||||||
|
ContentType="text/csv",
|
||||||
|
ContentEncoding="gzip",
|
||||||
|
Metadata=metadata,
|
||||||
|
**self._sse_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def fetch_sha256(self, key: str) -> str | None:
|
||||||
|
try:
|
||||||
|
response = self._client.get_object(Bucket=self._bucket, Key=key, **self._sse_headers())
|
||||||
|
except ClientError as erreur:
|
||||||
|
if _is_missing_object(erreur):
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
return sha256_of(response["Body"].read())
|
||||||
|
|
||||||
|
|
||||||
|
def decode_sse_key(encoded: SecretStr | None) -> bytes | None:
|
||||||
|
if encoded is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
key = base64.b64decode(encoded.get_secret_value(), validate=True)
|
||||||
|
if len(key) != SSE_KEY_LENGTH:
|
||||||
|
raise ValueError(
|
||||||
|
f"APP_S3_SSE_KEY doit encoder exactement {SSE_KEY_LENGTH} octets en base64, "
|
||||||
|
f"pas {len(key)}."
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def build_archive_store(settings: Settings) -> ArchiveStore:
|
||||||
|
endpoint = settings.s3_endpoint_url
|
||||||
|
access_key = settings.s3_access_key
|
||||||
|
secret_key = settings.s3_secret_key
|
||||||
|
bucket = settings.s3_bucket
|
||||||
|
|
||||||
|
if endpoint is None or access_key is None or secret_key is None or bucket is None:
|
||||||
|
raise ValueError(
|
||||||
|
"L'archivage vers Garage exige APP_S3_ENDPOINT_URL, APP_S3_ACCESS_KEY, "
|
||||||
|
"APP_S3_SECRET_KEY et APP_S3_BUCKET."
|
||||||
|
)
|
||||||
|
|
||||||
|
client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=endpoint,
|
||||||
|
aws_access_key_id=access_key,
|
||||||
|
aws_secret_access_key=secret_key.get_secret_value(),
|
||||||
|
region_name=settings.s3_region,
|
||||||
|
)
|
||||||
|
return ArchiveStore(client, bucket=bucket, sse_key=decode_sse_key(settings.s3_sse_key))
|
||||||
|
|
||||||
|
|
||||||
|
async def drop_chunk(conn: AsyncConnection, chunk: Chunk) -> None:
|
||||||
|
result = await conn.execute(DROP_CHUNK, {"start": chunk.range_start, "end": chunk.range_end})
|
||||||
|
supprimes = list(result.scalars().all())
|
||||||
|
|
||||||
|
if supprimes != [chunk.qualified_name]:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"drop_chunks devait supprimer exactement {chunk.qualified_name}, "
|
||||||
|
f"il a rendu {supprimes}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _export(
|
||||||
|
store: ArchiveStore,
|
||||||
|
key: str,
|
||||||
|
rows: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
rapport: Rapport,
|
||||||
|
) -> str:
|
||||||
|
body = serialize_csv_gzip(rows)
|
||||||
|
sha = sha256_of(body)
|
||||||
|
|
||||||
|
if await anyio.to_thread.run_sync(store.fetch_sha256, key) == sha:
|
||||||
|
rapport.deja_presents += 1
|
||||||
|
return f"{len(body)} octets déjà présents"
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return f"{len(body)} octets à exporter"
|
||||||
|
|
||||||
|
metadata = {"sha256": sha, "rows": str(len(rows))}
|
||||||
|
await anyio.to_thread.run_sync(store.put, key, body, metadata)
|
||||||
|
relu = await anyio.to_thread.run_sync(store.fetch_sha256, key)
|
||||||
|
|
||||||
|
if relu != sha:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Relecture de {key} : sha256 {relu} au lieu de {sha}, le chunk est conservé."
|
||||||
|
)
|
||||||
|
|
||||||
|
rapport.exportes += 1
|
||||||
|
return f"{len(body)} octets exportés et relus"
|
||||||
|
|
||||||
|
|
||||||
|
async def _archive_chunk(
|
||||||
|
engine: AsyncEngine,
|
||||||
|
store: ArchiveStore,
|
||||||
|
chunk: Chunk,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
rapport: Rapport,
|
||||||
|
) -> None:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
rows = await read_chunk_rows(conn, chunk)
|
||||||
|
|
||||||
|
key = object_key(chunk)
|
||||||
|
rapport.lignes += len(rows)
|
||||||
|
|
||||||
|
if rows:
|
||||||
|
action = await _export(store, key, rows, dry_run=dry_run, rapport=rapport)
|
||||||
|
else:
|
||||||
|
action = "vide, rien à exporter"
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print(f"{key} : {len(rows)} ligne(s), {action}, suppression simulée.")
|
||||||
|
return
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await drop_chunk(conn, chunk)
|
||||||
|
|
||||||
|
rapport.supprimes += 1
|
||||||
|
print(f"{key} : {len(rows)} ligne(s), {action}, chunk {chunk.qualified_name} supprimé.")
|
||||||
|
|
||||||
|
|
||||||
|
async def archive_reading_chunks(
|
||||||
|
engine: AsyncEngine,
|
||||||
|
store: ArchiveStore,
|
||||||
|
*,
|
||||||
|
older_than: datetime,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> Rapport:
|
||||||
|
rapport = Rapport()
|
||||||
|
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
chunks = await eligible_chunks(conn, older_than)
|
||||||
|
|
||||||
|
rapport.chunks_vus = len(chunks)
|
||||||
|
print(
|
||||||
|
f"{len(chunks)} chunk(s) de reading entièrement antérieur(s) au {older_than.isoformat()}."
|
||||||
|
)
|
||||||
|
|
||||||
|
for chunk in chunks:
|
||||||
|
await _archive_chunk(engine, store, chunk, dry_run=dry_run, rapport=rapport)
|
||||||
|
|
||||||
|
bilan = "Dry-run terminé : rien n'a été écrit ni supprimé." if dry_run else "Archivage terminé."
|
||||||
|
print(
|
||||||
|
f"{bilan} Chunks vus : {rapport.chunks_vus}, exportés : {rapport.exportes}, "
|
||||||
|
f"déjà présents : {rapport.deja_presents}, supprimés : {rapport.supprimes}, "
|
||||||
|
f"lignes : {rapport.lignes}."
|
||||||
|
)
|
||||||
|
return rapport
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(
|
||||||
|
settings: Settings,
|
||||||
|
store: ArchiveStore,
|
||||||
|
*,
|
||||||
|
older_than: datetime,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> Rapport:
|
||||||
|
engine = create_async_engine(str(settings.database_url), pool_pre_ping=True)
|
||||||
|
try:
|
||||||
|
return await archive_reading_chunks(engine, store, older_than=older_than, dry_run=dry_run)
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="python -m app.etl.reading_retention",
|
||||||
|
description=(
|
||||||
|
"Exporte vers Garage puis supprime les chunks de reading entièrement plus vieux "
|
||||||
|
"que la borne de rétention."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--older-than-days",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Borne en jours, par défaut APP_READING_RETENTION_DAYS.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Liste et mesure les chunks éligibles sans rien écrire ni supprimer.",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> None:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
jours = (
|
||||||
|
settings.reading_retention_days if args.older_than_days is None else args.older_than_days
|
||||||
|
)
|
||||||
|
older_than = datetime.now(UTC) - timedelta(days=jours)
|
||||||
|
store = build_archive_store(settings)
|
||||||
|
|
||||||
|
asyncio.run(_run(settings, store, older_than=older_than, dry_run=args.dry_run))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -65,6 +65,15 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||||||
default=defauts.min_observations,
|
default=defauts.min_observations,
|
||||||
help="En deçà, le verdict est `indetermine` plutôt qu'un chiffre trompeur.",
|
help="En deçà, le verdict est `indetermine` plutôt qu'un chiffre trompeur.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--bias-threshold",
|
||||||
|
type=float,
|
||||||
|
default=defauts.seuil_biais,
|
||||||
|
help=(
|
||||||
|
"Biais absolu en kWh au-delà duquel le verdict bascule en dérive. "
|
||||||
|
"Zéro, le défaut, laisse le biais informatif : voir l'ADR 0013."
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--fail-on-drift",
|
"--fail-on-drift",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -78,6 +87,7 @@ def seuils_depuis(args: argparse.Namespace) -> Seuils:
|
|||||||
fenetre=timedelta(hours=args.window_hours),
|
fenetre=timedelta(hours=args.window_hours),
|
||||||
grace=timedelta(hours=args.grace_hours),
|
grace=timedelta(hours=args.grace_hours),
|
||||||
min_observations=args.min_observations,
|
min_observations=args.min_observations,
|
||||||
|
seuil_biais=args.bias_threshold,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ même heure plusieurs fois, et la moyenne d'erreur pèserait ces sites en double
|
|||||||
On retient la prédiction du run le plus récent, celle que sert `GET /api/v1/predictions`, avec
|
On retient la prédiction du run le plus récent, celle que sert `GET /api/v1/predictions`, avec
|
||||||
`prediction_id` en départage : `created_at` vaut l'heure de début de transaction et ne
|
`prediction_id` en départage : `created_at` vaut l'heure de début de transaction et ne
|
||||||
distingue pas deux lignes du même run.
|
distingue pas deux lignes du même run.
|
||||||
|
|
||||||
Côté lectures, le départage est `reading_id` décroissant, la règle même de
|
|
||||||
`GET /api/v1/sites/{site_id}/current`. La dérive se mesure donc contre le réalisé que l'API
|
|
||||||
affiche, et non contre une source élue ici et nulle part ailleurs.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ class Seuils:
|
|||||||
min_observations: int = 24
|
min_observations: int = 24
|
||||||
ratio_derive: float = 1.25
|
ratio_derive: float = 1.25
|
||||||
mae_plancher: float = 0.0
|
mae_plancher: float = 0.0
|
||||||
|
# Un biais se compte en kWh, donc ne se transpose pas d'un site à l'autre : zéro le désactive,
|
||||||
|
# sans cesser de le mesurer. Réglé par `--bias-threshold`, arbitrage dans l'ADR 0013.
|
||||||
seuil_biais: float = 0.0
|
seuil_biais: float = 0.0
|
||||||
seuil_couverture: float = 0.8
|
seuil_couverture: float = 0.8
|
||||||
|
|
||||||
@@ -95,10 +97,7 @@ class DriftService:
|
|||||||
self, *, now: datetime | None = None, site_id: str | None = None
|
self, *, now: datetime | None = None, site_id: str | None = None
|
||||||
) -> list[NouveauRapportDerive]:
|
) -> list[NouveauRapportDerive]:
|
||||||
"""Une ligne par site, plus une ligne globale dont le `site_id` est nul."""
|
"""Une ligne par site, plus une ligne globale dont le `site_id` est nul."""
|
||||||
# Piège : `window_end` est la clé d'idempotence de `uq_drift_report_window`. Sans
|
fin = (now or datetime.now(UTC)) - self._seuils.grace
|
||||||
# troncature à l'heure, deux exécutions ne collident jamais, aux microsecondes près.
|
|
||||||
instant = (now or datetime.now(UTC)).replace(minute=0, second=0, microsecond=0)
|
|
||||||
fin = instant - self._seuils.grace
|
|
||||||
debut = fin - self._seuils.fenetre
|
debut = fin - self._seuils.fenetre
|
||||||
reference_fin = debut
|
reference_fin = debut
|
||||||
reference_debut = reference_fin - self._seuils.fenetre
|
reference_debut = reference_fin - self._seuils.fenetre
|
||||||
@@ -135,12 +134,8 @@ class DriftService:
|
|||||||
anciennes=[p for p in anciennes if p.site_id == site],
|
anciennes=[p for p in anciennes if p.site_id == site],
|
||||||
comptages=[c for c in comptages if c.site_id == site],
|
comptages=[c for c in comptages if c.site_id == site],
|
||||||
)
|
)
|
||||||
# Piège : un site présent dans la référence et absent de la fenêtre récente a cessé
|
|
||||||
# d'être scoré. C'est la panne à crier, pas une ligne à omettre.
|
|
||||||
for site in sorted(
|
for site in sorted(
|
||||||
{paire.site_id for paire in recentes}
|
{paire.site_id for paire in recentes} | {c.site_id for c in comptages}
|
||||||
| {paire.site_id for paire in anciennes}
|
|
||||||
| {c.site_id for c in comptages}
|
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
rapports.append(
|
rapports.append(
|
||||||
@@ -198,14 +193,7 @@ class DriftService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
plafond = _plafond(reference_mae, ratio=seuils.ratio_derive, plancher=seuils.mae_plancher)
|
plafond = _plafond(reference_mae, ratio=seuils.ratio_derive, plancher=seuils.mae_plancher)
|
||||||
if plafond is None:
|
if metriques.mae is not None and plafond is not None and metriques.mae > plafond:
|
||||||
return Verdict(
|
|
||||||
STATUT_INDETERMINE,
|
|
||||||
"Fenêtre de référence sans paire vérifiée, et aucun plancher de MAE : "
|
|
||||||
"rien à quoi comparer cette fenêtre.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if metriques.mae is not None and metriques.mae > plafond:
|
|
||||||
return Verdict(
|
return Verdict(
|
||||||
STATUT_DERIVE,
|
STATUT_DERIVE,
|
||||||
f"MAE de {metriques.mae:.2f} kWh au-delà de {plafond:.2f} kWh, "
|
f"MAE de {metriques.mae:.2f} kWh au-delà de {plafond:.2f} kWh, "
|
||||||
|
|||||||
+23
-23
@@ -213,7 +213,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Cookie de rafraîchissement": []
|
"CookieRafraichissement": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -252,7 +252,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Cookie de rafraîchissement": []
|
"CookieRafraichissement": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -301,7 +301,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -347,7 +347,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -423,7 +423,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -673,7 +673,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -757,7 +757,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -771,7 +771,7 @@
|
|||||||
"operationId": "update_user_api_v1_users__user_id__patch",
|
"operationId": "update_user_api_v1_users__user_id__patch",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@@ -889,7 +889,7 @@
|
|||||||
"operationId": "reset_password_api_v1_users__user_id__password_reset_post",
|
"operationId": "reset_password_api_v1_users__user_id__password_reset_post",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@@ -1023,7 +1023,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1037,7 +1037,7 @@
|
|||||||
"operationId": "get_site_api_v1_sites__site_id__get",
|
"operationId": "get_site_api_v1_sites__site_id__get",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@@ -1124,7 +1124,7 @@
|
|||||||
"operationId": "get_current_api_v1_sites__site_id__current_get",
|
"operationId": "get_current_api_v1_sites__site_id__current_get",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@@ -1211,7 +1211,7 @@
|
|||||||
"operationId": "list_alerts_api_v1_alerts_get",
|
"operationId": "list_alerts_api_v1_alerts_get",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@@ -1361,7 +1361,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1375,7 +1375,7 @@
|
|||||||
"operationId": "get_recommendation_api_v1_recommendations__recommendation_id__get",
|
"operationId": "get_recommendation_api_v1_recommendations__recommendation_id__get",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@@ -1462,7 +1462,7 @@
|
|||||||
"operationId": "generate_recommendations_api_v1_recommendations_generate_post",
|
"operationId": "generate_recommendations_api_v1_recommendations_generate_post",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@@ -1588,7 +1588,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1602,7 +1602,7 @@
|
|||||||
"operationId": "list_readings_api_v1_readings_get",
|
"operationId": "list_readings_api_v1_readings_get",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@@ -1799,7 +1799,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1855,7 +1855,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1869,7 +1869,7 @@
|
|||||||
"operationId": "get_drift_api_v1_monitoring_drift_get",
|
"operationId": "get_drift_api_v1_monitoring_drift_get",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@@ -3487,13 +3487,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"securitySchemes": {
|
"securitySchemes": {
|
||||||
"Cookie de rafraîchissement": {
|
"CookieRafraichissement": {
|
||||||
"type": "apiKey",
|
"type": "apiKey",
|
||||||
"description": "Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers `/api/v1/auth`.",
|
"description": "Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers `/api/v1/auth`.",
|
||||||
"in": "cookie",
|
"in": "cookie",
|
||||||
"name": "ev_refresh"
|
"name": "ev_refresh"
|
||||||
},
|
},
|
||||||
"Jeton d'accès": {
|
"JetonAcces": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"scheme": "bearer"
|
"scheme": "bearer"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ dependencies = [
|
|||||||
"aiosmtplib>=5.1.3",
|
"aiosmtplib>=5.1.3",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"pandas>=3.0.5",
|
"pandas>=3.0.5",
|
||||||
|
"boto3>=1.43.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
@@ -29,6 +30,7 @@ dev = [
|
|||||||
"pytest-asyncio>=1.4.0",
|
"pytest-asyncio>=1.4.0",
|
||||||
"pytest-cov>=7.1.0",
|
"pytest-cov>=7.1.0",
|
||||||
"pandas-stubs>=3.0.5.260914",
|
"pandas-stubs>=3.0.5.260914",
|
||||||
|
"types-boto3[s3]>=1.43.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from app.api.deps import get_current_principal
|
|||||||
from app.core.principal import Principal
|
from app.core.principal import Principal
|
||||||
from app.core.roles import AccountKind, Role
|
from app.core.roles import AccountKind, Role
|
||||||
from app.db.session import get_session_factory
|
from app.db.session import get_session_factory
|
||||||
from app.models.energy import Alert, DriftReport, Prediction, Reading, Recommendation, Site
|
from app.models.energy import Alert, Prediction, Reading, Recommendation, Site
|
||||||
from tests.repositories.test_alert import creer_alerte
|
from tests.repositories.test_alert import creer_alerte
|
||||||
from tests.repositories.test_prediction import creer_prediction
|
from tests.repositories.test_prediction import creer_prediction
|
||||||
from tests.repositories.test_reading import creer_lecture
|
from tests.repositories.test_reading import creer_lecture
|
||||||
@@ -52,7 +52,6 @@ async def _supprime(session: AsyncSession, sites: list[str]) -> None:
|
|||||||
alertes = select(Alert.alert_id).where(Alert.site_id.in_(sites))
|
alertes = select(Alert.alert_id).where(Alert.site_id.in_(sites))
|
||||||
await session.execute(delete(Recommendation).where(Recommendation.alert_id.in_(alertes)))
|
await session.execute(delete(Recommendation).where(Recommendation.alert_id.in_(alertes)))
|
||||||
await session.execute(delete(Alert).where(Alert.site_id.in_(sites)))
|
await session.execute(delete(Alert).where(Alert.site_id.in_(sites)))
|
||||||
await session.execute(delete(DriftReport).where(DriftReport.site_id.in_(sites)))
|
|
||||||
await session.execute(delete(Prediction).where(Prediction.site_id.in_(sites)))
|
await session.execute(delete(Prediction).where(Prediction.site_id.in_(sites)))
|
||||||
await session.execute(delete(Reading).where(Reading.site_id.in_(sites)))
|
await session.execute(delete(Reading).where(Reading.site_id.in_(sites)))
|
||||||
await session.execute(delete(Site).where(Site.site_id.in_(sites)))
|
await session.execute(delete(Site).where(Site.site_id.in_(sites)))
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ async def interroge(
|
|||||||
("x-content-type-options", "nosniff"),
|
("x-content-type-options", "nosniff"),
|
||||||
("x-frame-options", "DENY"),
|
("x-frame-options", "DENY"),
|
||||||
("referrer-policy", "no-referrer"),
|
("referrer-policy", "no-referrer"),
|
||||||
|
("cross-origin-resource-policy", "same-origin"),
|
||||||
],
|
],
|
||||||
ids=["nosniff", "anti_iframe", "referrer"],
|
ids=["nosniff", "anti_iframe", "referrer", "corp"],
|
||||||
)
|
)
|
||||||
async def test_every_response_carries_the_security_headers(
|
async def test_every_response_carries_the_security_headers(
|
||||||
client: AsyncClient, entete: str, valeur: str
|
client: AsyncClient, entete: str, valeur: str
|
||||||
@@ -71,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"}
|
||||||
|
|
||||||
|
|||||||
@@ -227,24 +227,26 @@ async def test_a_real_token_reaches_exactly_the_routes_of_its_rank(
|
|||||||
assert ecarts == []
|
assert ecarts == []
|
||||||
|
|
||||||
|
|
||||||
# Contrainte : `operateur` n'ouvre aujourd'hui aucune route de plus que `lecteur`, faute d'écriture
|
# Contrainte : les deux rangs ne se séparent que sur les routes que `ROLE_MINIMUM` réserve à
|
||||||
# métier dans l'API. Figer l'égalité rend la régression visible le jour où une route d'opérateur
|
# `operateur`. Une route d'opérateur ajoutée sans être classée fait diverger les statuts sans
|
||||||
# arrive sans que `ROLE_MINIMUM` soit mis à jour.
|
# qu'aucune entrée ne l'annonce, et une garde d'opérateur posée par erreur sur une route de
|
||||||
|
# lecture fait diverger ce qui devait rester identique.
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_the_operator_rank_opens_nothing_more_than_the_reader_rank(
|
async def test_the_operator_rank_diverges_from_the_reader_rank_only_where_declared(
|
||||||
comptes_par_role: dict[Role, str], client: AsyncClient
|
comptes_par_role: dict[Role, str], client: AsyncClient
|
||||||
) -> None:
|
) -> None:
|
||||||
lecteur = await authentifie(client, comptes_par_role[Role.LECTEUR])
|
lecteur = await authentifie(client, comptes_par_role[Role.LECTEUR])
|
||||||
operateur = await authentifie(client, comptes_par_role[Role.OPERATEUR])
|
operateur = await authentifie(client, comptes_par_role[Role.OPERATEUR])
|
||||||
divergences: list[tuple[str, str]] = []
|
ecarts: list[tuple[str, str]] = []
|
||||||
|
|
||||||
for methode, chemin in ROLE_MINIMUM:
|
for (methode, chemin), minimum in ROLE_MINIMUM.items():
|
||||||
cote_lecteur = await appelle(client, methode, chemin, headers=lecteur)
|
cote_lecteur = await appelle(client, methode, chemin, headers=lecteur)
|
||||||
cote_operateur = await appelle(client, methode, chemin, headers=operateur)
|
cote_operateur = await appelle(client, methode, chemin, headers=operateur)
|
||||||
if cote_lecteur.status_code != cote_operateur.status_code:
|
diverge = cote_lecteur.status_code != cote_operateur.status_code
|
||||||
divergences.append((methode, chemin))
|
if diverge is not (minimum is Role.OPERATEUR):
|
||||||
|
ecarts.append((methode, chemin))
|
||||||
|
|
||||||
assert divergences == []
|
assert ecarts == []
|
||||||
|
|
||||||
|
|
||||||
# Piège : `/auth/logout-all` prend un `CurrentPrincipalDep` nu, donc elle échappe au gate
|
# Piège : `/auth/logout-all` prend un `CurrentPrincipalDep` nu, donc elle échappe au gate
|
||||||
|
|||||||
@@ -63,8 +63,7 @@ def servi(app: FastAPI) -> Iterator[list[DriftReport]]:
|
|||||||
app.dependency_overrides[get_current_principal] = operateur
|
app.dependency_overrides[get_current_principal] = operateur
|
||||||
app.dependency_overrides[get_drift_service] = lambda: service
|
app.dependency_overrides[get_drift_service] = lambda: service
|
||||||
yield rapports
|
yield rapports
|
||||||
app.dependency_overrides.pop(get_current_principal, None)
|
app.dependency_overrides.clear()
|
||||||
app.dependency_overrides.pop(get_drift_service, None)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_drift_returns_the_latest_report_of_every_site(
|
async def test_drift_returns_the_latest_report_of_every_site(
|
||||||
@@ -87,19 +86,14 @@ async def test_drift_exposes_the_metrics_of_the_stored_report(
|
|||||||
assert premier["model_references"] == ["lightgbm-aaa"]
|
assert premier["model_references"] == ["lightgbm-aaa"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
async def test_drift_returns_an_empty_list_when_no_report_exists(
|
||||||
def sans_rapport(app: FastAPI) -> Iterator[None]:
|
app: FastAPI, client: AsyncClient
|
||||||
|
) -> None:
|
||||||
app.dependency_overrides[get_current_principal] = operateur
|
app.dependency_overrides[get_current_principal] = operateur
|
||||||
app.dependency_overrides[get_drift_service] = lambda: FauxService([])
|
app.dependency_overrides[get_drift_service] = lambda: FauxService([])
|
||||||
yield
|
|
||||||
app.dependency_overrides.pop(get_current_principal, None)
|
|
||||||
app.dependency_overrides.pop(get_drift_service, None)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_drift_returns_an_empty_list_when_no_report_exists(
|
|
||||||
sans_rapport: None, client: AsyncClient
|
|
||||||
) -> None:
|
|
||||||
reponse = await client.get("/api/v1/monitoring/drift")
|
reponse = await client.get("/api/v1/monitoring/drift")
|
||||||
|
|
||||||
assert reponse.status_code == 200
|
assert reponse.status_code == 200
|
||||||
assert reponse.json() == []
|
assert reponse.json() == []
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|||||||
@@ -104,8 +104,8 @@ def test_the_rate_limit_documents_the_delay_header(schema: dict[str, Any]) -> No
|
|||||||
def test_the_refresh_cookie_appears_in_the_security_schemes(schema: dict[str, Any]) -> None:
|
def test_the_refresh_cookie_appears_in_the_security_schemes(schema: dict[str, Any]) -> None:
|
||||||
schemes = schema["components"]["securitySchemes"]
|
schemes = schema["components"]["securitySchemes"]
|
||||||
|
|
||||||
assert schemes["Cookie de rafraîchissement"]["in"] == "cookie"
|
assert schemes["CookieRafraichissement"]["in"] == "cookie"
|
||||||
assert schemes["Cookie de rafraîchissement"]["name"] == "ev_refresh"
|
assert schemes["CookieRafraichissement"]["name"] == "ev_refresh"
|
||||||
|
|
||||||
|
|
||||||
def test_each_tag_used_by_a_route_is_described(schema: dict[str, Any]) -> None:
|
def test_each_tag_used_by_a_route_is_described(schema: dict[str, Any]) -> None:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
@@ -110,8 +110,8 @@ async def test_fetch_readings_sends_expected_query_parameters() -> None:
|
|||||||
|
|
||||||
transport = MockTransport(handler)
|
transport = MockTransport(handler)
|
||||||
|
|
||||||
start_time = datetime.fromisoformat("2024-06-15T12:00:00")
|
start_time = datetime.fromisoformat("2024-06-15T12:00:00+00:00")
|
||||||
end_time = datetime.fromisoformat("2024-06-15T13:00:00")
|
end_time = datetime.fromisoformat("2024-06-15T13:00:00+00:00")
|
||||||
|
|
||||||
async with AsyncClient(
|
async with AsyncClient(
|
||||||
transport=transport,
|
transport=transport,
|
||||||
@@ -127,11 +127,61 @@ async def test_fetch_readings_sends_expected_query_parameters() -> None:
|
|||||||
|
|
||||||
assert len(readings) == 1
|
assert len(readings) == 1
|
||||||
assert captured_params["site_id"] == "SITE001"
|
assert captured_params["site_id"] == "SITE001"
|
||||||
assert captured_params["start_time"] == "2024-06-15T12:00:00"
|
assert captured_params["start_time"] == "2024-06-15T12:00:00+00:00"
|
||||||
assert captured_params["end_time"] == "2024-06-15T13:00:00"
|
assert captured_params["end_time"] == "2024-06-15T13:00:00+00:00"
|
||||||
assert captured_params["limit"] == "60"
|
assert captured_params["limit"] == "60"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetch_readings_discards_a_reading_outside_the_requested_window() -> None:
|
||||||
|
# Le garde-fou `refuse_if_overlaps_historical_dataset` ne vérifie que la fenêtre demandée :
|
||||||
|
# une réponse dont un `timestamp` déborde de `[start_time, end_time)` (bug du mock, ou
|
||||||
|
# hostile) contournerait ce contrôle si elle atteignait la base telle quelle.
|
||||||
|
dans_la_fenetre = make_reading()
|
||||||
|
dans_la_fenetre["timestamp"] = "2024-06-15T12:00:00Z"
|
||||||
|
|
||||||
|
hors_fenetre = make_reading()
|
||||||
|
hors_fenetre["timestamp"] = "2023-01-01T00:00:00Z"
|
||||||
|
|
||||||
|
def handler(request: Request) -> Response:
|
||||||
|
return Response(status_code=200, json=[dans_la_fenetre, hors_fenetre])
|
||||||
|
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=MockTransport(handler),
|
||||||
|
base_url="https://mock.test",
|
||||||
|
) as client:
|
||||||
|
readings = await fetch_readings(
|
||||||
|
client=client,
|
||||||
|
site_id="SITE001",
|
||||||
|
start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"),
|
||||||
|
end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"),
|
||||||
|
limit=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert readings == [dans_la_fenetre]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetch_readings_discards_a_reading_with_an_unparseable_timestamp() -> None:
|
||||||
|
invalide = make_reading()
|
||||||
|
invalide["timestamp"] = "pas une date"
|
||||||
|
|
||||||
|
def handler(request: Request) -> Response:
|
||||||
|
return Response(status_code=200, json=[invalide])
|
||||||
|
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=MockTransport(handler),
|
||||||
|
base_url="https://mock.test",
|
||||||
|
) as client:
|
||||||
|
readings = await fetch_readings(
|
||||||
|
client=client,
|
||||||
|
site_id="SITE001",
|
||||||
|
start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"),
|
||||||
|
end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"),
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert readings == []
|
||||||
|
|
||||||
|
|
||||||
async def test_fetch_readings_rejects_non_list_response() -> None:
|
async def test_fetch_readings_rejects_non_list_response() -> None:
|
||||||
def handler(request: Request) -> Response:
|
def handler(request: Request) -> Response:
|
||||||
return Response(
|
return Response(
|
||||||
@@ -152,8 +202,8 @@ async def test_fetch_readings_rejects_non_list_response() -> None:
|
|||||||
await fetch_readings(
|
await fetch_readings(
|
||||||
client=client,
|
client=client,
|
||||||
site_id="SITE001",
|
site_id="SITE001",
|
||||||
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
|
start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"),
|
||||||
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
|
end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"),
|
||||||
limit=60,
|
limit=60,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -175,8 +225,8 @@ async def test_fetch_readings_raises_on_http_error() -> None:
|
|||||||
await fetch_readings(
|
await fetch_readings(
|
||||||
client=client,
|
client=client,
|
||||||
site_id="SITE999",
|
site_id="SITE999",
|
||||||
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
|
start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"),
|
||||||
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
|
end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"),
|
||||||
limit=60,
|
limit=60,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -283,6 +333,41 @@ def test_create_mock_api_client_requires_credentials(
|
|||||||
mock_api_import.create_mock_api_client()
|
mock_api_import.create_mock_api_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("username", "password_value"),
|
||||||
|
[
|
||||||
|
("", "test-password"),
|
||||||
|
("test-user", ""),
|
||||||
|
(" ", "test-password"),
|
||||||
|
("test-user", " "),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_create_mock_api_client_rejects_empty_credentials(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
username: str,
|
||||||
|
password_value: str,
|
||||||
|
) -> None:
|
||||||
|
password = MagicMock()
|
||||||
|
password.get_secret_value.return_value = password_value
|
||||||
|
|
||||||
|
settings = SimpleNamespace(
|
||||||
|
mock_api_username=username,
|
||||||
|
mock_api_password=password,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mock_api_import,
|
||||||
|
"get_settings",
|
||||||
|
lambda: settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match="Les identifiants de l'API Mock ne sont pas configurés",
|
||||||
|
):
|
||||||
|
mock_api_import.create_mock_api_client()
|
||||||
|
|
||||||
|
|
||||||
async def test_create_mock_api_client_uses_configuration(
|
async def test_create_mock_api_client_uses_configuration(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -322,6 +407,100 @@ async def test_upsert_sites_with_empty_list_does_nothing() -> None:
|
|||||||
connection.execute.assert_not_awaited()
|
connection.execute.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_refuse_if_overlaps_historical_dataset_lets_a_clear_window_through() -> None:
|
||||||
|
connection = AsyncMock()
|
||||||
|
connection.execute.return_value.scalar_one = MagicMock(return_value=0)
|
||||||
|
|
||||||
|
await mock_api_import.refuse_if_overlaps_historical_dataset(
|
||||||
|
connection,
|
||||||
|
datetime.fromisoformat("2026-01-01T00:00:00+00:00"),
|
||||||
|
datetime.fromisoformat("2026-01-01T01:00:00+00:00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.execute.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_refuse_if_overlaps_historical_dataset_rejects_a_window_already_in_the_csv() -> None:
|
||||||
|
connection = AsyncMock()
|
||||||
|
connection.execute.return_value.scalar_one = MagicMock(return_value=5)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="doublon inter-source"):
|
||||||
|
await mock_api_import.refuse_if_overlaps_historical_dataset(
|
||||||
|
connection,
|
||||||
|
datetime.fromisoformat("2023-06-15T12:00:00+00:00"),
|
||||||
|
datetime.fromisoformat("2023-06-15T13:00:00+00:00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_limit_for_window_returns_one_per_hour() -> None:
|
||||||
|
limite = mock_api_import.limit_for_window(
|
||||||
|
datetime.fromisoformat("2026-09-02T12:00:00+00:00"),
|
||||||
|
datetime.fromisoformat("2026-09-23T12:00:00+00:00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert limite == 21 * 24
|
||||||
|
|
||||||
|
|
||||||
|
def test_limit_for_window_falls_back_to_one_reading_under_an_hour() -> None:
|
||||||
|
# Le DAG horaire (`:45`) demande desormais [heure pile precedente, instant du declenchement) :
|
||||||
|
# une fenetre plus courte qu'une heure, dont l'espacement `duree/limit` ne peut jamais valoir
|
||||||
|
# 1h pile pour plus d'une lecture. Seule `limit=1`, ancree sur `start_time`, reste alignee.
|
||||||
|
limite = mock_api_import.limit_for_window(
|
||||||
|
datetime.fromisoformat("2026-09-02T12:00:00+00:00"),
|
||||||
|
datetime.fromisoformat("2026-09-02T12:45:00+00:00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert limite == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_limit_for_window_falls_back_to_one_reading_for_a_non_whole_hour_span() -> None:
|
||||||
|
# Meme raisonnement pour une fenetre de plus d'une heure mais qui n'en est pas un multiple
|
||||||
|
# entier : aucun `limit > 1` ne donnerait un espacement d'1h pile.
|
||||||
|
limite = mock_api_import.limit_for_window(
|
||||||
|
datetime.fromisoformat("2026-09-02T12:00:00+00:00"),
|
||||||
|
datetime.fromisoformat("2026-09-02T13:30:00+00:00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert limite == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_limit_for_window_rejects_a_start_time_not_on_the_hour() -> None:
|
||||||
|
with pytest.raises(ValueError, match="pile sur l'heure"):
|
||||||
|
mock_api_import.limit_for_window(
|
||||||
|
datetime.fromisoformat("2026-09-02T12:05:00+00:00"),
|
||||||
|
datetime.fromisoformat("2026-09-02T13:05:00+00:00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_limit_for_window_rejects_a_window_above_the_api_cap() -> None:
|
||||||
|
with pytest.raises(ValueError, match="au-delà du plafond"):
|
||||||
|
mock_api_import.limit_for_window(
|
||||||
|
datetime.fromisoformat("2020-01-01T00:00:00+00:00"),
|
||||||
|
datetime.fromisoformat("2020-03-01T00:00:00+00:00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_engine(*, overlap_count: int = 0) -> tuple[MagicMock, AsyncMock]:
|
||||||
|
"""Engine dont `.connect()` (garde-fou) et `.begin()` (écriture) rendent tous deux la même
|
||||||
|
connexion, dont `scalar_one()` renvoie `overlap_count` : `import_mock_api_history` ouvre
|
||||||
|
désormais le garde-fou via `.connect()`, y compris en dry-run."""
|
||||||
|
connection = AsyncMock()
|
||||||
|
connection.execute.return_value.scalar_one = MagicMock(return_value=overlap_count)
|
||||||
|
|
||||||
|
def _context() -> MagicMock:
|
||||||
|
context = MagicMock()
|
||||||
|
context.__aenter__ = AsyncMock(return_value=connection)
|
||||||
|
context.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
return context
|
||||||
|
|
||||||
|
engine = MagicMock()
|
||||||
|
engine.connect.return_value = _context()
|
||||||
|
engine.begin.return_value = _context()
|
||||||
|
engine.dispose = AsyncMock()
|
||||||
|
|
||||||
|
return engine, connection
|
||||||
|
|
||||||
|
|
||||||
async def test_import_mock_api_history_dry_run_does_not_write(
|
async def test_import_mock_api_history_dry_run_does_not_write(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -361,22 +540,24 @@ async def test_import_mock_api_history_dry_run_does_not_write(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
create_engine_mock = MagicMock()
|
engine, connection = _mock_engine(overlap_count=0)
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
mock_api_import,
|
mock_api_import,
|
||||||
"create_async_engine",
|
"create_async_engine",
|
||||||
create_engine_mock,
|
MagicMock(return_value=engine),
|
||||||
)
|
)
|
||||||
|
|
||||||
await mock_api_import.import_mock_api_history(
|
await mock_api_import.import_mock_api_history(
|
||||||
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
|
start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"),
|
||||||
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
|
end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"),
|
||||||
limit=60,
|
|
||||||
dry_run=True,
|
dry_run=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
create_engine_mock.assert_not_called()
|
# Le garde-fou tourne quand même (lecture seule), mais aucune écriture n'a lieu.
|
||||||
|
connection.execute.assert_awaited_once()
|
||||||
|
engine.begin.assert_not_called()
|
||||||
|
engine.dispose.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
async def test_import_mock_api_history_loads_data(
|
async def test_import_mock_api_history_loads_data(
|
||||||
@@ -418,23 +599,8 @@ async def test_import_mock_api_history_loads_data(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
connection = AsyncMock()
|
engine, connection = _mock_engine(overlap_count=0)
|
||||||
|
create_engine_mock = MagicMock(return_value=engine)
|
||||||
transaction_context = MagicMock()
|
|
||||||
transaction_context.__aenter__ = AsyncMock(
|
|
||||||
return_value=connection,
|
|
||||||
)
|
|
||||||
transaction_context.__aexit__ = AsyncMock(
|
|
||||||
return_value=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
engine = MagicMock()
|
|
||||||
engine.begin.return_value = transaction_context
|
|
||||||
engine.dispose = AsyncMock()
|
|
||||||
|
|
||||||
create_engine_mock = MagicMock(
|
|
||||||
return_value=engine,
|
|
||||||
)
|
|
||||||
|
|
||||||
upsert_sites_mock = AsyncMock()
|
upsert_sites_mock = AsyncMock()
|
||||||
|
|
||||||
@@ -451,9 +617,8 @@ async def test_import_mock_api_history_loads_data(
|
|||||||
)
|
)
|
||||||
|
|
||||||
await mock_api_import.import_mock_api_history(
|
await mock_api_import.import_mock_api_history(
|
||||||
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
|
start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"),
|
||||||
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
|
end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"),
|
||||||
limit=60,
|
|
||||||
dry_run=False,
|
dry_run=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -467,7 +632,39 @@ async def test_import_mock_api_history_loads_data(
|
|||||||
[make_site()],
|
[make_site()],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Un appel pour le garde-fou (via .connect()), un pour READING_INSERT (via .begin()).
|
||||||
|
assert connection.execute.await_count == 2
|
||||||
|
dernier_appel = connection.execute.await_args_list[-1]
|
||||||
|
assert dernier_appel.args[0] is READING_INSERT
|
||||||
|
engine.dispose.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_mock_api_history_refuses_when_it_overlaps_the_historical_dataset(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mock_api_import,
|
||||||
|
"get_settings",
|
||||||
|
lambda: SimpleNamespace(database_url="postgresql+asyncpg://test:test@localhost/test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
engine, connection = _mock_engine(overlap_count=3)
|
||||||
|
monkeypatch.setattr(mock_api_import, "create_async_engine", MagicMock(return_value=engine))
|
||||||
|
|
||||||
|
# Le garde-fou tourne avant tout appel à l'API Mock : create_mock_api_client() ne doit
|
||||||
|
# jamais être invoqué pour une fenêtre refusée.
|
||||||
|
create_client_mock = MagicMock()
|
||||||
|
monkeypatch.setattr(mock_api_import, "create_mock_api_client", create_client_mock)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="doublon inter-source"):
|
||||||
|
await mock_api_import.import_mock_api_history(
|
||||||
|
start_time=datetime.fromisoformat("2023-06-15T12:00:00+00:00"),
|
||||||
|
end_time=datetime.fromisoformat("2023-06-15T13:00:00+00:00"),
|
||||||
|
dry_run=False,
|
||||||
|
)
|
||||||
|
|
||||||
connection.execute.assert_awaited_once()
|
connection.execute.assert_awaited_once()
|
||||||
|
create_client_mock.assert_not_called()
|
||||||
engine.dispose.assert_awaited_once()
|
engine.dispose.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
@@ -481,6 +678,23 @@ def test_parse_datetime_accepts_z_suffix() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_datetime_attaches_utc_to_a_naive_string() -> None:
|
||||||
|
# `--start-time`/`--end-time` du DAG sont formatés sans fuseau (Jinja `strftime`) : sans ce
|
||||||
|
# comportement, l'encodeur `timestamptz` d'asyncpg lirait le datetime naïf dans le fuseau
|
||||||
|
# *local du processus*, pas UTC, et le garde-fou comparerait une autre fenêtre que celle
|
||||||
|
# envoyée à l'API.
|
||||||
|
result = mock_api_import.parse_datetime("2024-06-15T12:00:00")
|
||||||
|
|
||||||
|
assert result == datetime.fromisoformat("2024-06-15T12:00:00+00:00")
|
||||||
|
assert result.tzinfo is UTC
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_datetime_keeps_a_non_utc_offset_as_is() -> None:
|
||||||
|
result = mock_api_import.parse_datetime("2024-06-15T12:00:00+02:00")
|
||||||
|
|
||||||
|
assert result == datetime.fromisoformat("2024-06-15T12:00:00+02:00")
|
||||||
|
|
||||||
|
|
||||||
def test_parse_args_reads_cli_parameters(
|
def test_parse_args_reads_cli_parameters(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -493,8 +707,6 @@ def test_parse_args_reads_cli_parameters(
|
|||||||
"2024-06-15T12:00:00Z",
|
"2024-06-15T12:00:00Z",
|
||||||
"--end-time",
|
"--end-time",
|
||||||
"2024-06-15T13:00:00Z",
|
"2024-06-15T13:00:00Z",
|
||||||
"--limit",
|
|
||||||
"60",
|
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -507,34 +719,9 @@ def test_parse_args_reads_cli_parameters(
|
|||||||
assert args.end_time == datetime.fromisoformat(
|
assert args.end_time == datetime.fromisoformat(
|
||||||
"2024-06-15T13:00:00+00:00",
|
"2024-06-15T13:00:00+00:00",
|
||||||
)
|
)
|
||||||
assert args.limit == 60
|
|
||||||
assert args.dry_run is True
|
assert args.dry_run is True
|
||||||
|
|
||||||
|
|
||||||
def test_main_rejects_limit_out_of_bounds(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.setattr(
|
|
||||||
sys,
|
|
||||||
"argv",
|
|
||||||
[
|
|
||||||
"mock_api_import",
|
|
||||||
"--start-time",
|
|
||||||
"2024-06-15T12:00:00Z",
|
|
||||||
"--end-time",
|
|
||||||
"2024-06-15T13:00:00Z",
|
|
||||||
"--limit",
|
|
||||||
"0",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(
|
|
||||||
ValueError,
|
|
||||||
match="--limit doit être compris entre 1 et 1000",
|
|
||||||
):
|
|
||||||
mock_api_import.main()
|
|
||||||
|
|
||||||
|
|
||||||
def test_main_rejects_invalid_period(
|
def test_main_rejects_invalid_period(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -547,8 +734,6 @@ def test_main_rejects_invalid_period(
|
|||||||
"2024-06-15T14:00:00Z",
|
"2024-06-15T14:00:00Z",
|
||||||
"--end-time",
|
"--end-time",
|
||||||
"2024-06-15T13:00:00Z",
|
"2024-06-15T13:00:00Z",
|
||||||
"--limit",
|
|
||||||
"60",
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -577,7 +762,6 @@ def test_main_runs_import(
|
|||||||
lambda: SimpleNamespace(
|
lambda: SimpleNamespace(
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
end_time=end_time,
|
end_time=end_time,
|
||||||
limit=60,
|
|
||||||
dry_run=True,
|
dry_run=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -593,7 +777,6 @@ def test_main_runs_import(
|
|||||||
import_mock.assert_awaited_once_with(
|
import_mock.assert_awaited_once_with(
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
end_time=end_time,
|
end_time=end_time,
|
||||||
limit=60,
|
|
||||||
dry_run=True,
|
dry_run=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -638,8 +821,8 @@ async def test_fetch_readings_rejects_a_response_above_the_requested_limit() ->
|
|||||||
await fetch_readings(
|
await fetch_readings(
|
||||||
client=client,
|
client=client,
|
||||||
site_id="SITE001",
|
site_id="SITE001",
|
||||||
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
|
start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"),
|
||||||
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
|
end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"),
|
||||||
limit=2,
|
limit=2,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,710 @@
|
|||||||
|
import base64
|
||||||
|
import io
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import UTC, datetime, timedelta, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
from botocore.response import StreamingBody
|
||||||
|
from botocore.stub import Stubber
|
||||||
|
from pydantic import SecretStr
|
||||||
|
from tests.factories import make_settings
|
||||||
|
|
||||||
|
import app.etl.reading_retention as reading_retention
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.etl.reading_retention import (
|
||||||
|
CHUNK_ROWS,
|
||||||
|
DROP_CHUNK,
|
||||||
|
ELIGIBLE_CHUNKS,
|
||||||
|
ArchiveStore,
|
||||||
|
Chunk,
|
||||||
|
Rapport,
|
||||||
|
archive_reading_chunks,
|
||||||
|
build_archive_store,
|
||||||
|
build_parser,
|
||||||
|
decode_sse_key,
|
||||||
|
drop_chunk,
|
||||||
|
eligible_chunks,
|
||||||
|
object_key,
|
||||||
|
read_chunk_rows,
|
||||||
|
serialize_csv_gzip,
|
||||||
|
sha256_of,
|
||||||
|
)
|
||||||
|
|
||||||
|
CLE_SSE = b"0123456789abcdef0123456789abcdef"
|
||||||
|
CLE_SSE_BASE64 = base64.b64encode(CLE_SSE).decode("ascii")
|
||||||
|
|
||||||
|
CHUNK = Chunk(
|
||||||
|
schema="_timescaledb_internal",
|
||||||
|
name="_hyper_1_7_chunk",
|
||||||
|
range_start=datetime(2023, 1, 5, tzinfo=UTC),
|
||||||
|
range_end=datetime(2023, 1, 12, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
CLE_ATTENDUE = "reading/2023/reading_20230105T000000Z_20230112T000000Z.csv.gz"
|
||||||
|
|
||||||
|
|
||||||
|
def make_row(**overrides: Any) -> dict[str, Any]:
|
||||||
|
ligne: dict[str, Any] = {
|
||||||
|
"reading_id": 1,
|
||||||
|
"site_id": "SITE001",
|
||||||
|
"timestamp": datetime(2023, 1, 5, 12, tzinfo=UTC),
|
||||||
|
"source": "csv",
|
||||||
|
"dataset_id": 1,
|
||||||
|
"consumption_kw": Decimal("87.34"),
|
||||||
|
"data_quality": "good",
|
||||||
|
"null_reasons": ["sensor_offline"],
|
||||||
|
"imputed_values": None,
|
||||||
|
"raw_data": {"b": 1, "a": "é"},
|
||||||
|
}
|
||||||
|
return {**ligne, **overrides}
|
||||||
|
|
||||||
|
|
||||||
|
def settings_s3(**overrides: Any) -> Settings:
|
||||||
|
reglages: dict[str, Any] = {
|
||||||
|
"_env_file": None,
|
||||||
|
"secret_key": SecretStr("secret-de-test-assez-long-pour-le-validateur"),
|
||||||
|
"database_url": "postgresql+asyncpg://retention:test@localhost:5432/enervision",
|
||||||
|
"s3_endpoint_url": "http://garage:3900",
|
||||||
|
"s3_access_key": "GK0123456789",
|
||||||
|
"s3_secret_key": SecretStr("un-secret-garage"),
|
||||||
|
"s3_bucket": "enervision-archives",
|
||||||
|
"s3_sse_key": SecretStr(CLE_SSE_BASE64),
|
||||||
|
}
|
||||||
|
return Settings(**{**reglages, **overrides})
|
||||||
|
|
||||||
|
|
||||||
|
def s3_client() -> Any:
|
||||||
|
return boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url="http://garage:3900",
|
||||||
|
aws_access_key_id="GK0123456789",
|
||||||
|
aws_secret_access_key="un-secret-garage",
|
||||||
|
region_name="garage",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def streaming(data: bytes) -> StreamingBody:
|
||||||
|
return StreamingBody(io.BytesIO(data), len(data))
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_treat_empty_s3_values_as_absent() -> None:
|
||||||
|
settings = make_settings(
|
||||||
|
s3_endpoint_url="", s3_access_key="", s3_secret_key="", s3_bucket="", s3_sse_key=""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert settings.s3_endpoint_url is None
|
||||||
|
assert settings.s3_access_key is None
|
||||||
|
assert settings.s3_secret_key is None
|
||||||
|
assert settings.s3_bucket is None
|
||||||
|
assert settings.s3_sse_key is None
|
||||||
|
assert settings.reading_retention_days == 1095
|
||||||
|
|
||||||
|
|
||||||
|
def test_object_key_places_the_chunk_under_the_year_of_its_start() -> None:
|
||||||
|
assert object_key(CHUNK) == CLE_ATTENDUE
|
||||||
|
|
||||||
|
|
||||||
|
def test_object_key_expresses_the_bounds_in_utc() -> None:
|
||||||
|
paris = timezone(timedelta(hours=1))
|
||||||
|
chunk = Chunk(
|
||||||
|
schema=CHUNK.schema,
|
||||||
|
name=CHUNK.name,
|
||||||
|
range_start=datetime(2023, 1, 5, 1, tzinfo=paris),
|
||||||
|
range_end=datetime(2023, 1, 12, 1, tzinfo=paris),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert object_key(chunk) == CLE_ATTENDUE
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_csv_gzip_is_read_back_by_pandas() -> None:
|
||||||
|
archive = serialize_csv_gzip([make_row(), make_row(reading_id=2, null_reasons=[])])
|
||||||
|
|
||||||
|
relu = pd.read_csv(io.BytesIO(archive), compression="gzip")
|
||||||
|
|
||||||
|
assert list(relu.columns) == list(make_row())
|
||||||
|
assert relu["reading_id"].tolist() == [1, 2]
|
||||||
|
assert relu["site_id"].tolist() == ["SITE001", "SITE001"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_csv_gzip_writes_jsonb_and_arrays_as_sorted_json() -> None:
|
||||||
|
archive = serialize_csv_gzip([make_row()])
|
||||||
|
|
||||||
|
relu = pd.read_csv(io.BytesIO(archive), compression="gzip")
|
||||||
|
|
||||||
|
assert relu.loc[0, "raw_data"] == '{"a": "é", "b": 1}'
|
||||||
|
assert relu.loc[0, "null_reasons"] == '["sensor_offline"]'
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_csv_gzip_is_byte_for_byte_reproducible() -> None:
|
||||||
|
lignes = [make_row(), make_row(reading_id=2)]
|
||||||
|
|
||||||
|
premier = serialize_csv_gzip(lignes)
|
||||||
|
second = serialize_csv_gzip(lignes)
|
||||||
|
|
||||||
|
assert premier == second
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_csv_gzip_refuses_an_empty_export() -> None:
|
||||||
|
with pytest.raises(ValueError, match="Aucune ligne"):
|
||||||
|
serialize_csv_gzip([])
|
||||||
|
|
||||||
|
|
||||||
|
def test_sha256_of_hashes_the_bytes() -> None:
|
||||||
|
assert sha256_of(b"hello").startswith("2cf24dba")
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_put_sends_the_sse_c_headers_when_a_key_is_set() -> None:
|
||||||
|
client = s3_client()
|
||||||
|
store = ArchiveStore(client, bucket="enervision-archives", sse_key=CLE_SSE)
|
||||||
|
|
||||||
|
with Stubber(client) as stub:
|
||||||
|
stub.add_response(
|
||||||
|
"put_object",
|
||||||
|
{},
|
||||||
|
expected_params={
|
||||||
|
"Bucket": "enervision-archives",
|
||||||
|
"Key": CLE_ATTENDUE,
|
||||||
|
"Body": b"corps",
|
||||||
|
"ContentType": "text/csv",
|
||||||
|
"ContentEncoding": "gzip",
|
||||||
|
"Metadata": {"sha256": "abc"},
|
||||||
|
"SSECustomerAlgorithm": "AES256",
|
||||||
|
"SSECustomerKey": CLE_SSE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
store.put(CLE_ATTENDUE, b"corps", {"sha256": "abc"})
|
||||||
|
stub.assert_no_pending_responses()
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_put_omits_the_sse_c_headers_without_a_key() -> None:
|
||||||
|
client = s3_client()
|
||||||
|
store = ArchiveStore(client, bucket="enervision-archives", sse_key=None)
|
||||||
|
|
||||||
|
with Stubber(client) as stub:
|
||||||
|
stub.add_response(
|
||||||
|
"put_object",
|
||||||
|
{},
|
||||||
|
expected_params={
|
||||||
|
"Bucket": "enervision-archives",
|
||||||
|
"Key": CLE_ATTENDUE,
|
||||||
|
"Body": b"corps",
|
||||||
|
"ContentType": "text/csv",
|
||||||
|
"ContentEncoding": "gzip",
|
||||||
|
"Metadata": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
store.put(CLE_ATTENDUE, b"corps", {})
|
||||||
|
stub.assert_no_pending_responses()
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_fetch_sha256_hashes_the_object_read_with_the_key() -> None:
|
||||||
|
client = s3_client()
|
||||||
|
store = ArchiveStore(client, bucket="enervision-archives", sse_key=CLE_SSE)
|
||||||
|
|
||||||
|
with Stubber(client) as stub:
|
||||||
|
stub.add_response(
|
||||||
|
"get_object",
|
||||||
|
{"Body": streaming(b"hello")},
|
||||||
|
expected_params={
|
||||||
|
"Bucket": "enervision-archives",
|
||||||
|
"Key": CLE_ATTENDUE,
|
||||||
|
"SSECustomerAlgorithm": "AES256",
|
||||||
|
"SSECustomerKey": CLE_SSE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert store.fetch_sha256(CLE_ATTENDUE) == sha256_of(b"hello")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("code", "statut"),
|
||||||
|
[("NoSuchKey", 404), ("NotFound", 404), ("NoSuchKey", 400)],
|
||||||
|
ids=["no_such_key", "404_sans_code_connu", "no_such_key_sans_404"],
|
||||||
|
)
|
||||||
|
def test_store_fetch_sha256_returns_none_for_a_missing_object(code: str, statut: int) -> None:
|
||||||
|
client = s3_client()
|
||||||
|
store = ArchiveStore(client, bucket="enervision-archives", sse_key=None)
|
||||||
|
|
||||||
|
with Stubber(client) as stub:
|
||||||
|
stub.add_client_error("get_object", service_error_code=code, http_status_code=statut)
|
||||||
|
|
||||||
|
assert store.fetch_sha256(CLE_ATTENDUE) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_fetch_sha256_raises_any_other_error() -> None:
|
||||||
|
client = s3_client()
|
||||||
|
store = ArchiveStore(client, bucket="enervision-archives", sse_key=None)
|
||||||
|
|
||||||
|
with Stubber(client) as stub:
|
||||||
|
stub.add_client_error("get_object", service_error_code="AccessDenied", http_status_code=403)
|
||||||
|
|
||||||
|
with pytest.raises(ClientError):
|
||||||
|
store.fetch_sha256(CLE_ATTENDUE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_sse_key_returns_none_without_a_key() -> None:
|
||||||
|
assert decode_sse_key(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_sse_key_decodes_the_base64_key() -> None:
|
||||||
|
assert decode_sse_key(SecretStr(CLE_SSE_BASE64)) == CLE_SSE
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_sse_key_refuses_a_key_of_the_wrong_length() -> None:
|
||||||
|
courte = SecretStr(base64.b64encode(b"trop-courte").decode("ascii"))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="exactement 32 octets"):
|
||||||
|
decode_sse_key(courte)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"manquant",
|
||||||
|
["s3_endpoint_url", "s3_access_key", "s3_secret_key", "s3_bucket"],
|
||||||
|
)
|
||||||
|
def test_build_archive_store_refuses_a_missing_setting(manquant: str) -> None:
|
||||||
|
reglages = settings_s3(**{manquant: None})
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="APP_S3_ENDPOINT_URL"):
|
||||||
|
build_archive_store(reglages)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_archive_store_refuses_a_sse_key_of_the_wrong_length() -> None:
|
||||||
|
courte = base64.b64encode(b"trop-courte").decode("ascii")
|
||||||
|
reglages = settings_s3(s3_sse_key=SecretStr(courte))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="exactement 32 octets"):
|
||||||
|
build_archive_store(reglages)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_archive_store_configures_the_client_from_the_settings(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
recu: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def faux_client(service: str, **kwargs: Any) -> MagicMock:
|
||||||
|
recu["service"] = service
|
||||||
|
recu.update(kwargs)
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
monkeypatch.setattr(reading_retention.boto3, "client", faux_client)
|
||||||
|
|
||||||
|
build_archive_store(settings_s3())
|
||||||
|
|
||||||
|
assert recu == {
|
||||||
|
"service": "s3",
|
||||||
|
"endpoint_url": "http://garage:3900",
|
||||||
|
"aws_access_key_id": "GK0123456789",
|
||||||
|
"aws_secret_access_key": "un-secret-garage",
|
||||||
|
"region_name": "garage",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_archive_store_uses_the_bucket_and_the_decoded_key() -> None:
|
||||||
|
store = build_archive_store(settings_s3())
|
||||||
|
|
||||||
|
with Stubber(store._client) as stub:
|
||||||
|
stub.add_response(
|
||||||
|
"get_object",
|
||||||
|
{"Body": streaming(b"hello")},
|
||||||
|
expected_params={
|
||||||
|
"Bucket": "enervision-archives",
|
||||||
|
"Key": CLE_ATTENDUE,
|
||||||
|
"SSECustomerAlgorithm": "AES256",
|
||||||
|
"SSECustomerKey": CLE_SSE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert store.fetch_sha256(CLE_ATTENDUE) == sha256_of(b"hello")
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_archive_store_accepts_an_absent_sse_key() -> None:
|
||||||
|
store = build_archive_store(settings_s3(s3_sse_key=None))
|
||||||
|
|
||||||
|
with Stubber(store._client) as stub:
|
||||||
|
stub.add_response(
|
||||||
|
"get_object",
|
||||||
|
{"Body": streaming(b"hello")},
|
||||||
|
expected_params={"Bucket": "enervision-archives", "Key": CLE_ATTENDUE},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert store.fetch_sha256(CLE_ATTENDUE) == sha256_of(b"hello")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResult:
|
||||||
|
def __init__(self, rows: list[Any]) -> None:
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def mappings(self) -> FakeResult:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def scalars(self) -> FakeResult:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def all(self) -> list[Any]:
|
||||||
|
return self._rows
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_mapping(chunk: Chunk) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"chunk_schema": chunk.schema,
|
||||||
|
"chunk_name": chunk.name,
|
||||||
|
"range_start": chunk.range_start,
|
||||||
|
"range_end": chunk.range_end,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_eligible_chunks_queries_the_timescaledb_catalog() -> None:
|
||||||
|
conn = AsyncMock()
|
||||||
|
conn.execute.return_value = FakeResult([chunk_mapping(CHUNK)])
|
||||||
|
borne = datetime(2023, 10, 1, tzinfo=UTC)
|
||||||
|
|
||||||
|
chunks = await eligible_chunks(conn, borne)
|
||||||
|
|
||||||
|
assert chunks == [CHUNK]
|
||||||
|
statement, params = conn.execute.await_args.args
|
||||||
|
assert statement is ELIGIBLE_CHUNKS
|
||||||
|
assert params == {"older_than": borne}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_read_chunk_rows_reads_through_the_hypertable_within_the_chunk_bounds() -> None:
|
||||||
|
conn = AsyncMock()
|
||||||
|
conn.execute.return_value = FakeResult([make_row(), make_row(reading_id=2)])
|
||||||
|
|
||||||
|
lignes = await read_chunk_rows(conn, CHUNK)
|
||||||
|
|
||||||
|
assert lignes == [make_row(), make_row(reading_id=2)]
|
||||||
|
statement, params = conn.execute.await_args.args
|
||||||
|
assert statement is CHUNK_ROWS
|
||||||
|
assert params == {"start": CHUNK.range_start, "end": CHUNK.range_end}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drop_chunk_targets_the_chunk_by_its_own_bounds() -> None:
|
||||||
|
conn = AsyncMock()
|
||||||
|
conn.execute.return_value = FakeResult([CHUNK.qualified_name])
|
||||||
|
|
||||||
|
await drop_chunk(conn, CHUNK)
|
||||||
|
|
||||||
|
statement, params = conn.execute.await_args.args
|
||||||
|
assert statement is DROP_CHUNK
|
||||||
|
assert params == {"start": CHUNK.range_start, "end": CHUNK.range_end}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"rendu",
|
||||||
|
[[], ["_timescaledb_internal._hyper_1_7_chunk", "_timescaledb_internal._hyper_1_8_chunk"]],
|
||||||
|
ids=["aucun_chunk", "deux_chunks"],
|
||||||
|
)
|
||||||
|
async def test_drop_chunk_raises_unless_exactly_the_chunk_was_dropped(rendu: list[str]) -> None:
|
||||||
|
conn = AsyncMock()
|
||||||
|
conn.execute.return_value = FakeResult(rendu)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match=r"exactement _timescaledb_internal\._hyper_1_7_chunk"):
|
||||||
|
await drop_chunk(conn, CHUNK)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConn:
|
||||||
|
def __init__(self, journal: list[str], chunks: list[Chunk], rows: list[dict[str, Any]]) -> None:
|
||||||
|
self._journal = journal
|
||||||
|
self._chunks = chunks
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
async def execute(self, statement: Any, params: dict[str, Any]) -> FakeResult:
|
||||||
|
if statement is ELIGIBLE_CHUNKS:
|
||||||
|
self._journal.append("lister")
|
||||||
|
return FakeResult([chunk_mapping(chunk) for chunk in self._chunks])
|
||||||
|
if statement is CHUNK_ROWS:
|
||||||
|
self._journal.append("lire")
|
||||||
|
return FakeResult(self._rows)
|
||||||
|
self._journal.append("drop")
|
||||||
|
chunk = next(c for c in self._chunks if c.range_start == params["start"])
|
||||||
|
return FakeResult([chunk.qualified_name])
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEngine:
|
||||||
|
def __init__(self, conn: FakeConn, journal: list[str]) -> None:
|
||||||
|
self._conn = conn
|
||||||
|
self._journal = journal
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def connect(self) -> AsyncIterator[FakeConn]:
|
||||||
|
self._journal.append("connect")
|
||||||
|
yield self._conn
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def begin(self) -> AsyncIterator[FakeConn]:
|
||||||
|
self._journal.append("begin")
|
||||||
|
yield self._conn
|
||||||
|
|
||||||
|
async def dispose(self) -> None:
|
||||||
|
self._journal.append("dispose")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStore(ArchiveStore):
|
||||||
|
def __init__(self, journal: list[str], *, corrompt: bool = False) -> None:
|
||||||
|
super().__init__(MagicMock(), bucket="enervision-archives", sse_key=None)
|
||||||
|
self._journal = journal
|
||||||
|
self._corrompt = corrompt
|
||||||
|
self.objets: dict[str, str] = {}
|
||||||
|
self.metadata: dict[str, dict[str, str]] = {}
|
||||||
|
|
||||||
|
def put(self, key: str, body: bytes, metadata: dict[str, str]) -> None:
|
||||||
|
self._journal.append("put")
|
||||||
|
self.objets[key] = "sha-corrompu" if self._corrompt else sha256_of(body)
|
||||||
|
self.metadata[key] = metadata
|
||||||
|
|
||||||
|
def fetch_sha256(self, key: str) -> str | None:
|
||||||
|
self._journal.append("relire")
|
||||||
|
return self.objets.get(key)
|
||||||
|
|
||||||
|
|
||||||
|
def make_archive(
|
||||||
|
chunks: list[Chunk] | None = None,
|
||||||
|
rows: list[dict[str, Any]] | None = None,
|
||||||
|
*,
|
||||||
|
corrompt: bool = False,
|
||||||
|
) -> tuple[FakeEngine, FakeStore, list[str]]:
|
||||||
|
journal: list[str] = []
|
||||||
|
lignes = [make_row(), make_row(reading_id=2)] if rows is None else rows
|
||||||
|
eligibles = [CHUNK] if chunks is None else chunks
|
||||||
|
engine = FakeEngine(FakeConn(journal, eligibles, lignes), journal)
|
||||||
|
return engine, FakeStore(journal, corrompt=corrompt), journal
|
||||||
|
|
||||||
|
|
||||||
|
async def test_archive_reading_chunks_reads_exports_verifies_then_drops(
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
engine, store, journal = make_archive()
|
||||||
|
borne = datetime(2023, 10, 1, tzinfo=UTC)
|
||||||
|
|
||||||
|
rapport = await archive_reading_chunks(engine, store, older_than=borne, dry_run=False)
|
||||||
|
|
||||||
|
assert journal == [
|
||||||
|
"connect",
|
||||||
|
"lister",
|
||||||
|
"connect",
|
||||||
|
"lire",
|
||||||
|
"relire",
|
||||||
|
"put",
|
||||||
|
"relire",
|
||||||
|
"begin",
|
||||||
|
"drop",
|
||||||
|
]
|
||||||
|
assert rapport == Rapport(chunks_vus=1, exportes=1, deja_presents=0, supprimes=1, lignes=2)
|
||||||
|
assert store.objets[CLE_ATTENDUE] == sha256_of(
|
||||||
|
serialize_csv_gzip([make_row(), make_row(reading_id=2)])
|
||||||
|
)
|
||||||
|
assert store.metadata[CLE_ATTENDUE] == {"sha256": store.objets[CLE_ATTENDUE], "rows": "2"}
|
||||||
|
|
||||||
|
sortie = capsys.readouterr().out
|
||||||
|
assert f"{CLE_ATTENDUE} : 2 ligne(s)" in sortie
|
||||||
|
assert "exportés et relus" in sortie
|
||||||
|
assert f"chunk {CHUNK.qualified_name} supprimé" in sortie
|
||||||
|
assert "Archivage terminé." in sortie
|
||||||
|
|
||||||
|
|
||||||
|
async def test_archive_reading_chunks_skips_the_upload_when_the_object_already_matches() -> None:
|
||||||
|
engine, store, journal = make_archive()
|
||||||
|
store.objets[CLE_ATTENDUE] = sha256_of(serialize_csv_gzip([make_row(), make_row(reading_id=2)]))
|
||||||
|
|
||||||
|
rapport = await archive_reading_chunks(
|
||||||
|
engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "put" not in journal
|
||||||
|
assert journal[-2:] == ["begin", "drop"]
|
||||||
|
assert rapport == Rapport(chunks_vus=1, exportes=0, deja_presents=1, supprimes=1, lignes=2)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_archive_reading_chunks_re_uploads_when_the_stored_object_differs() -> None:
|
||||||
|
engine, store, journal = make_archive()
|
||||||
|
store.objets[CLE_ATTENDUE] = "un-autre-sha"
|
||||||
|
|
||||||
|
rapport = await archive_reading_chunks(
|
||||||
|
engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert journal.count("put") == 1
|
||||||
|
assert rapport.exportes == 1
|
||||||
|
assert rapport.deja_presents == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_archive_reading_chunks_in_dry_run_neither_writes_nor_drops(
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
engine, store, journal = make_archive()
|
||||||
|
|
||||||
|
rapport = await archive_reading_chunks(
|
||||||
|
engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "put" not in journal
|
||||||
|
assert "begin" not in journal
|
||||||
|
assert "drop" not in journal
|
||||||
|
assert rapport == Rapport(chunks_vus=1, exportes=0, deja_presents=0, supprimes=0, lignes=2)
|
||||||
|
|
||||||
|
sortie = capsys.readouterr().out
|
||||||
|
assert "octets à exporter, suppression simulée." in sortie
|
||||||
|
assert "Dry-run terminé : rien n'a été écrit ni supprimé." in sortie
|
||||||
|
|
||||||
|
|
||||||
|
async def test_archive_reading_chunks_keeps_the_chunk_when_the_read_back_differs() -> None:
|
||||||
|
engine, store, journal = make_archive(corrompt=True)
|
||||||
|
borne = datetime(2023, 10, 1, tzinfo=UTC)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="sha256 sha-corrompu au lieu de"):
|
||||||
|
await archive_reading_chunks(engine, store, older_than=borne, dry_run=False)
|
||||||
|
|
||||||
|
assert "put" in journal
|
||||||
|
assert "drop" not in journal
|
||||||
|
|
||||||
|
|
||||||
|
async def test_archive_reading_chunks_drops_an_empty_chunk_without_exporting(
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
engine, store, journal = make_archive(rows=[])
|
||||||
|
|
||||||
|
rapport = await archive_reading_chunks(
|
||||||
|
engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "put" not in journal
|
||||||
|
assert "relire" not in journal
|
||||||
|
assert journal[-2:] == ["begin", "drop"]
|
||||||
|
assert rapport == Rapport(chunks_vus=1, exportes=0, deja_presents=0, supprimes=1, lignes=0)
|
||||||
|
assert "vide, rien à exporter" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_archive_reading_chunks_handles_each_chunk_in_turn() -> None:
|
||||||
|
suivant = Chunk(
|
||||||
|
schema=CHUNK.schema,
|
||||||
|
name="_hyper_1_8_chunk",
|
||||||
|
range_start=CHUNK.range_end,
|
||||||
|
range_end=CHUNK.range_end + timedelta(days=7),
|
||||||
|
)
|
||||||
|
engine, store, journal = make_archive(chunks=[CHUNK, suivant])
|
||||||
|
|
||||||
|
rapport = await archive_reading_chunks(
|
||||||
|
engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rapport == Rapport(chunks_vus=2, exportes=2, deja_presents=0, supprimes=2, lignes=4)
|
||||||
|
assert set(store.objets) == {CLE_ATTENDUE, object_key(suivant)}
|
||||||
|
assert journal.count("drop") == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_archive_reading_chunks_reports_nothing_to_do_without_eligible_chunks(
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
engine, store, journal = make_archive(chunks=[])
|
||||||
|
|
||||||
|
rapport = await archive_reading_chunks(
|
||||||
|
engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rapport == Rapport()
|
||||||
|
assert journal == ["connect", "lister"]
|
||||||
|
assert "0 chunk(s) de reading" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_parser_defaults_to_the_settings_and_a_real_run() -> None:
|
||||||
|
arguments = build_parser().parse_args([])
|
||||||
|
|
||||||
|
assert arguments.older_than_days is None
|
||||||
|
assert arguments.dry_run is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_parser_reads_the_bound_and_the_dry_run() -> None:
|
||||||
|
arguments = build_parser().parse_args(["--older-than-days", "400", "--dry-run"])
|
||||||
|
|
||||||
|
assert arguments.older_than_days == 400
|
||||||
|
assert arguments.dry_run is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_parser_refuses_a_non_integer_bound() -> None:
|
||||||
|
parser = build_parser()
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
parser.parse_args(["--older-than-days", "un-an"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_parser_answers_help_without_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.delenv("APP_SECRET_KEY", raising=False)
|
||||||
|
parser = build_parser()
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as sortie:
|
||||||
|
parser.parse_args(["--help"])
|
||||||
|
|
||||||
|
assert sortie.value.code == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def main_branche(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
||||||
|
capture: dict[str, Any] = {}
|
||||||
|
journal: list[str] = []
|
||||||
|
engine = FakeEngine(FakeConn(journal, [], []), journal)
|
||||||
|
store = FakeStore(journal)
|
||||||
|
|
||||||
|
async def faux_archive(
|
||||||
|
engine_recu: Any, store_recu: Any, *, older_than: datetime, dry_run: bool
|
||||||
|
) -> Rapport:
|
||||||
|
capture.update(engine=engine_recu, store=store_recu, older_than=older_than, dry_run=dry_run)
|
||||||
|
return Rapport()
|
||||||
|
|
||||||
|
def faux_engine(url: str, **kwargs: Any) -> FakeEngine:
|
||||||
|
capture["url"] = url
|
||||||
|
capture["engine_kwargs"] = kwargs
|
||||||
|
return engine
|
||||||
|
|
||||||
|
monkeypatch.setattr(reading_retention, "get_settings", settings_s3)
|
||||||
|
monkeypatch.setattr(reading_retention, "build_archive_store", lambda settings: store)
|
||||||
|
monkeypatch.setattr(reading_retention, "create_async_engine", faux_engine)
|
||||||
|
monkeypatch.setattr(reading_retention, "archive_reading_chunks", faux_archive)
|
||||||
|
capture["journal"] = journal
|
||||||
|
capture["store_attendu"] = store
|
||||||
|
capture["engine_attendu"] = engine
|
||||||
|
return capture
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_uses_the_retention_setting_by_default(main_branche: dict[str, Any]) -> None:
|
||||||
|
avant = datetime.now(UTC)
|
||||||
|
|
||||||
|
reading_retention.main([])
|
||||||
|
|
||||||
|
attendu = avant - timedelta(days=1095)
|
||||||
|
assert timedelta(0) <= main_branche["older_than"] - attendu < timedelta(seconds=5)
|
||||||
|
assert main_branche["dry_run"] is False
|
||||||
|
assert main_branche["store"] is main_branche["store_attendu"]
|
||||||
|
assert main_branche["engine"] is main_branche["engine_attendu"]
|
||||||
|
assert main_branche["url"] == "postgresql+asyncpg://retention:test@localhost:5432/enervision"
|
||||||
|
assert main_branche["engine_kwargs"] == {"pool_pre_ping": True}
|
||||||
|
assert main_branche["journal"] == ["dispose"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_honours_an_explicit_bound_and_the_dry_run(main_branche: dict[str, Any]) -> None:
|
||||||
|
avant = datetime.now(UTC)
|
||||||
|
|
||||||
|
reading_retention.main(["--older-than-days", "10", "--dry-run"])
|
||||||
|
|
||||||
|
attendu = avant - timedelta(days=10)
|
||||||
|
assert timedelta(0) <= main_branche["older_than"] - attendu < timedelta(seconds=5)
|
||||||
|
assert main_branche["dry_run"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_fails_before_touching_the_database_without_s3_settings(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(reading_retention, "get_settings", lambda: settings_s3(s3_bucket=None))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
reading_retention,
|
||||||
|
"create_async_engine",
|
||||||
|
lambda *_, **__: pytest.fail("l'engine ne doit pas être créé"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="APP_S3_BUCKET"):
|
||||||
|
reading_retention.main([])
|
||||||
@@ -33,6 +33,9 @@ async def creer_lecture(session: AsyncSession, *, site_id: str, **overrides: obj
|
|||||||
timestamp=overrides.get("timestamp", datetime(2026, 9, 16, tzinfo=UTC)),
|
timestamp=overrides.get("timestamp", datetime(2026, 9, 16, tzinfo=UTC)),
|
||||||
source=overrides.get("source", "api_current"),
|
source=overrides.get("source", "api_current"),
|
||||||
consumption_kw=overrides.get("consumption_kw", 10.0),
|
consumption_kw=overrides.get("consumption_kw", 10.0),
|
||||||
|
# Nul par defaut : seules les mesures en kWh alimentent la comparaison prevu/realise, et
|
||||||
|
# un override silencieusement ignore laissait la colonne vide sans que rien ne le dise.
|
||||||
|
consumption_kwh=overrides.get("consumption_kwh"),
|
||||||
data_quality=overrides.get("data_quality", "good"),
|
data_quality=overrides.get("data_quality", "good"),
|
||||||
raw_data=overrides.get("raw_data", {}),
|
raw_data=overrides.get("raw_data", {}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -142,19 +142,6 @@ async def test_drift_reports_stable_when_the_recent_mae_stays_close_to_the_refer
|
|||||||
assert global_.reason is None
|
assert global_.reason is None
|
||||||
|
|
||||||
|
|
||||||
async def test_drift_reports_indetermine_when_the_reference_window_is_empty() -> None:
|
|
||||||
depot = FauxDepot(
|
|
||||||
recentes=paires(nombre=30, prevu=14.0, reel=10.0),
|
|
||||||
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
|
|
||||||
)
|
|
||||||
|
|
||||||
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
|
|
||||||
|
|
||||||
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
|
|
||||||
assert global_.status == STATUT_INDETERMINE
|
|
||||||
assert global_.reference_mae is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_drift_reports_derive_when_the_coverage_ratio_falls_under_the_threshold() -> None:
|
async def test_drift_reports_derive_when_the_coverage_ratio_falls_under_the_threshold() -> None:
|
||||||
depot = FauxDepot(
|
depot = FauxDepot(
|
||||||
recentes=paires(nombre=30, prevu=10.0, reel=10.0),
|
recentes=paires(nombre=30, prevu=10.0, reel=10.0),
|
||||||
@@ -187,23 +174,6 @@ async def test_drift_reports_one_line_per_site_and_one_global_line() -> None:
|
|||||||
assert next(r for r in rapports if r.site_id is None).n_observations == 24
|
assert next(r for r in rapports if r.site_id is None).n_observations == 24
|
||||||
|
|
||||||
|
|
||||||
async def test_drift_still_reports_a_site_that_stopped_being_scored() -> None:
|
|
||||||
depot = FauxDepot(
|
|
||||||
recentes=paires(site_id="SITE001", nombre=30, prevu=10.0, reel=10.0),
|
|
||||||
anciennes=[
|
|
||||||
*paires(site_id="SITE001", nombre=30, prevu=10.0, reel=10.0),
|
|
||||||
*paires(site_id="SITE002", nombre=30, prevu=10.0, reel=10.0),
|
|
||||||
],
|
|
||||||
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
|
|
||||||
)
|
|
||||||
|
|
||||||
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
|
|
||||||
|
|
||||||
disparu = next(rapport for rapport in rapports if rapport.site_id == "SITE002")
|
|
||||||
assert disparu.status == STATUT_INDETERMINE
|
|
||||||
assert disparu.n_observations == 0
|
|
||||||
|
|
||||||
|
|
||||||
async def test_drift_measures_the_share_of_sites_left_without_enough_history() -> None:
|
async def test_drift_measures_the_share_of_sites_left_without_enough_history() -> None:
|
||||||
depot = FauxDepot(
|
depot = FauxDepot(
|
||||||
recentes=paires(nombre=30, prevu=10.0, reel=10.0),
|
recentes=paires(nombre=30, prevu=10.0, reel=10.0),
|
||||||
@@ -231,16 +201,6 @@ async def test_drift_closes_the_window_before_the_grace_delay() -> None:
|
|||||||
assert reference[1] == recente[0]
|
assert reference[1] == recente[0]
|
||||||
|
|
||||||
|
|
||||||
async def test_drift_truncates_the_reference_instant_to_the_hour() -> None:
|
|
||||||
premier = FauxDepot()
|
|
||||||
second = FauxDepot()
|
|
||||||
|
|
||||||
await service(premier).evaluate(now=INSTANT + timedelta(minutes=17, microseconds=3))
|
|
||||||
await service(second).evaluate(now=INSTANT + timedelta(minutes=48))
|
|
||||||
|
|
||||||
assert premier.fenetres[0] == second.fenetres[0]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("prevu", "attendu"),
|
("prevu", "attendu"),
|
||||||
[(10.0, STATUT_STABLE), (30.0, STATUT_DERIVE)],
|
[(10.0, STATUT_STABLE), (30.0, STATUT_DERIVE)],
|
||||||
@@ -258,3 +218,54 @@ async def test_drift_compares_the_recent_window_to_the_reference_one(
|
|||||||
rapports = await service(depot, min_observations=10, mae_plancher=1.0).evaluate(now=INSTANT)
|
rapports = await service(depot, min_observations=10, mae_plancher=1.0).evaluate(now=INSTANT)
|
||||||
|
|
||||||
assert next(r for r in rapports if r.site_id is None).status == attendu
|
assert next(r for r in rapports if r.site_id is None).status == attendu
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drift_leaves_the_bias_out_of_the_verdict_by_default() -> None:
|
||||||
|
# Le modèle surestime de 3 kWh à chaque heure, et le verdict reste `stable` : le biais est
|
||||||
|
# mesuré et servi, il ne juge pas tant que `--bias-threshold` n'a pas été réglé (ADR 0013).
|
||||||
|
depot = FauxDepot(
|
||||||
|
recentes=paires(nombre=30, prevu=13.0, reel=10.0),
|
||||||
|
anciennes=paires(nombre=30, prevu=13.0, reel=10.0),
|
||||||
|
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
|
||||||
|
)
|
||||||
|
|
||||||
|
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
|
||||||
|
|
||||||
|
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
|
||||||
|
assert global_.status == STATUT_STABLE
|
||||||
|
assert global_.bias == 3.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("prevu", "attendu"),
|
||||||
|
[(13.0, STATUT_DERIVE), (11.0, STATUT_STABLE)],
|
||||||
|
ids=["biais_au_dela", "biais_sous_le_seuil"],
|
||||||
|
)
|
||||||
|
async def test_drift_reports_derive_on_the_bias_once_a_threshold_is_set(
|
||||||
|
prevu: float, attendu: str
|
||||||
|
) -> None:
|
||||||
|
# MAE récente et MAE de référence sont égales : seul le biais peut faire basculer le verdict.
|
||||||
|
depot = FauxDepot(
|
||||||
|
recentes=paires(nombre=30, prevu=prevu, reel=10.0),
|
||||||
|
anciennes=paires(nombre=30, prevu=prevu, reel=10.0),
|
||||||
|
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
|
||||||
|
)
|
||||||
|
|
||||||
|
rapports = await service(depot, min_observations=10, seuil_biais=2.0).evaluate(now=INSTANT)
|
||||||
|
|
||||||
|
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
|
||||||
|
assert global_.status == attendu
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drift_prefers_the_mae_reason_when_both_the_mae_and_the_bias_exceed() -> None:
|
||||||
|
depot = FauxDepot(
|
||||||
|
recentes=paires(nombre=30, prevu=20.0, reel=10.0),
|
||||||
|
anciennes=paires(nombre=30, prevu=11.0, reel=10.0),
|
||||||
|
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
|
||||||
|
)
|
||||||
|
|
||||||
|
rapports = await service(depot, min_observations=10, seuil_biais=2.0).evaluate(now=INSTANT)
|
||||||
|
|
||||||
|
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
|
||||||
|
assert global_.status == STATUT_DERIVE
|
||||||
|
assert "MAE" in (global_.reason or "")
|
||||||
|
|||||||
@@ -219,5 +219,5 @@ async def test_the_api_serves_the_latest_run_when_the_score_cli_runs_twice(
|
|||||||
await executer("enervision_ml.score", "--model", str(modele), journal=tmp_path)
|
await executer("enervision_ml.score", "--model", str(modele), journal=tmp_path)
|
||||||
|
|
||||||
second = await resume_du_site(client, parc.sites[0])
|
second = await resume_du_site(client, parc.sites[0])
|
||||||
assert second["prediction"]["created_at"] > premier["prediction"]["created_at"]
|
assert second["prediction"]["created_at"] >= premier["prediction"]["created_at"]
|
||||||
assert second["prediction"]["model_reference"] == premier["prediction"]["model_reference"]
|
assert second["prediction"]["model_reference"] == premier["prediction"]["model_reference"]
|
||||||
|
|||||||
@@ -106,3 +106,11 @@ def test_main_exits_zero_when_drift_is_detected_without_the_flag(
|
|||||||
|
|
||||||
assert code == 0
|
assert code == 0
|
||||||
assert capsys.readouterr().out != ""
|
assert capsys.readouterr().out != ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_leaves_the_bias_threshold_disabled_by_default() -> None:
|
||||||
|
assert cli.parse_args([]).bias_threshold == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_seuils_depuis_carries_the_bias_threshold() -> None:
|
||||||
|
assert cli.seuils_depuis(cli.parse_args(["--bias-threshold", "2.5"])).seuil_biais == 2.5
|
||||||
|
|||||||
Generated
+107
@@ -192,6 +192,43 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
|
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "boto3"
|
||||||
|
version = "1.43.101"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "botocore" },
|
||||||
|
{ name = "jmespath" },
|
||||||
|
{ name = "s3transfer" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ad/ef/096f1520a4b0cbc794348fcf77ada637e5f98145c3453f219d678c3a0798/boto3-1.43.101.tar.gz", hash = "sha256:49f3eb750f70e050df9929a7e9392e67896c97d7d0a448f13ed3354c634268bd", size = 112635, upload-time = "2026-09-23T19:23:29.553Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/73/8dd65374f88b1b2a33656d9c808dc36aef0cb4a22c74d618ba6fe0092cd2/boto3-1.43.101-py3-none-any.whl", hash = "sha256:8a899b0ea94df3f2fab6d0c69caf2791f2971449696834374d8e88ced01c7ef3", size = 140041, upload-time = "2026-09-23T19:23:27.61Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "botocore"
|
||||||
|
version = "1.43.101"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "jmespath" },
|
||||||
|
{ name = "python-dateutil" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/12/12/e90cc51bd65ecdcd0eedcd522d3c9f102b1d2c601f39f1f1c256695d63a3/botocore-1.43.101.tar.gz", hash = "sha256:3bc67fb55046e1e05ce5f2bd0171f37bef1cf54161786ef04ff338614d98169e", size = 16202504, upload-time = "2026-09-23T19:23:24.49Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/0d/6679253333d6ba74b8ad7077560687096629255ef526e106bb3accceffcc/botocore-1.43.101-py3-none-any.whl", hash = "sha256:f380237ffecc3f887265cd09c4d7e9c8e8dd9ba6162af83b1fc9e5d24622e461", size = 15897867, upload-time = "2026-09-23T19:23:21.643Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "botocore-stubs"
|
||||||
|
version = "1.43.67"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3f/45/53d662227dc4787b2c854445ee7eb4751cb5d74cfb5c686a6ecbe1f94c17/botocore_stubs-1.43.67.tar.gz", hash = "sha256:853e74014a1f557055c4ffae5fb38d7c65c7c0520e1aab366cac41d5428f419d", size = 42846, upload-time = "2026-08-08T14:57:53.412Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/5e/bdbf19967898a032292da65a47d6e25b2eee55865db4e687f861d80b5602/botocore_stubs-1.43.67-py3-none-any.whl", hash = "sha256:c51262bac3341c1cda71f05fa01141fffd3990d7a92c7960e3b755c1bc830373", size = 67244, upload-time = "2026-08-08T14:57:52.01Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.7.22"
|
version = "2026.7.22"
|
||||||
@@ -325,6 +362,7 @@ dependencies = [
|
|||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
{ name = "argon2-cffi" },
|
{ name = "argon2-cffi" },
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
|
{ name = "boto3" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "pandas" },
|
{ name = "pandas" },
|
||||||
@@ -345,6 +383,7 @@ dev = [
|
|||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
{ name = "pytest-cov" },
|
{ name = "pytest-cov" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
|
{ name = "types-boto3", extra = ["s3"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
@@ -354,6 +393,7 @@ requires-dist = [
|
|||||||
{ name = "anyio", specifier = ">=4.0" },
|
{ name = "anyio", specifier = ">=4.0" },
|
||||||
{ name = "argon2-cffi", specifier = ">=23.1" },
|
{ name = "argon2-cffi", specifier = ">=23.1" },
|
||||||
{ name = "asyncpg", specifier = ">=0.31.0" },
|
{ name = "asyncpg", specifier = ">=0.31.0" },
|
||||||
|
{ name = "boto3", specifier = ">=1.43.101" },
|
||||||
{ name = "fastapi", specifier = ">=0.141.1" },
|
{ name = "fastapi", specifier = ">=0.141.1" },
|
||||||
{ name = "httpx", specifier = ">=0.28.1" },
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
{ name = "pandas", specifier = ">=3.0.5" },
|
{ name = "pandas", specifier = ">=3.0.5" },
|
||||||
@@ -374,6 +414,7 @@ dev = [
|
|||||||
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
|
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
|
||||||
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||||
{ name = "ruff", specifier = ">=0.16.7" },
|
{ name = "ruff", specifier = ">=0.16.7" },
|
||||||
|
{ name = "types-boto3", extras = ["s3"], specifier = ">=1.43.101" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -496,6 +537,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jmespath"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "librt"
|
name = "librt"
|
||||||
version = "0.15.0"
|
version = "0.15.0"
|
||||||
@@ -959,6 +1009,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" },
|
{ url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "s3transfer"
|
||||||
|
version = "0.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "botocore" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "six"
|
name = "six"
|
||||||
version = "1.17.0"
|
version = "1.17.0"
|
||||||
@@ -1006,6 +1068,42 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
|
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "types-boto3"
|
||||||
|
version = "1.43.101"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "botocore-stubs" },
|
||||||
|
{ name = "types-s3transfer" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1d/32/e9cfa9a44874cc603220713084bd3d347ee8d3f539748673aa7a62cc7b9c/types_boto3-1.43.101.tar.gz", hash = "sha256:a892e195f6b46e73a3278b08f45dea6470306662647ccf212e8e4366e052e863", size = 105304, upload-time = "2026-09-23T20:24:43.981Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/9e/57626d063d66db4329a6ee96524cf768a20b60194120a0d71ee962758d2b/types_boto3-1.43.101-py3-none-any.whl", hash = "sha256:a5303a8024fa0588dad70fb7ba5845adaa4d86c0ea395063d7ae33badbc6396f", size = 71672, upload-time = "2026-09-23T20:24:39.849Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
s3 = [
|
||||||
|
{ name = "types-boto3-s3" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "types-boto3-s3"
|
||||||
|
version = "1.43.93"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f7/1a/285aa2a27436e437aea1c6d6f964b692df3d8c349bf6a35fd476d0b2f7bb/types_boto3_s3-1.43.93.tar.gz", hash = "sha256:6a7f979872b81f6bf22eb4dc39ea9909d635ec756275eca69e9caabdc94d5a6a", size = 79218, upload-time = "2026-09-11T19:44:38.149Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/16/db644e738b967336fb0ca335d708c7d659a965b8ae703e9c50fe209c59be/types_boto3_s3-1.43.93-py3-none-any.whl", hash = "sha256:da9249f05ea081bb3b3f3b8cc49099a988ff5c89da8a7393532397c6174e04d3", size = 86538, upload-time = "2026-09-11T19:44:36.68Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "types-s3transfer"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fe/64/42689150509eb3e6e82b33ee3d89045de1592488842ddf23c56957786d05/types_s3transfer-0.16.0.tar.gz", hash = "sha256:b4636472024c5e2b62278c5b759661efeb52a81851cde5f092f24100b1ecb443", size = 13557, upload-time = "2025-12-08T08:13:09.928Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/27/e88220fe6274eccd3bdf95d9382918716d312f6f6cef6a46332d1ee2feff/types_s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:1c0cd111ecf6e21437cb410f5cddb631bfb2263b77ad973e79b9c6d0cb24e0ef", size = 19247, upload-time = "2025-12-08T08:13:08.426Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.16.0"
|
version = "4.16.0"
|
||||||
@@ -1036,6 +1134,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.53.0"
|
version = "0.53.0"
|
||||||
|
|||||||
@@ -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,14 +7,18 @@
|
|||||||
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>
|
||||||
|
|
||||||
<label class="form-label" for="current_password">Mot de passe actuel</label>
|
<input hidden type="email" autocomplete="username" [value]="email" readonly />
|
||||||
<input
|
|
||||||
id="current_password"
|
@if (asksCurrentPassword()) {
|
||||||
class="form-input"
|
<label class="form-label" for="current_password">Mot de passe actuel</label>
|
||||||
type="password"
|
<input
|
||||||
formControlName="current_password"
|
id="current_password"
|
||||||
autocomplete="current-password"
|
class="form-input"
|
||||||
/>
|
type="password"
|
||||||
|
formControlName="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,46 +68,94 @@ 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);
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
const component = fixture.componentInstance;
|
fixture.detectChanges();
|
||||||
component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
|
|
||||||
|
|
||||||
authMock.changePassword.mockReturnValue(throwError(() => new Error('401')));
|
expect(champActuel(fixture)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
component.onSubmit();
|
it('réutilise le mot de passe provisoire de la connexion sans le redemander', () => {
|
||||||
fixture.detectChanges(); // rend le bloc @if (errorMessage())
|
authMock.takeProvisionalPassword.mockReturnValue('Provisoire-24-caracteres');
|
||||||
|
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
||||||
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(component.errorMessage()).toContain('incorrect');
|
expect(champActuel(fixture)).toBeNull();
|
||||||
const errorEl = fixture.nativeElement.querySelector('.ev-alert');
|
component.form.controls.new_password.setValue(NOUVEAU);
|
||||||
expect(errorEl?.textContent).toContain('incorrect');
|
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();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.errorMessage()).toContain('Mot de passe actuel incorrect');
|
||||||
|
expect(fixture.nativeElement.querySelector('.ev-alert')?.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', () => {
|
||||||
const fixture = TestBed.createComponent(ChangePassword);
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const button = fixture.nativeElement.querySelector('button[type="submit"]');
|
const button = fixture.nativeElement.querySelector('button[type="submit"]');
|
||||||
expect(button.disabled).toBe(true);
|
expect(button.disabled).toBe(true);
|
||||||
expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull();
|
expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
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' } }));
|
||||||
|
|
||||||
const form = fixture.nativeElement.querySelector('form');
|
const form = fixture.nativeElement.querySelector('form');
|
||||||
form.dispatchEvent(new Event('submit'));
|
form.dispatchEvent(new Event('submit'));
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
+190
-4
@@ -34,12 +34,14 @@ x-airflow-common: &airflow-common
|
|||||||
# memes identifiants que le backend en attendant.
|
# memes identifiants que le backend en attendant.
|
||||||
ML_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
ML_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
MLFLOW_TRACKING_URI: sqlite:////opt/ml/state/mlflow.db
|
MLFLOW_TRACKING_URI: sqlite:////opt/ml/state/mlflow.db
|
||||||
# Le DAG `alertes` lance le backend en sous-processus : il lit `DATABASE_URL`, en
|
# Les DAGs backend lisent `DATABASE_URL` en dialecte asyncpg, là où le pipeline ML
|
||||||
# dialecte asyncpg, là où le pipeline ML lit `ML_DATABASE_URL`.
|
# utilise `ML_DATABASE_URL`.
|
||||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
# Clé distincte de celle de l'API : la détection ne signe ni ne vérifie aucun jeton, et
|
|
||||||
# Airflow permet d'exécuter du code depuis son interface (cf. ADR 0008).
|
# Clé distincte de celle de l'API : les traitements lancés par Airflow ne signent ni ne
|
||||||
|
# vérifient aucun jeton. Airflow permet d'exécuter du code depuis son interface (ADR 0008).
|
||||||
APP_SECRET_KEY: ${AIRFLOW_APP_SECRET_KEY:-}
|
APP_SECRET_KEY: ${AIRFLOW_APP_SECRET_KEY:-}
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./etl/airflow/dags:/opt/airflow/dags
|
- ./etl/airflow/dags:/opt/airflow/dags
|
||||||
- ./etl/airflow/plugins:/opt/airflow/plugins
|
- ./etl/airflow/plugins:/opt/airflow/plugins
|
||||||
@@ -80,6 +82,37 @@ services:
|
|||||||
- "${MAILPIT_UI_PORT:-8025}:8025"
|
- "${MAILPIT_UI_PORT:-8025}:8025"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Piège : image `FROM scratch`, sans shell : healthcheck en forme exec, et aucune garde shell sur
|
||||||
|
# les secrets. Un GARAGE_RPC_SECRET vide ou non hexadécimal fait échouer Garage lui-même, message
|
||||||
|
# explicite dans ses journaux ; `make services-up` et `make stack-up` vérifient le .env avant.
|
||||||
|
# Piège : GARAGE_SECRET_KEY ne se change pas sur un volume `garage_meta` déjà peuplé, Garage
|
||||||
|
# refuse alors de démarrer. Rotation par `garage key` ou par recréation du volume (ADR 0019).
|
||||||
|
garage:
|
||||||
|
image: dxflrs/garage:v2.4.1
|
||||||
|
command: ["/garage", "server", "--single-node", "--default-bucket"]
|
||||||
|
environment:
|
||||||
|
GARAGE_RPC_SECRET: ${GARAGE_RPC_SECRET:-}
|
||||||
|
GARAGE_ADMIN_TOKEN: ${GARAGE_ADMIN_TOKEN:-}
|
||||||
|
GARAGE_METRICS_TOKEN: ${GARAGE_METRICS_TOKEN:-}
|
||||||
|
GARAGE_DEFAULT_ACCESS_KEY: ${GARAGE_ACCESS_KEY:-}
|
||||||
|
GARAGE_DEFAULT_SECRET_KEY: ${GARAGE_SECRET_KEY:-}
|
||||||
|
GARAGE_DEFAULT_BUCKET: ${GARAGE_BUCKET:-enervision-archives}
|
||||||
|
volumes:
|
||||||
|
- ./infra/garage/garage.toml:/etc/garage.toml:ro
|
||||||
|
- garage_meta:/var/lib/garage/meta
|
||||||
|
- garage_data:/var/lib/garage/data
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${GARAGE_S3_PORT:-3900}:3900"
|
||||||
|
- "127.0.0.1:${GARAGE_ADMIN_PORT:-3903}:3903"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/garage", "health", "-q"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 6
|
||||||
|
start_period: 20s
|
||||||
|
mem_limit: 256m
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./apps/backend
|
build: ./apps/backend
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -105,6 +138,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
|
||||||
@@ -167,6 +201,23 @@ services:
|
|||||||
airflow-scheduler:
|
airflow-scheduler:
|
||||||
<<: *airflow-common
|
<<: *airflow-common
|
||||||
command: scheduler
|
command: scheduler
|
||||||
|
environment:
|
||||||
|
<<: *airflow-common-env
|
||||||
|
# LocalExecutor exécute les tâches dans le scheduler : lui seul a besoin des
|
||||||
|
# identifiants de l'API Mock.
|
||||||
|
APP_MOCK_API_BASE_URL: ${APP_MOCK_API_BASE_URL:-https://api-mock.charlieandre.fr}
|
||||||
|
APP_MOCK_API_USERNAME: ${APP_MOCK_API_USERNAME:-}
|
||||||
|
APP_MOCK_API_PASSWORD: ${APP_MOCK_API_PASSWORD:-}
|
||||||
|
APP_MOCK_API_TIMEOUT_SECONDS: ${APP_MOCK_API_TIMEOUT_SECONDS:-10}
|
||||||
|
# Le DAG `retention` archive les chunks de `reading` sur le Garage du projet (ADR 0019),
|
||||||
|
# chiffrés par la clé SSE-C du .env (ADR 0020). Vides, `app.etl.reading_retention` refuse seul.
|
||||||
|
APP_S3_ENDPOINT_URL: http://garage:3900
|
||||||
|
APP_S3_REGION: garage
|
||||||
|
APP_S3_ACCESS_KEY: ${GARAGE_ACCESS_KEY:-}
|
||||||
|
APP_S3_SECRET_KEY: ${GARAGE_SECRET_KEY:-}
|
||||||
|
APP_S3_BUCKET: ${GARAGE_BUCKET:-enervision-archives}
|
||||||
|
APP_S3_SSE_KEY: ${GARAGE_SSE_KEY:-}
|
||||||
|
APP_READING_RETENTION_DAYS: ${READING_RETENTION_DAYS:-1095}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -183,7 +234,142 @@ 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
|
||||||
|
- garage_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:
|
||||||
|
garage_meta:
|
||||||
|
garage_data:
|
||||||
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
|
||||||
|
garage_metrics_token:
|
||||||
|
environment: GARAGE_METRICS_TOKEN
|
||||||
|
|||||||
+4
-4
@@ -25,7 +25,7 @@ Le choix du modèle est dans l'ADR 0005. Ce document ne les répète pas.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `load_from_csv(path)` | `ml/data/all_sites_combined.csv` | Chemin de démarrage, tant que la base n'est pas peuplée |
|
| `load_from_csv(path)` | `ml/data/all_sites_combined.csv` | Chemin de démarrage, tant que la base n'est pas peuplée |
|
||||||
| `load_from_database(connection)` | `reading` joint à `site`, **historique complet** | Entraînement |
|
| `load_from_database(connection)` | `reading` joint à `site`, **historique complet** | Entraînement |
|
||||||
| `load_recent_from_database(connection, since=…)` | `reading` joint à `site`, **borné par `since`** | Scoring |
|
| `load_recent_from_database(connection, since=…, until=…)` | `reading` joint à `site`, **borné des deux côtés** | Scoring |
|
||||||
|
|
||||||
L'égalité des schémas n'est pas un confort : c'est ce qui permet de valider tout le pipeline sur
|
L'égalité des schémas n'est pas un confort : c'est ce qui permet de valider tout le pipeline sur
|
||||||
CSV, sans base joignable, et d'obtenir le même comportement une fois la base peuplée. Une
|
CSV, sans base joignable, et d'obtenir le même comportement une fois la base peuplée. Une
|
||||||
@@ -93,7 +93,7 @@ La table `prediction` **n'a pas de contrainte d'unicité sur `(site_id, target_a
|
|||||||
insère une ligne de plus au lieu d'écraser la précédente. C'est délibéré, et c'est ce qui rend
|
insère une ligne de plus au lieu d'écraser la précédente. C'est délibéré, et c'est ce qui rend
|
||||||
possible la comparaison prévision contre réalisé. La surveillance de dérive s'en sert : elle
|
possible la comparaison prévision contre réalisé. La surveillance de dérive s'en sert : elle
|
||||||
retient, pour chaque `(site_id, target_at)`, la ligne du run le plus récent, celle-là même que
|
retient, pour chaque `(site_id, target_at)`, la ligne du run le plus récent, celle-là même que
|
||||||
sert `GET /api/v1/predictions`. Voir l'[ADR 0011](adr/0011-surveillance-de-derive-dans-le-backend.md).
|
sert `GET /api/v1/predictions`. Voir l'[ADR 0013](adr/0013-surveillance-de-derive-dans-le-backend.md).
|
||||||
|
|
||||||
Trois contraintes de cohérence sont portées par la base et non par le code applicatif :
|
Trois contraintes de cohérence sont portées par la base et non par le code applicatif :
|
||||||
`status = 'available'` exige une `predicted_value` et interdit un `failure_reason` ;
|
`status = 'available'` exige une `predicted_value` et interdit un `failure_reason` ;
|
||||||
@@ -183,7 +183,7 @@ l'entraînement, dont le DAG `ml_train` n'a pas de planification.
|
|||||||
La surveillance de dérive traverse cette frontière **dans le sens de la table vers le backend**,
|
La surveillance de dérive traverse cette frontière **dans le sens de la table vers le backend**,
|
||||||
sans la percer : elle relit `prediction` et `reading` en SQL, ne charge aucun modèle, et n'appelle
|
sans la percer : elle relit `prediction` et `reading` en SQL, ne charge aucun modèle, et n'appelle
|
||||||
pas MLflow. Son calcul, son seuil et son refus de comparer à la métrique d'entraînement sont dans
|
pas MLflow. Son calcul, son seuil et son refus de comparer à la métrique d'entraînement sont dans
|
||||||
l'[ADR 0011](adr/0011-surveillance-de-derive-dans-le-backend.md).
|
l'[ADR 0013](adr/0013-surveillance-de-derive-dans-le-backend.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -194,4 +194,4 @@ l'[ADR 0011](adr/0011-surveillance-de-derive-dans-le-backend.md).
|
|||||||
- [ADR 0006](adr/0006-moteur-de-regles-dans-le-backend.md) : ce qui consomme les prédictions
|
- [ADR 0006](adr/0006-moteur-de-regles-dans-le-backend.md) : ce qui consomme les prédictions
|
||||||
- [`architecture/20-backend.md`](architecture/20-backend.md) : le contrat de `GET /predictions`
|
- [`architecture/20-backend.md`](architecture/20-backend.md) : le contrat de `GET /predictions`
|
||||||
- [`architecture/40-data.md`](architecture/40-data.md) : le modèle de données
|
- [`architecture/40-data.md`](architecture/40-data.md) : le modèle de données
|
||||||
- [ADR 0011](adr/0011-surveillance-de-derive-dans-le-backend.md) : la surveillance de dérive
|
- [ADR 0013](adr/0013-surveillance-de-derive-dans-le-backend.md) : la surveillance de dérive
|
||||||
|
|||||||
+6
-1
@@ -17,4 +17,9 @@
|
|||||||
| [0008](adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement |
|
| [0008](adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement |
|
||||||
| [0009](adr/0009-deux-environnements-compose-sur-la-vm-eni.md) | Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé |
|
| [0009](adr/0009-deux-environnements-compose-sur-la-vm-eni.md) | Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé |
|
||||||
| [0010](adr/0010-terraform-provisionne-github-actions-deploie.md) | Terraform provisionne la machine, GitHub Actions déploie l'application |
|
| [0010](adr/0010-terraform-provisionne-github-actions-deploie.md) | Terraform provisionne la machine, GitHub Actions déploie l'application |
|
||||||
| [0011](adr/0011-surveillance-de-derive-dans-le-backend.md) | La surveillance de dérive vit dans le backend et écrit sa propre table |
|
| [0011](adr/0011-enervision-procedure-deploiement.md) | Procédure de déploiement, telle qu'exécutée le 22/09/2026 |
|
||||||
|
| [0012](adr/0012-enervision-deploiement-rec-prod-vm-eni.md) | État de la recette et de la production sur la VM ENI |
|
||||||
|
| [0013](adr/0013-surveillance-de-derive-dans-le-backend.md) | La surveillance de dérive vit dans le backend et écrit sa propre table |
|
||||||
|
| [0014](adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md) | Un pipeline CI unique appelle les workflows de composant et conditionne le déploiement |
|
||||||
|
| [0015](adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md) | Les tests de bout en bout et de charge visent la stack Compose déployée |
|
||||||
|
| [0016](adr/0016-supervision-en-profil-compose.md) | La supervision vit dans un profil Compose, active en prod |
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# EnerVision · procédure de déploiement (22/09/2026)
|
||||||
|
|
||||||
|
Terraform provisionne la machine, GitHub Actions déploie (ADR 0010). Deux environnements Compose
|
||||||
|
sur la VM ENI `10.101.200.37` : `rec` sur la branche `dev`, `prod` sur `main` (ADR 0009).
|
||||||
|
|
||||||
|
| | recette | production |
|
||||||
|
|---|---|---|
|
||||||
|
| Branche, environnement GitHub | `dev`, `rec` | `main`, `prod` |
|
||||||
|
| Dossier, projet Compose | `/srv/enervision/rec`, `enervision-rec` | `/srv/enervision/prod`, `enervision-prod` |
|
||||||
|
| URL | `https://rec.enervision.local:8443` | `https://enervision.local` |
|
||||||
|
| Proxy HTTP / HTTPS | `127.0.0.1:8081` / `8443` | `80` / `443` |
|
||||||
|
| Postgres / Mailpit / Airflow (locaux) | `5434` / `8026` / `8082` | `5433` / `8025` / `8080` |
|
||||||
|
|
||||||
|
## 0. Avant toute commande
|
||||||
|
|
||||||
|
1. **Clé SSH déposée** sur la VM : `ssh-copy-id -i ~/.ssh/id_ed25519.pub root@10.101.200.37`.
|
||||||
|
Terraform ne gère **pas** l'authentification par mot de passe (elle finirait dans le state).
|
||||||
|
2. **L'utilisateur propriétaire existe déjà** sur la VM (ex. `enervision`) : il possède
|
||||||
|
`/srv/enervision` et fait tourner le runner. Terraform échoue tôt s'il manque, il ne le crée pas.
|
||||||
|
3. **Jeton d'enregistrement du runner** : Settings > Actions > Runners > New self-hosted runner.
|
||||||
|
Valable 1 h, une seule inscription, créé par un administrateur du dépôt (ineszang).
|
||||||
|
4. **`main` est en retard de 64 commits** et ne porte ni `deploy.yml`, ni `provision-host.sh`, ni
|
||||||
|
le Terraform, ni l'overlay paramétré (ports et `PUBLIC_ORIGIN` en dur). Tant que `dev` n'est pas
|
||||||
|
remonté dans `main`, seule la recette est déployable : le clone `prod` sera préparé mais son
|
||||||
|
`make stack-up` publierait 80/443 sans les variables, et aucun push sur `main` ne déclencherait
|
||||||
|
de déploiement (le workflow n'y existe pas). **Remonter `dev` → `main` avant de toucher à prod.**
|
||||||
|
|
||||||
|
## 1. Provisionner la machine (depuis le poste)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infra/terraform/environments/vm-eni
|
||||||
|
cp terraform.tfvars.example terraform.tfvars
|
||||||
|
terraform init
|
||||||
|
terraform apply
|
||||||
|
```
|
||||||
|
|
||||||
|
`terraform.tfvars`, ignoré par git, trois valeurs à renseigner :
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
proprietaire = "enervision" # doit exister sur la VM
|
||||||
|
runner_version = "2.330.0" # épingler depuis github.com/actions/runner/releases
|
||||||
|
runner_token = "..." # jeton d'1 h, à retirer du fichier après l'apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Défauts utiles : `ssh_host = "10.101.200.37"`, `ssh_user = "root"`,
|
||||||
|
`ssh_private_key_path = "~/.ssh/id_ed25519"`, `racine = "/srv/enervision"`,
|
||||||
|
`runner_labels = "eni-g3"` (ciblé par `deploy.yml`), `runner_dossier = "/opt/actions-runner"`.
|
||||||
|
|
||||||
|
L'apply fait trois choses, dans cet ordre : Docker + plugin Compose et `usermod -aG docker`,
|
||||||
|
puis `scripts/provision-host.sh`, puis l'installation et l'enregistrement du runner en service.
|
||||||
|
Il ne construit aucune image et ne démarre aucun conteneur : un apply n'interrompt pas la stack.
|
||||||
|
|
||||||
|
Rejouable : un clone existant est réaligné, un `.env` présent n'est **jamais** réécrit, un
|
||||||
|
certificat présent n'est jamais régénéré. Un nouvel apply de la ressource runner redemande un
|
||||||
|
jeton frais (il expire en 1 h).
|
||||||
|
|
||||||
|
## 2. Variables d'environnement
|
||||||
|
|
||||||
|
Un `.env` par dossier, en `600`, généré sur la machine depuis `.env.example`. **Aucun secret ne
|
||||||
|
passe par git ni par GitHub** : le runner n'en reçoit aucun (seul `SONAR_TOKEN` existe côté CI).
|
||||||
|
|
||||||
|
**Générés automatiquement** : `POSTGRES_PASSWORD`, `APP_SECRET_KEY`, `AIRFLOW_FERNET_KEY`,
|
||||||
|
`AIRFLOW_API_SECRET_KEY`, `AIRFLOW_JWT_SECRET`, `AIRFLOW_ADMIN_PASSWORD`, `AIRFLOW_APP_SECRET_KEY`.
|
||||||
|
|
||||||
|
**Fixés par environnement** : `COMPOSE_PROJECT_NAME`, `PUBLIC_HOST`, `PUBLIC_ORIGIN`,
|
||||||
|
`PROXY_HTTP_PORT`, `PROXY_HTTPS_PORT`, `POSTGRES_PORT`, `MAILPIT_UI_PORT`, `AIRFLOW_PORT`.
|
||||||
|
|
||||||
|
**À renseigner à la main**, dans chaque `.env`, avant le premier démarrage :
|
||||||
|
|
||||||
|
```
|
||||||
|
APP_MOCK_API_USERNAME=...
|
||||||
|
APP_MOCK_API_PASSWORD=...
|
||||||
|
```
|
||||||
|
|
||||||
|
Garde-fou : le script refuse d'écrire un `.env` s'il reste un `change_me` hors `APP_MOCK_API_*`
|
||||||
|
(cas vécu d'une clé renommée en amont, `AIRFLOW_WEBSERVER_SECRET_KEY` sous Airflow 3).
|
||||||
|
|
||||||
|
`APP_ENV=prod` et `APP_DEBUG=false` sont en dur dans l'overlay, pas dans le `.env` : la valeur
|
||||||
|
`local` du poste reprendrait le dessus et rouvrirait `/docs` sans cookie `__Secure-`.
|
||||||
|
|
||||||
|
`TS_TUNE_MEMORY=2GB` et `TS_TUNE_NUM_CPUS=2` sont obligatoires : deux TimescaleDB sur 8 Go se
|
||||||
|
réserveraient 25 % de la RAM chacune. La montée à 32 Go est à demander.
|
||||||
|
|
||||||
|
Certificats auto-signés générés par le script (`infra/proxy/tls/`), couvrant le nom d'hôte,
|
||||||
|
`localhost` et l'IP. Let's Encrypt (`make tls-acme`, `ACME_EMAIL`) reste hors d'atteinte sans
|
||||||
|
domaine public résolvable.
|
||||||
|
|
||||||
|
## 3. Premier démarrage (manuel, une seule fois, sur la VM)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /srv/enervision/rec && make stack-up # build + up + alembic upgrade head
|
||||||
|
cd /srv/enervision/prod && make stack-up # seulement après la remontée dev → main
|
||||||
|
```
|
||||||
|
|
||||||
|
`stack-up` refuse de démarrer si le certificat manque ou ne couvre pas `PUBLIC_HOST`, et applique
|
||||||
|
les migrations : sans elles la stack démarrerait verte sur une base sans schéma.
|
||||||
|
|
||||||
|
Premier administrateur, stack démarrée, dans chaque dossier :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend \
|
||||||
|
python -m app.cli create-admin --email <adresse>
|
||||||
|
```
|
||||||
|
|
||||||
|
Données historiques : `data/raw` n'est pas dans git. Déposer les fichiers dans chaque dossier
|
||||||
|
avant de déclencher le DAG `historical_import`.
|
||||||
|
|
||||||
|
## 4. Réglages GitHub (administrateur du dépôt)
|
||||||
|
|
||||||
|
- Environnement `prod` : branche `main` seule autorisée, **approbation d'un relecteur** requise.
|
||||||
|
- Environnement `rec` : branche `dev` seule autorisée, sans approbation.
|
||||||
|
- Settings > Actions : **« Require approval for all outside collaborators »**. Un runner
|
||||||
|
auto-hébergé sur un dépôt public exécute ce qu'on lui envoie ; `deploy.yml` ne se déclenche
|
||||||
|
jamais sur `pull_request`, et le runner ne tourne jamais en root.
|
||||||
|
|
||||||
|
## 5. Déploiement continu, ensuite
|
||||||
|
|
||||||
|
Un push sur `dev` déploie la recette, un push sur `main` la production après approbation.
|
||||||
|
Le job (runner `eni-g3`) aligne le clone (`fetch`, `checkout`, `reset --hard`), lance
|
||||||
|
`make stack-up`, puis sonde `/api/v1/health/ready` derrière le proxy pendant 3 minutes ; en cas
|
||||||
|
d'échec il publie `ps` et les 50 dernières lignes de `backend` et `proxy`. Pas de `checkout` dans
|
||||||
|
l'espace du runner : `.env`, certificats et volumes doivent survivre d'un déploiement à l'autre.
|
||||||
|
Concurrence par branche, sans annulation.
|
||||||
|
|
||||||
|
Déclenchement manuel possible : `workflow_dispatch`.
|
||||||
|
|
||||||
|
## 6. Vérifier
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k https://localhost:8443/api/v1/health/ready # recette, sur la VM
|
||||||
|
curl -k https://localhost/api/v1/health/ready # production, sur la VM
|
||||||
|
```
|
||||||
|
|
||||||
|
Depuis un poste, ajouter à `/etc/hosts` :
|
||||||
|
|
||||||
|
```
|
||||||
|
10.101.200.37 enervision.local rec.enervision.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Les deux noms sont obligatoires : le cookie `__Secure-ev_refresh` est posé par hôte et non par
|
||||||
|
port ; un seul nom déconnecterait la production à chaque connexion en recette.
|
||||||
|
|
||||||
|
## Pièges à connaître
|
||||||
|
|
||||||
|
- Compose **2.24.4 minimum** : l'overlay emploie `!override` et `!reset`, sans quoi l'API resterait
|
||||||
|
joignable en clair à côté du proxy. Le script le vérifie.
|
||||||
|
- Le runner doit tourner sous le propriétaire de `/srv/enervision` : sinon git refuse les clones
|
||||||
|
(propriété douteuse) et le `.env` en `600` lui échappe. Correctif :
|
||||||
|
`PROPRIETAIRE=<utilisateur> bash scripts/provision-host.sh`.
|
||||||
|
- Chaque environnement reconstruit ses images à partir du même commit : la production n'exécute
|
||||||
|
pas l'artefact validé en recette, mais un second build. Le passage à GHCR lèvera cette limite.
|
||||||
|
- Un `.env` perdu se régénère, mais invalide les sessions et les connexions chiffrées par Airflow :
|
||||||
|
ils ne sont sauvegardés nulle part ailleurs.
|
||||||
|
- Retirer le runner se fait à la main, depuis les paramètres du dépôt : `terraform destroy` ne le
|
||||||
|
désinscrit pas.
|
||||||
|
|
||||||
|
## Références dans le dépôt
|
||||||
|
|
||||||
|
`docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md`,
|
||||||
|
`docs/adr/0010-terraform-provisionne-github-actions-deploie.md`,
|
||||||
|
`docs/architecture/50-cicd.md`, `docs/architecture/10-infra.md`, `infra/README.md`,
|
||||||
|
`scripts/provision-host.sh`, `.github/workflows/deploy.yml`, `docker-compose.prod.yml`.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# EnerVision · Recette et production sur la VM ENI, aujourd'hui
|
||||||
|
|
||||||
|
État au lundi 21 septembre 2026, 15h. Cible : deux environnements qui tournent sur la VM
|
||||||
|
`eadl-2025-nantes-g3` (`10.101.200.37`) avant vendredi 25/09 9h, déployés automatiquement depuis
|
||||||
|
GitHub. Ce document donne la solution retenue, ce qu'elle change dans le dépôt, et le déroulé de
|
||||||
|
l'après-midi avec qui fait quoi.
|
||||||
|
|
||||||
|
## 1. La décision en une phrase
|
||||||
|
|
||||||
|
**Deux projets Docker Compose sur la même VM, un par environnement, déployés par un runner GitHub
|
||||||
|
Actions installé sur la VM.** `dev` alimente la recette, `main` alimente la production. Terraform
|
||||||
|
reste ce qu'il est : le module k3s, cible à terme, non utilisé pour cette mise en ligne.
|
||||||
|
|
||||||
|
| | Recette (`rec`) | Production (`prod`) |
|
||||||
|
|---|---|---|
|
||||||
|
| Branche | `dev` | `main` |
|
||||||
|
| Environnement GitHub | `rec` (créé ce midi) | `prod` (créé ce midi) |
|
||||||
|
| Dossier sur la VM | `/srv/enervision/rec` | `/srv/enervision/prod` |
|
||||||
|
| Projet Compose | `enervision-rec` | `enervision-prod` |
|
||||||
|
| URL | `https://rec.enervision.local:8443` | `https://enervision.local` |
|
||||||
|
| Proxy HTTPS | `8443` | `443` |
|
||||||
|
| Proxy HTTP (redirection) | `127.0.0.1:8081`, inutilisé | `80` |
|
||||||
|
| PostgreSQL, Mailpit, Airflow | `127.0.0.1` : `5434`, `8026`, `8082` | `127.0.0.1` : `5433`, `8025`, `8080` |
|
||||||
|
| Certificat | auto-signé, SAN `rec.enervision.local` | auto-signé, SAN `enervision.local` |
|
||||||
|
| Déclenchement | chaque push sur `dev` | push sur `main`, après approbation dans GitHub |
|
||||||
|
|
||||||
|
Les deux noms d'hôte pointent sur la même IP. Deux lignes dans le `/etc/hosts` des postes de
|
||||||
|
l'équipe suffisent. Deux noms distincts sont indispensables : le cookie de rafraîchissement
|
||||||
|
`__Secure-ev_refresh` est posé par hôte, pas par port, et un seul nom ferait se déconnecter la
|
||||||
|
prod à chaque connexion sur la recette.
|
||||||
|
|
||||||
|
## 2. Pourquoi c'est la solution la plus simple
|
||||||
|
|
||||||
|
- **Tout existe déjà.** L'overlay `docker-compose.prod.yml`, le proxy Nginx TLS, les scripts de
|
||||||
|
certificat et `make stack-up` sont écrits et validés sur poste (PR #117, ADR 0007). Il ne
|
||||||
|
manque que quatre variables pour que deux instances cohabitent sur une machine.
|
||||||
|
- **Un projet Compose isole tout.** Volumes, réseau, noms de conteneurs sont préfixés par le nom
|
||||||
|
du projet. Casser la recette ne touche pas la prod, ce qui est la raison d'être d'une recette.
|
||||||
|
- **Le runner sur la VM est la seule façon d'atteindre une IP privée d'école depuis GitHub.** Les
|
||||||
|
runners hébergés par GitHub ne voient pas `10.101.200.37`. Le runner se connecte en sortie
|
||||||
|
vers GitHub, aucun port entrant n'est nécessaire. C'était le choix 16 du dossier EC01 : il
|
||||||
|
redevient tenu.
|
||||||
|
- **La promotion existe déjà dans la stratégie de branches** : `dev` puis `main` par PR. Le
|
||||||
|
même code est déployé en recette, puis en production, sans troisième mécanisme.
|
||||||
|
|
||||||
|
Ce qu'on écarte, et pourquoi :
|
||||||
|
|
||||||
|
| Piste | Pourquoi pas cette semaine |
|
||||||
|
|---|---|
|
||||||
|
| k3s avec deux namespaces | Le cluster serait vide : aucun manifeste, aucun registre d'images, aucun stockage persistant. Trois jours de travail sans valeur visible au J10 |
|
||||||
|
| Terraform de `feat/deploy` (nginx système + copie de fichiers) | Revue postée sur l'issue #21 : huit points bloquants, `rec` et `prod` ne passent pas `terraform validate`. On abandonne cette voie |
|
||||||
|
| Azure ENI pour la prod | Deuxième infrastructure à provisionner, choix à justifier devant le jury (document 03), et rien n'est prêt côté Azure |
|
||||||
|
| Images publiées sur GHCR | Meilleure pratique, mais un registre de plus à authentifier sur la VM. Les images se construisent sur la VM, où le runner tourne déjà. À faire ensuite, issue à ouvrir |
|
||||||
|
| Let's Encrypt | Aucun domaine public ne résout vers la VM. Auto-signé assumé, chemin ACME déjà câblé |
|
||||||
|
|
||||||
|
## 3. Ce qui change dans le dépôt (une PR vers `dev`)
|
||||||
|
|
||||||
|
| Fichier | Changement | Raison |
|
||||||
|
|---|---|---|
|
||||||
|
| `apps/frontend/Dockerfile` | `FROM nginx:1.28-alpine` à la place de `dhi.io/nginx:...` | Le registre Docker Hardened Images demande une authentification. L'image frontend n'a jamais été construite, sur aucun poste : c'est le premier point où `make stack-up` échouerait sur la VM |
|
||||||
|
| `docker-compose.prod.yml` | Ports du proxy en variables `PROXY_HTTP_PORT` et `PROXY_HTTPS_PORT`. Origine publique `PUBLIC_ORIGIN` pour CORS et le lien de réinitialisation. `TS_TUNE_MEMORY` sur la base | Deux proxys ne peuvent pas publier 80 et 443. L'origine de la recette porte un port. Deux TimescaleDB sur 8 Go se réserveraient chacune 2 Go sans réglage |
|
||||||
|
| `.env.example` | `COMPOSE_PROJECT_NAME`, les variables ci-dessus, ports de la recette en commentaire | Le `.env` de chaque dossier est la seule différence entre les deux environnements |
|
||||||
|
| `.github/workflows/deploy.yml` | Nouveau. `on: push` sur `dev` et `main`, `runs-on: [self-hosted, eni-g3]`, `environment: rec` ou `prod`, puis `git reset --hard origin/<branche>` et `make stack-up` dans le dossier de l'environnement | Le D de CI/CD, issue #21 |
|
||||||
|
| `scripts/provision-host.sh` | Nouveau. Vérifie Docker et Compose 2.24.4 ou plus, crée `/srv/enervision/{rec,prod}`, clone les deux branches | Rejouable, et réutilisable par Terraform plus tard |
|
||||||
|
| `docs/adr/0009-...md`, `10-infra.md`, `50-cicd.md`, `infra/proxy/README.md` | Décision, vue infra, vue CI/CD, tableau des ports | Règle du dépôt : la vue change dans la même PR que le composant |
|
||||||
|
|
||||||
|
Ce qui ne change pas : `docker-compose.yml`, la configuration Nginx, `infra/terraform`.
|
||||||
|
|
||||||
|
## 4. Déroulé de l'après-midi
|
||||||
|
|
||||||
|
| # | Qui | Quoi | Durée |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **ineszang** (seule admin du dépôt) | Environnement `prod` : branche autorisée `main`, un relecteur requis. Environnement `rec` : branche `dev`. Settings > Actions : « Require approval for all outside collaborators ». Générer le jeton d'enregistrement du runner (Settings > Actions > Runners > New self-hosted runner, Linux x64) et le transmettre à Johan | 10 min |
|
||||||
|
| 2 | **Johan** | Déposer sa clé sur la VM : `ssh-copy-id -i ~/.ssh/id_ed25519.pub root@10.101.200.37`, mot de passe du compte administrateur local des postes de l'école | 2 min |
|
||||||
|
| 3 | Johan + Claude | **Fait à 15h** : branche locale `feat/deploy-rec-prod` avec tous les changements du §3, image frontend reconstruite avec succès, fusion Compose vérifiée pour les deux environnements. Reste : commit, push, PR vers `dev` | fait |
|
||||||
|
| 4 | Claude, par SSH | `scripts/provision-host.sh` sur la VM. Écrire les deux `.env` (secrets générés sur la VM, jamais dans git). Certificats : `PUBLIC_HOST=rec.enervision.local PUBLIC_IP=10.101.200.37 make tls-selfsigned` dans `rec`, idem avec `enervision.local` dans `prod`. Puis `make stack-up` dans chaque dossier | 20 min plus la construction des images |
|
||||||
|
| 5 | Johan, sur la VM | Installer le runner sous un utilisateur non-root membre du groupe `docker`, label `eni-g3`, en service systemd (`./config.sh --unattended --labels eni-g3`, `sudo ./svc.sh install && sudo ./svc.sh start`) | 10 min |
|
||||||
|
| 6 | Équipe | Merger la PR dans `dev` : la recette se redéploie seule. Ouvrir la PR `dev` vers `main` : la prod se déploie après approbation dans l'onglet Environments | 15 min |
|
||||||
|
| 7 | Tous | Vérifier depuis un poste de l'équipe, `/etc/hosts` renseigné : connexion, tableau de bord, Airflow par tunnel SSH | 15 min |
|
||||||
|
|
||||||
|
Contrôle en fin de chaîne, depuis la VM :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k https://localhost/api/v1/health/ready # prod
|
||||||
|
curl -k https://localhost:8443/api/v1/health/ready # rec
|
||||||
|
docker compose -p enervision-prod ps
|
||||||
|
docker compose -p enervision-rec ps
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Ce qui peut faire échouer la journée, et la parade
|
||||||
|
|
||||||
|
| Risque | Parade |
|
||||||
|
|---|---|
|
||||||
|
| **8 Go de RAM pour deux stacks complètes** (deux Airflow, deux TimescaleDB, deux API) | Demander dès maintenant le passage à 32 Go, prévu par les consignes. En attendant : `TS_TUNE_MEMORY=2GB` et deux workers gunicorn pour Airflow. Si la RAM ne suit pas, démarrer la recette sans Airflow (`docker compose up -d --scale airflow-webserver=0 --scale airflow-scheduler=0`) |
|
||||||
|
| **Compose trop ancien sur la VM** (les marqueurs `!override` et `!reset` exigent 2.24.4) | `docker compose version` en premier. Sinon installer le paquet `docker-compose-plugin` depuis le dépôt Docker |
|
||||||
|
| **Pas de sortie Internet depuis la VM** | `curl -sI https://github.com` et `docker pull hello-world` avant tout. Sans sortie, ni construction d'image ni runner : déploiement manuel par `scp` d'images, plan B lourd |
|
||||||
|
| **Runner auto-hébergé sur un dépôt public** | Le workflow de déploiement ne s'exécute que sur `push` vers `dev` et `main`, jamais sur `pull_request`. Réglage d'approbation des PR externes (étape 1). Runner sous un utilisateur dédié, jamais root |
|
||||||
|
| **Premier démarrage avec un volume `pgdata` vide** | C'est le cas nominal sur la VM : `db/init` crée les bases `enervision`, `enervision_test` et `airflow`. Ne pas restaurer un volume de poste |
|
||||||
|
| **Le jury accepte mal un certificat auto-signé** | Dire pourquoi avant qu'on le demande : aucun DNS public, ACME câblé et documenté, ADR 0007. Un clic « continuer » dans le navigateur |
|
||||||
|
| **Conflit avec `feat/deploy`** (ineszang y a mergé `dev` à 14h06) | Partager ce document avant de pousser. La PR remplace `feat/deploy`, elle ne s'y ajoute pas |
|
||||||
|
|
||||||
|
## 6. Ce que ça donne pour la grille
|
||||||
|
|
||||||
|
- **EC03, CI/CD** : la chaîne ne s'arrête plus au merge. Deux environnements, déploiement
|
||||||
|
automatique en recette, promotion approuvée en production, journal des déploiements dans
|
||||||
|
l'onglet Environments de GitHub.
|
||||||
|
- **EC04, cloud et sécurisation** : une application déployée et fonctionnelle, une seule surface
|
||||||
|
exposée par environnement, secrets hors de git et hors de GitHub, base et Airflow joignables
|
||||||
|
uniquement par tunnel SSH.
|
||||||
|
- **Dossier EC01** : le choix 16 (runner auto-hébergé, déploiement automatique) passe de « non
|
||||||
|
fait » à « tenu ». Le choix 12 (Ansible) reste non fait, et la réponse est prête : le
|
||||||
|
durcissement de la machine n'est pas automatisé, le script de provisionnement en est la
|
||||||
|
première brique, Terraform pourra l'appeler.
|
||||||
|
|
||||||
|
## 7. Après vendredi, si on continue
|
||||||
|
|
||||||
|
Dans l'ordre de valeur : images construites une fois en CI et publiées sur GHCR, puis déployées
|
||||||
|
par digest (vraie promotion d'artefact). Racine Terraform `environments/eni-g3` qui provisionne
|
||||||
|
la machine et le runner à partir du script. Sauvegarde de `pgdata` par `pg_dump` planifié.
|
||||||
|
Monitoring (issue #26). Et seulement ensuite la bascule k3s, si elle garde un sens.
|
||||||
+10
-11
@@ -1,4 +1,4 @@
|
|||||||
# 0011 - La surveillance de dérive vit dans le backend et écrit sa propre table
|
# 0013 - La surveillance de dérive vit dans le backend et écrit sa propre table
|
||||||
|
|
||||||
- Statut : accepté
|
- Statut : accepté
|
||||||
- Date : 2026-09-22
|
- Date : 2026-09-22
|
||||||
@@ -81,22 +81,14 @@ modèle change n'est pas une dérive, c'est une régression de réentraînement.
|
|||||||
| Écrire le résultat dans `alert` | `ck_alert_source` et `ck_alert_type` bornent les valeurs autorisées, `alert.site_id` est `NOT NULL` et n'accueillerait donc pas la ligne globale, et toute alerte est ensuite relue par le moteur de recommandations, qui devrait apprendre une règle qui ne le concerne pas (ADR 0006). |
|
| Écrire le résultat dans `alert` | `ck_alert_source` et `ck_alert_type` bornent les valeurs autorisées, `alert.site_id` est `NOT NULL` et n'accueillerait donc pas la ligne globale, et toute alerte est ensuite relue par le moteur de recommandations, qui devrait apprendre une règle qui ne le concerne pas (ADR 0006). |
|
||||||
| Une jauge Prometheus | `monitoring/` ne contient que des `.gitkeep` et aucun collecteur ne lit `/metrics` : une jauge que personne ne scrute n'est pas une preuve. Le calcul est de surcroît un traitement par lot, pas le processus qui sert l'API : la jauge disparaîtrait avec lui. |
|
| Une jauge Prometheus | `monitoring/` ne contient que des `.gitkeep` et aucun collecteur ne lit `/metrics` : une jauge que personne ne scrute n'est pas une preuve. Le calcul est de surcroît un traitement par lot, pas le processus qui sert l'API : la jauge disparaîtrait avec lui. |
|
||||||
| Ne rien persister, journaliser seulement | La question posée à un jury est « comment savez-vous que le modèle se dégrade ? ». La réponse est une série dans le temps, pas une ligne de journal perdue avec le conteneur. Sans ligne écrite, l'endpoint n'a rien à lire et le test d'intégration rien à vérifier. |
|
| Ne rien persister, journaliser seulement | La question posée à un jury est « comment savez-vous que le modèle se dégrade ? ». La réponse est une série dans le temps, pas une ligne de journal perdue avec le conteneur. Sans ligne écrite, l'endpoint n'a rien à lire et le test d'intégration rien à vérifier. |
|
||||||
| Une tâche de plus dans le DAG `alertes` | La fenêtre fait 168 h : la recalculer chaque heure écrirait vingt-quatre lignes par jour pour un verdict qui ne bouge pas à cette cadence. Surtout, un échec de dérive ferait rougir `alertes` et laisserait croire que la détection a échoué. |
|
| Une tâche de plus dans le DAG `alertes` | La fenêtre fait 168 h : la recalculer chaque heure écrirait vingt-quatre lignes identiques par jour. Surtout, un échec de dérive ferait rougir `alertes` et laisserait croire que la détection a échoué. |
|
||||||
|
|
||||||
## Conséquences
|
## Conséquences
|
||||||
|
|
||||||
- Une migration ajoute `drift_report`. Son idempotence passe par un **index unique à
|
- Une migration ajoute `drift_report`. Son idempotence passe par un **index unique à
|
||||||
`coalesce(site_id, '')`** et non par une `UniqueConstraint` : deux lignes globales ont toutes
|
`coalesce(site_id, '')`** et non par une `UniqueConstraint` : deux lignes globales ont toutes
|
||||||
deux `site_id` à NULL, et NULL n'est égal à rien, pas même à lui-même. Même forme que
|
deux `site_id` à NULL, et NULL n'est égal à rien, pas même à lui-même. Même forme que
|
||||||
`uq_reading_source`. Cet index n'a de sens que parce que `evaluate()` **tronque son instant de
|
`uq_reading_source`.
|
||||||
référence à l'heure** : avec les microsecondes de `now()`, deux exécutions ne porteraient jamais
|
|
||||||
la même clé et l'index ne dédoublonnerait rien.
|
|
||||||
- **Sans fenêtre de référence, le verdict est `indetermine`, pas `stable`.** Au premier
|
|
||||||
lancement, et après tout trou d'ingestion de plus de 168 h, il n'y a rien à quoi comparer :
|
|
||||||
annoncer `stable` serait affirmer ce que la donnée ne dit pas.
|
|
||||||
- **Un site présent dans la fenêtre de référence et absent de la récente reçoit sa ligne**, à
|
|
||||||
zéro observation. Un site qui cesse d'être scoré est exactement la panne que cette surveillance
|
|
||||||
existe pour dire : le taire en ne produisant aucune ligne serait l'inverse du besoin.
|
|
||||||
- `GET /api/v1/monitoring/drift` est réservé à partir du rôle `operateur` : c'est l'opérateur
|
- `GET /api/v1/monitoring/drift` est réservé à partir du rôle `operateur` : c'est l'opérateur
|
||||||
qui agit sur un pipeline dégradé, pas l'administrateur de comptes. La route est classée dans
|
qui agit sur un pipeline dégradé, pas l'administrateur de comptes. La route est classée dans
|
||||||
`tests/api/acces.py`, donc couverte gratuitement par la matrice de rôles rejouée avec de vrais
|
`tests/api/acces.py`, donc couverte gratuitement par la matrice de rôles rejouée avec de vrais
|
||||||
@@ -105,6 +97,13 @@ modèle change n'est pas une dérive, c'est une régression de réentraînement.
|
|||||||
l'identique.
|
l'identique.
|
||||||
- La CLI sort en code non nul sous `--fail-on-drift` seulement. Par défaut, constater une dérive
|
- La CLI sort en code non nul sous `--fail-on-drift` seulement. Par défaut, constater une dérive
|
||||||
n'est pas un échec d'exécution.
|
n'est pas un échec d'exécution.
|
||||||
|
- **Le biais ne fait pas basculer le verdict par défaut** : `Seuils.seuil_biais` vaut `0`, ce qui
|
||||||
|
désactive la règle. Le plafond de MAE se dérive de la fenêtre de référence, donc il vaut pour
|
||||||
|
n'importe quel site ; un seuil de biais, lui, s'exprime en kWh et ne se transpose pas d'un
|
||||||
|
bureau de 10 kWh à une usine de 1 000 kWh. En déclarer un sans l'avoir calibré sur la vraie
|
||||||
|
série ferait rougir la tâche sans rien prouver. Le `bias` signé reste calculé, stocké et servi
|
||||||
|
par `GET /api/v1/monitoring/drift` : il se lit, il ne juge pas encore. `--bias-threshold`
|
||||||
|
l'active site par site quand une valeur aura été mesurée.
|
||||||
|
|
||||||
## Effet de bord assumé sur le pipeline
|
## Effet de bord assumé sur le pipeline
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# 0019 - Stockage objet Garage par environnement, et cycle de vie des mesures : export puis suppression
|
||||||
|
|
||||||
|
- Statut : accepté
|
||||||
|
- Date : 2026-09-24
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Les issues #24 « Déployer MinIO » et #36 « Politique de rétention + export vers MinIO » datent du
|
||||||
|
cadrage du 14/09. Au 24/09, la hypertable `reading` grossit d'une lecture par site et par heure
|
||||||
|
sans qu'aucune politique ne la borne, et `docs/architecture/40-data.md` classe rétention et
|
||||||
|
compression parmi les cibles non faites. Aucun stockage objet ne tourne.
|
||||||
|
|
||||||
|
La PR 164 a posé une amorce : un projet Compose à part dans `garage/`, l'image `dxflrs/garage:v1.0.1`,
|
||||||
|
des secrets dans un `garage.toml` gitignoré et des tests de fumée boto3 que rien ne jouait. Rien
|
||||||
|
n'était branché sur les trois environnements de la VM ([ADR 0009](0009-deux-environnements-compose-sur-la-vm-eni.md),
|
||||||
|
[ADR 0017](0017-environnement-dev-a-la-demande.md)), ni sur la CI, ni sur la supervision.
|
||||||
|
|
||||||
|
Contrainte propre au projet : le jeu historique s'arrête au 31/12/2024 et `make demo-data` s'y
|
||||||
|
ancre. Une rétention sous vingt-et-un mois effacerait la démonstration.
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
**Garage plutôt que MinIO**, en `v2.4.1`. Un binaire statique de quelques dizaines de Mo, une
|
||||||
|
API S3 suffisante pour boto3, des métriques Prometheus natives, et depuis la `v2.3.0` un mode
|
||||||
|
`--single-node --default-bucket` qui crée layout, clé et bucket au premier démarrage à partir de
|
||||||
|
trois variables d'environnement : aucun conteneur d'initialisation, aucune séquence CLI à rejouer.
|
||||||
|
|
||||||
|
**Un Garage par projet Compose.** Le service `garage` vit dans `docker-compose.yml`, comme `db`
|
||||||
|
et `mailpit`. Chaque environnement a le sien, ses volumes `garage_meta` et `garage_data`, ses
|
||||||
|
secrets et ses ports sur `127.0.0.1` : S3 `3900`, `3910`, `3920` et admin `3903`, `3913`, `3923`
|
||||||
|
pour prod, recette et dev. Le RPC n'est pas publié. Rien ne passe par le proxy.
|
||||||
|
|
||||||
|
**`infra/garage/garage.toml` est versionné sans secret.** `GARAGE_RPC_SECRET` (32 octets
|
||||||
|
hexadécimaux), `GARAGE_ADMIN_TOKEN` et `GARAGE_METRICS_TOKEN` arrivent par l'environnement, comme
|
||||||
|
les autres secrets du `.env`, générés par `scripts/provision-host.sh`. L'image est `FROM scratch`,
|
||||||
|
sans shell : la garde sur les secrets vit dans le `Makefile` (`garage-garde`, appelée par
|
||||||
|
`services-up` et `stack-up`), et le healthcheck est `garage health -q`.
|
||||||
|
|
||||||
|
**Nœud unique assumé.** `replication_factor = 1` et moteur `sqlite`, avec un instantané des
|
||||||
|
métadonnées toutes les six heures. La documentation de Garage réserve ce facteur aux
|
||||||
|
déploiements de test : ici la machine est unique, la redondance n'existe pour aucun autre service,
|
||||||
|
et le chiffrement au repos est traité à part ([ADR 0020](0020-chiffrement-au-repos-coffre-luks-et-sse-c.md)). LMDB, le moteur par défaut, se corrompt à l'arrêt brutal et rien ne le reconstruirait.
|
||||||
|
|
||||||
|
**La rétention de `reading` est un traitement du backend, ordonnancé par Airflow.** Le DAG
|
||||||
|
`retention` lance chaque nuit `app.etl.reading_retention` ([ADR 0008](0008-airflow-execute-le-code-du-backend.md)),
|
||||||
|
qui, pour chaque chunk entièrement plus vieux que `READING_RETENTION_DAYS` (1095 jours par défaut) :
|
||||||
|
|
||||||
|
1. lit ses lignes par la hypertable (`WHERE timestamp >= range_start AND timestamp < range_end`) ;
|
||||||
|
2. les sérialise en CSV gzip reproductible, les colonnes `jsonb` et `text[]` en JSON ;
|
||||||
|
3. les dépose sur Garage sous `reading/<annee>/reading_<debut>_<fin>.csv.gz`, chiffrées par SSE-C,
|
||||||
|
avec le sha256 et le nombre de lignes en métadonnées ; un objet déjà présent avec le même sha
|
||||||
|
n'est pas réécrit ;
|
||||||
|
4. relit l'objet et compare son sha256 ;
|
||||||
|
5. supprime ce seul chunk par `drop_chunks(older_than => range_end, newer_than => range_start)`,
|
||||||
|
dans une transaction dédiée et courte.
|
||||||
|
|
||||||
|
`add_retention_policy` de TimescaleDB est écartée : son travail de fond supprimerait sans avoir
|
||||||
|
exporté. `db/migrations/` reste vide pour la même raison.
|
||||||
|
|
||||||
|
**Supervision.** Prometheus scrute `garage:3903/metrics` avec `GARAGE_METRICS_TOKEN` passé en
|
||||||
|
secret Compose. `CibleInjoignable` couvre son indisponibilité, aucune règle nouvelle.
|
||||||
|
|
||||||
|
**CI.** Le job « Validation des fichiers Compose et de la supervision » démarre le vrai conteneur
|
||||||
|
avec des secrets générés, attend son healthcheck et joue `tests/garage/test_smoke.py` : bucket
|
||||||
|
présent, aller-retour, suppression effective, et lecture refusée sans clé SSE-C.
|
||||||
|
|
||||||
|
## Alternatives écartées
|
||||||
|
|
||||||
|
| Écartée | Raison |
|
||||||
|
|---|---|
|
||||||
|
| MinIO | Plus lourd, licence AGPL, orientation vers l'offre commerciale ; l'équipe préfère un composant qu'elle peut lire en entier. Le titre des issues date du cadrage, la décision a changé depuis. |
|
||||||
|
| Un Garage partagé entre les trois environnements | Un troisième projet Compose et des réseaux externes à déclarer, le couplage que l'ADR 0009 évite. |
|
||||||
|
| `add_retention_policy` TimescaleDB, plus un export séparé | Deux horloges indépendantes : un export en retard d'une semaine perd les données que la politique a déjà supprimées. |
|
||||||
|
| Export Parquet | Une dépendance binaire de plus (`pyarrow`) dans l'image Airflow et le backend, pour un gain nul sur 120 000 lignes ; le CSV gzip est le format d'origine du jeu historique. |
|
||||||
|
| Commande de restauration | Hors périmètre du J6. La procédure manuelle tient en trois commandes : `get_object` avec la clé SSE-C, `gunzip`, `COPY reading FROM STDIN CSV HEADER` ; `uq_reading_source` refuse les doublons. |
|
||||||
|
| Compression TimescaleDB des chunks chauds | Autre chantier, sans lien avec l'export. |
|
||||||
|
|
||||||
|
## Conséquences
|
||||||
|
|
||||||
|
- **Premier passage en prod** (24/09/2026, borne à trois ans) : les chunks de janvier à septembre
|
||||||
|
2023 sont archivés puis supprimés, environ quarante objets. La démonstration ancrée fin 2024
|
||||||
|
et l'entraînement du modèle (quinze mois d'historique plus 2026) ne sont pas touchés.
|
||||||
|
- **`drop_chunks` verrouille `site` et `dataset`** en exclusif jusqu'au COMMIT : le DAG tourne à
|
||||||
|
03h20, entre `alertes` (:15) et `derive` (05h30), et chaque suppression est une transaction
|
||||||
|
propre.
|
||||||
|
- **Secrets.** `.env.example` gagne `GARAGE_RPC_SECRET`, `GARAGE_ADMIN_TOKEN`,
|
||||||
|
`GARAGE_METRICS_TOKEN`, `GARAGE_ACCESS_KEY`, `GARAGE_SECRET_KEY`, `GARAGE_BUCKET`,
|
||||||
|
`GARAGE_S3_PORT`, `GARAGE_ADMIN_PORT`, `GARAGE_SSE_KEY` et `READING_RETENTION_DAYS`.
|
||||||
|
`provision-host.sh` les génère et réaligne les `.env` de la VM : il doit être rejoué avant le
|
||||||
|
premier déploiement qui suit ce changement, sinon `make stack-up` s'arrête sur la garde.
|
||||||
|
- **Rotation.** `GARAGE_SECRET_KEY` ne se change pas sur un volume peuplé : Garage refuse de
|
||||||
|
démarrer. Passer par `garage key` en CLI, ou recréer le volume d'un environnement jetable.
|
||||||
|
- **Perte de `GARAGE_SSE_KEY` = archives illisibles.** La clé est sauvegardée hors de la VM.
|
||||||
|
- **Postes de développement.** `make dev` exige désormais les clés `GARAGE_*` dans le `.env`,
|
||||||
|
comme il exigeait déjà les clés Airflow.
|
||||||
|
- **Métriques Garage** visibles dans Prometheus ; aucun tableau Grafana dédié pour l'instant.
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# 0020 - Chiffrement au repos : coffre LUKS des volumes Docker et SSE-C des archives
|
||||||
|
|
||||||
|
- Statut : accepté
|
||||||
|
- Date : 2026-09-24
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
L'issue #42 demande que les données de la plateforme soient chiffrées au repos. Tout ce que la
|
||||||
|
plateforme persiste vit dans les volumes Docker nommés des trois projets Compose de la VM ENI
|
||||||
|
([ADR 0009](0009-deux-environnements-compose-sur-la-vm-eni.md),
|
||||||
|
[ADR 0017](0017-environnement-dev-a-la-demande.md)), sous `/var/lib/docker/volumes` : la base
|
||||||
|
TimescaleDB (`pgdata`, relevés, comptes, audit), les métadonnées et les objets de Garage
|
||||||
|
(`garage_meta`, `garage_data`, les archives des chunks de `reading` exportées par le DAG
|
||||||
|
`retention`, [ADR 0019](0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)), les journaux et l'état ML d'Airflow, les séries de Prometheus et la base
|
||||||
|
de Grafana.
|
||||||
|
|
||||||
|
Aucun des deux dépôts de données ne chiffre lui-même : Garage n'a pas de chiffrement côté serveur
|
||||||
|
et sa documentation renvoie à un volume LUKS sous ses données ; PostgreSQL communautaire n'a pas de
|
||||||
|
chiffrement transparent des données (TDE), et l'image `timescaledb-ha` n'en ajoute pas. La VM est
|
||||||
|
unique, sur un seul disque virtuel, sans partition libre, sans TPM, et personne n'est devant sa
|
||||||
|
console au démarrage : tout redémarrage doit aboutir sans saisie.
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
**Constat du 24/09, qui borne tout ce qui suit.** La machine `eadl-2025-nantes-g3` n'est pas une
|
||||||
|
machine virtuelle mais un conteneur LXC Ubuntu 24.04 sur un hôte Proxmox (`systemd-detect-virt`
|
||||||
|
répond `lxc`, aucun `/dev/mapper/control`, aucun périphérique loop, pas de `/dev/fuse`, module
|
||||||
|
`dm_crypt` inaccessible). LUKS, comme tout chiffrement au niveau bloc ou FUSE, y est impossible.
|
||||||
|
Le chiffrement au repos du disque de ce conteneur ne peut se faire que sur l'hôte Proxmox
|
||||||
|
(volume LUKS ou ZFS chiffré sous le conteneur), par l'administrateur de l'école : la demande
|
||||||
|
lui est adressée, et jusqu'à sa réponse la base et les métadonnées Garage sont en clair sur ce
|
||||||
|
disque. `scripts/coffre-luks.sh` détecte ce cas et refuse de démarrer. Ce qui suit reste la
|
||||||
|
décision pour toute machine où le device-mapper est disponible (la cible k3s de `10-infra.md`,
|
||||||
|
ou une vraie VM), et le SSE-C des archives est en place dès aujourd'hui.
|
||||||
|
|
||||||
|
**Un coffre LUKS2 sous tous les volumes Docker, posé par `scripts/coffre-luks.sh`.**
|
||||||
|
|
||||||
|
- Le coffre est un **fichier image creux** (`/srv/enervision/coffre.img`, 30 Go par défaut)
|
||||||
|
formaté en **LUKS2**, ouvert par une **clé de 64 octets tirée de `/dev/urandom`**, lisible par
|
||||||
|
root seulement (`/root/enervision-coffre.key`, `0400`). Un fichier plutôt qu'une partition : la
|
||||||
|
VM n'en a pas de libre, et l'image se déplace ou se sauvegarde comme un fichier.
|
||||||
|
- Le mapper `enervision-coffre` porte un ext4 monté sur `/srv/enervision/coffre`, et
|
||||||
|
`/var/lib/docker/volumes` est **bind-monté** depuis `/srv/enervision/coffre/docker-volumes`.
|
||||||
|
Docker ne voit qu'un dossier ordinaire : ni `data-root`, ni les fichiers Compose, ni les noms
|
||||||
|
de volumes ne changent, et les trois environnements sont couverts d'un coup.
|
||||||
|
- L'ouverture et les montages sont déclarés dans **`/etc/crypttab` et `/etc/fstab`, avec
|
||||||
|
`nofail`** sur les trois lignes : un coffre absent ne doit jamais envoyer la machine en mode
|
||||||
|
urgence, où SSH ne répond plus. Sur Debian 13 le générateur crypttab est dans le paquet
|
||||||
|
`systemd-cryptsetup`, installé par le script s'il existe dans apt.
|
||||||
|
- Un **drop-in `RequiresMountsFor=/var/lib/docker/volumes`** sur `docker.service` fait la
|
||||||
|
barrière : sans le bind, Docker ne démarre pas, plutôt que de recréer des volumes vides en
|
||||||
|
clair et de laisser trois stacks se lever sur des bases neuves.
|
||||||
|
- Le script est **rejouable** : clé, image, formatage, système de fichiers, crypttab, fstab et
|
||||||
|
drop-in ne sont posés que s'ils manquent, et il sort sans rien toucher si
|
||||||
|
`/var/lib/docker/volumes` est déjà servi par le coffre. La **migration à froid** des volumes
|
||||||
|
existants n'a lieu qu'avec `COFFRE_MIGRER=1` : refus si `live-restore` est actif, arrêt de
|
||||||
|
`docker.socket` et `docker.service`, `rsync -aHAX --numeric-ids`, comparaison du nombre et de la
|
||||||
|
taille des fichiers, puis bascule du dossier et redémarrage de Docker. L'ancien dossier reste en
|
||||||
|
`/var/lib/docker/volumes.avant-coffre` jusqu'à validation par un redémarrage.
|
||||||
|
- Terraform peut le jouer : `null_resource.coffre`, activé par `coffre_taille` non vide,
|
||||||
|
s'exécute après Docker et avant `provision-host.sh`. La ressource est optionnelle et absente du
|
||||||
|
plan tant que la variable est vide.
|
||||||
|
|
||||||
|
**SSE-C sur les archives exportées vers Garage.** Le module d'export du DAG `retention` envoie
|
||||||
|
chaque archive avec une clé client (`GARAGE_SSE_KEY`, générée dans le `.env` par
|
||||||
|
`provision-host.sh`) ; Garage la chiffre en AES-256-GCM et n'en garde que l'empreinte. Les objets
|
||||||
|
sont donc chiffrés une seconde fois, avec une clé distincte de celle du coffre, dans le seul
|
||||||
|
dépôt que l'on pourrait un jour sortir de la VM.
|
||||||
|
|
||||||
|
## Alternatives écartées
|
||||||
|
|
||||||
|
| Écartée | Raison |
|
||||||
|
|---|---|
|
||||||
|
| `pgcrypto`, chiffrement par colonne | Ne couvre ni les index, ni les journaux WAL, ni Garage, ni Airflow ; la clé serait dans l'application, à côté des données, pour un coût de développement et de requête sur chaque lecture d'hypertable. |
|
||||||
|
| Déplacer le `data-root` de Docker dans le coffre | Chiffre aussi les images et les couches, sans valeur, et impose de recopier tout `/var/lib/docker` : plus long, plus de place, et le démon doit être reconfiguré. Seuls les volumes portent des données. |
|
||||||
|
| Chiffrer côté client dans le module d'export | Couvre les archives et rien d'autre, avec une bibliothèque cryptographique à porter dans le code métier alors que Garage offre SSE-C. Retenu seulement sous cette forme, en complément du coffre. |
|
||||||
|
| Volume Docker chiffré par un plugin | Un plugin tiers par volume nommé, à installer et suivre sur la machine, pour huit volumes par environnement ; le coffre les couvre tous d'un bind. |
|
||||||
|
| Disque ou partition dédiée | La VM n'a qu'un disque virtuel, sans partition libre, et son redimensionnement n'est pas dans les mains de l'équipe. |
|
||||||
|
| Clé saisie au démarrage | Personne devant la console ; un redémarrage de la VM par l'école laisserait la plateforme arrêtée jusqu'à intervention. |
|
||||||
|
| Clé scellée dans un TPM | La VM n'en expose pas. |
|
||||||
|
|
||||||
|
## Conséquences
|
||||||
|
|
||||||
|
- **Ce que le coffre protège, et ce qu'il ne protège pas.** La clé et l'image vivent sur le même
|
||||||
|
disque. Le coffre protège une copie isolée de l'image ou du disque : snapshot, sauvegarde,
|
||||||
|
décommissionnement du disque virtuel. Il ne protège ni du vol du disque entier, où la clé se
|
||||||
|
trouve aussi, ni d'un root sur l'hôte allumé, qui lit le montage en clair. La copie
|
||||||
|
`.avant-coffre`, supprimée après validation, n'est pas effaçable physiquement sur un disque
|
||||||
|
virtuel. La clé SSE-C transite en clair sur le réseau Compose interne, entre `airflow-scheduler`
|
||||||
|
et Garage, à chaque objet envoyé.
|
||||||
|
- **Perte de la clé, perte de tout.** Sans `/root/enervision-coffre.key`, l'image est illisible
|
||||||
|
et les trois bases avec elle. La clé est à sauvegarder hors de la VM tout de suite après la
|
||||||
|
pose, dans un emplacement que seuls les administrateurs lisent.
|
||||||
|
- **Coupure lors de la migration.** La copie des volumes se fait Docker arrêté : les trois
|
||||||
|
environnements sont indisponibles une à trois minutes, et le disque doit porter deux fois la
|
||||||
|
taille des volumes jusqu'à la suppression de `.avant-coffre`.
|
||||||
|
- **Redémarrage de test obligatoire.** L'ordonnancement crypttab, fstab, drop-in ne se vérifie
|
||||||
|
qu'en redémarrant : `findmnt /var/lib/docker/volumes` et `docker ps` après le reboot, avant de
|
||||||
|
supprimer la copie en clair.
|
||||||
|
- **Docker dépend du coffre.** Si l'image ou la clé disparaît, Docker refuse de démarrer
|
||||||
|
(`dependency failed`) et la machine reste joignable par SSH ; c'est voulu. Retirer la ligne
|
||||||
|
fstab du bind retire cette protection sans message.
|
||||||
|
- **Rotation.** La clé LUKS se change par `cryptsetup luksChangeKey` sans réécrire les données.
|
||||||
|
`GARAGE_SSE_KEY` ne se change pas sans réécrire chaque objet : Garage n'a pas de re-chiffrement
|
||||||
|
côté serveur, et un objet écrit avec l'ancienne clé ne se lit qu'avec elle.
|
||||||
|
- **Terraform interrompt la stack, une fois.** La première pose avec `coffre_taille` est la seule
|
||||||
|
ressource de `vm-eni` qui arrête Docker, en contradiction assumée avec
|
||||||
|
l'[ADR 0010](0010-terraform-provisionne-github-actions-deploie.md) pour cette seule occasion ;
|
||||||
|
les `apply` suivants trouvent le coffre en place et n'y touchent pas.
|
||||||
@@ -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,23 +61,32 @@ 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
|
||||||
intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
|
intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
|
||||||
[30-frontend.md](30-frontend.md).
|
[30-frontend.md](30-frontend.md).
|
||||||
|
|
||||||
Le lien `airflow --> db` est maintenant en trait plein : cinq DAGs tournent, deux pour
|
Le lien `airflow --> db` est maintenant en trait plein : six DAGs tournent, deux pour
|
||||||
l'entraînement et le scoring du modèle ML (issue #115), un pour la détection d'alertes et la
|
l'entraînement et le scoring du modèle ML (issue #115), un pour la détection d'alertes et la
|
||||||
génération des recommandations (issue #116), et `historical_import` pour l'ingestion du dataset
|
génération des recommandations (issue #116), un pour la surveillance de dérive (issue #45),
|
||||||
historique (issue #119). L'orchestration de l'import API Mock et la réconciliation globale des
|
`historical_import` pour le dataset historique (issue #119) et `mock_api_import` pour l'ingestion
|
||||||
deux sources restent à compléter dans l'issue #15.
|
horaire de l'API Mock (issue #15). La réconciliation entre les deux sources de lectures (issue
|
||||||
|
#15) est tranchée : le trou entre la fin de l'historique (31/12/2024) et le début de l'ingestion
|
||||||
|
API Mock est accepté comme définitivement perdu, aucune mesure réelle n'existant pour cette
|
||||||
|
période. `mock_api_import` refuse toute fenêtre qui recouvrirait des lectures déjà importées du
|
||||||
|
CSV plutôt que de laisser les deux sources dupliquer silencieusement un même instant, et le
|
||||||
|
pipeline ML déduplique par construction (`DISTINCT ON`, source `csv` préférée) au cas où un
|
||||||
|
recouvrement se produirait malgré tout, voir [40-data.md](40-data.md).
|
||||||
|
|
||||||
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
|
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
|
||||||
|
|
||||||
@@ -86,18 +95,22 @@ collecteur ne vient le lire.
|
|||||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) |
|
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) |
|
||||||
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle |
|
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle |
|
||||||
| 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 0011](../adr/0011-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 |
|
| Stockage objet | Garage, S3 | `infra/garage`, `docker-compose.yml` | `Fait` | Un Garage par environnement, `--single-node --default-bucket`, secrets par l'environnement, ports sur `127.0.0.1`, fumée S3 et SSE-C en CI. Reçoit les archives CSV gzip du DAG `retention`, chiffrées SSE-C, avant `drop_chunks` ([ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md), [ADR 0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md)) |
|
||||||
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Cinq DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes`, `historical_import` et `derive` (quotidien, surveillance de dérive). Le DAG historique orchestre `app.etl.historical_import` et charge `dataset`, `site` et `reading`. L'orchestration API Mock reste à compléter dans #15 |
|
| 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) |
|
||||||
| 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) |
|
| 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` | 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
|
||||||
|
|
||||||
Statut : `En cours`. **Le chemin de lecture tourne** : base, API et frontend. **Le chemin
|
Statut : `En cours`. **Le chemin de lecture tourne** entre la base, l'API et le frontend.
|
||||||
d'ingestion dessiné ci-dessous n'existe pas** : les DAGs livrés (`ml_train`, `ml_score`,
|
**Le chemin d'ingestion est maintenant orchestré par Airflow** : `historical_import` charge le
|
||||||
issue #115 ; `alertes`, issue #116) orchestrent le pipeline ML et la détection d'alertes, pas
|
dataset CSV/JSON sur déclenchement manuel et `mock_api_import` collecte chaque heure les mesures
|
||||||
l'ingestion, qui reste lancée à la main par les scripts d'import (issues #15 et #16).
|
de l'API Mock. Les DAGs `ml_train` et `ml_score` (issue #115), `alertes` (issue #116) et `derive`
|
||||||
|
(issue #45) portent le pipeline ML, la détection d'alertes et la surveillance de dérive. La
|
||||||
|
réconciliation entre les deux sources de lectures (issue #15) est close : voir
|
||||||
|
[40-data.md](40-data.md) pour le détail du garde-fou d'ingestion et de la déduplication ML.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
@@ -128,6 +141,11 @@ consolidée.
|
|||||||
Argon2id, RBAC à trois rôles. Détail dans [20-backend.md](20-backend.md), décisions dans les
|
Argon2id, RBAC à trois rôles. Détail dans [20-backend.md](20-backend.md), décisions dans les
|
||||||
[ADR 0002](../adr/0002-authentification-jwt-et-refresh-opaque.md) et
|
[ADR 0002](../adr/0002-authentification-jwt-et-refresh-opaque.md) et
|
||||||
[0003](../adr/0003-autorisation-rbac-a-trois-roles.md).
|
[0003](../adr/0003-autorisation-rbac-a-trois-roles.md).
|
||||||
|
- **Chiffrement au repos.** Les archives de mesures déposées sur Garage sont chiffrées par clé
|
||||||
|
client (SSE-C). Le coffre LUKS des volumes Docker (`scripts/coffre-luks.sh`) est prêt pour une
|
||||||
|
vraie VM, mais la machine ENI est un conteneur LXC sans device-mapper : le chiffrement de son
|
||||||
|
disque relève de l'hôte Proxmox, demandé à l'école. Ce qui est couvert et ce qui ne l'est pas :
|
||||||
|
[ADR 0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md).
|
||||||
- **Interdire par défaut.** Toute route exige un jeton, sauf quatre exceptions listées dans un
|
- **Interdire par défaut.** Toute route exige un jeton, sauf quatre exceptions listées dans un
|
||||||
fichier de test qui interroge réellement chaque route sans identifiant.
|
fichier de test qui interroge réellement chaque route sans identifiant.
|
||||||
- **Révocation immédiate.** Le compte est relu en base à chaque requête : une désactivation ou un
|
- **Révocation immédiate.** Le compte est relu en base à chaque requête : une désactivation ou un
|
||||||
@@ -146,7 +164,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
|
||||||
@@ -190,3 +208,5 @@ Elles vivent dans `../adr/`, pas ici.
|
|||||||
| [0008](../adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement |
|
| [0008](../adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement |
|
||||||
| [0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md) | Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé |
|
| [0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md) | Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé |
|
||||||
| [0010](../adr/0010-terraform-provisionne-github-actions-deploie.md) | Terraform provisionne la machine, GitHub Actions déploie l'application |
|
| [0010](../adr/0010-terraform-provisionne-github-actions-deploie.md) | Terraform provisionne la machine, GitHub Actions déploie l'application |
|
||||||
|
| [0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md) | Stockage objet Garage par environnement ; les chunks anciens de `reading` sont exportés en CSV gzip puis supprimés |
|
||||||
|
| [0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md) | Chiffrement au repos : coffre LUKS des volumes Docker de la VM, SSE-C des archives |
|
||||||
|
|||||||
+114
-22
@@ -10,6 +10,7 @@ dans quel contexte, quelles décisions sont arrêtées, et ce qui manque encore
|
|||||||
| Deux projets Compose sur la VM ENI, recette et production | Déploiement continu depuis GitHub | `En cours` |
|
| Deux projets Compose sur la VM ENI, recette et production | Déploiement continu depuis GitHub | `En cours` |
|
||||||
| Provisionnement Terraform de la VM | Préparer la machine et enregistrer le runner | `En cours` |
|
| Provisionnement Terraform de la VM | Préparer la machine et enregistrer le runner | `En cours` |
|
||||||
| k3s single-node | Cible à terme | `En cours` |
|
| k3s single-node | Cible à terme | `En cours` |
|
||||||
|
| MLflow (`ml/`) | Tracker les expériences et le registre de modèles en local | `Fait`, non relié aux autres topologies |
|
||||||
|
|
||||||
## Poste de développement
|
## Poste de développement
|
||||||
|
|
||||||
@@ -36,6 +37,9 @@ 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` |
|
||||||
|
| `garage` | `dxflrs/garage:v2.4.1` | S3 en `127.0.0.1:3900`, admin et `/metrics` en `127.0.0.1:3903`. `--single-node --default-bucket` : clé et bucket créés au premier démarrage, secrets par l'environnement (`GARAGE_*` du `.env`, garde dans le Makefile), `garage.toml` versionné sans secret dans `infra/garage`. Reçoit les archives du DAG `retention` ([ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)) |
|
||||||
|
| `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
|
||||||
@@ -53,7 +57,7 @@ Trois pièges sont documentés en tête du `docker-compose.yml`, ils ne se devin
|
|||||||
- `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais de l'api-server :
|
- `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais de l'api-server :
|
||||||
c'est le scheduler qui a besoin du volume `airflow_ml_state` (modèle, magasin MLflow).
|
c'est le scheduler qui a besoin du volume `airflow_ml_state` (modèle, magasin MLflow).
|
||||||
|
|
||||||
### Airflow (issues #115, #116 et #119)
|
### Airflow (issues #15, #115, #116 et #119)
|
||||||
|
|
||||||
Quatre services (Airflow 3.3), `docker compose profiles` non utilisés (démarrage explicite via `make
|
Quatre services (Airflow 3.3), `docker compose profiles` non utilisés (démarrage explicite via `make
|
||||||
airflow-up`, pas dans `make dev`) :
|
airflow-up`, pas dans `make dev`) :
|
||||||
@@ -86,13 +90,28 @@ l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md).
|
|||||||
| `ml_score` | `0 * * * *` | `enervision_ml.score`, dans `/opt/ml/.venv` |
|
| `ml_score` | `0 * * * *` | `enervision_ml.score`, dans `/opt/ml/.venv` |
|
||||||
| `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations`, dans `/opt/backend/.venv` |
|
| `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations`, dans `/opt/backend/.venv` |
|
||||||
| `historical_import` | manuelle | `app.etl.historical_import`, dans `/opt/backend/.venv` ; les fichiers de `data/raw` sont montés en lecture seule dans `/opt/data/raw` |
|
| `historical_import` | manuelle | `app.etl.historical_import`, dans `/opt/backend/.venv` ; les fichiers de `data/raw` sont montés en lecture seule dans `/opt/data/raw` |
|
||||||
|
| `mock_api_import` | `45 * * * *` | `app.etl.mock_api_import`, dans `/opt/backend/.venv` ; importe depuis l'API Mock la mesure de l'heure pile précédant son déclenchement |
|
||||||
| `derive` | `30 5 * * *` | `app.monitoring.drift`, dans `/opt/backend/.venv` ; quotidien parce que sa fenêtre couvre 168 h, et sans reprise parce qu'une dérive n'est pas une panne passagère |
|
| `derive` | `30 5 * * *` | `app.monitoring.drift`, dans `/opt/backend/.venv` ; quotidien parce que sa fenêtre couvre 168 h, et sans reprise parce qu'une dérive n'est pas une panne passagère |
|
||||||
|
| `retention` | `20 3 * * *` | `app.etl.reading_retention`, dans `/opt/backend/.venv` ; exporte vers Garage (CSV gzip, SSE-C) chaque chunk de `reading` plus vieux que `READING_RETENTION_DAYS` puis le supprime par `drop_chunks` ; la nuit parce que la suppression verrouille `site` et `dataset` jusqu'au COMMIT |
|
||||||
|
|
||||||
Le DAG `historical_import` réutilise le pipeline historique existant sans dupliquer sa logique.
|
Le DAG `historical_import` réutilise le pipeline historique existant sans dupliquer sa logique.
|
||||||
Il reste manuel, car le dataset sert à initialiser l'environnement. Le montage
|
Il reste manuel, car le dataset sert à initialiser l'environnement. Le montage
|
||||||
`./data/raw:/opt/data/raw:ro` permet au scheduler de lire les fichiers CSV/JSON sans pouvoir les
|
`./data/raw:/opt/data/raw:ro` permet au scheduler de lire les fichiers CSV/JSON sans pouvoir les
|
||||||
modifier.
|
modifier.
|
||||||
|
|
||||||
|
Le DAG `mock_api_import` exécute le pipeline API Mock toutes les heures, à la minute `:45`.
|
||||||
|
Un `CronTriggerTimetable` explicite lui attribue un intervalle d'une heure, y compris lors d'un
|
||||||
|
déclenchement manuel, mais la fenêtre transmise au script backend part de l'heure pile qui
|
||||||
|
précède le déclenchement (pas de l'intervalle Airflow tel quel), pour que la mesure importée
|
||||||
|
tombe à :00 et non à :45, voir [40-data.md](40-data.md). Le pipeline charge la mesure dans les
|
||||||
|
tables communes `site` et `reading`. Le décalage à `:45` laisse quinze minutes avant le
|
||||||
|
scoring exécuté à l'heure pile, puis quinze minutes supplémentaires avant les alertes à `:15`.
|
||||||
|
`max_active_runs=1` empêche deux exécutions du DAG de se chevaucher.
|
||||||
|
|
||||||
|
Le DAG conserve `catchup=False` pour éviter un rattrapage massif depuis sa date de démarrage.
|
||||||
|
Une interruption du scheduler peut donc créer un intervalle manquant, qui devra être rejoué
|
||||||
|
explicitement par une opération de backfill.
|
||||||
|
|
||||||
**Pourquoi `alertes` tourne à la quinzième minute.** Sa règle `anomaly` compare une lecture à la
|
**Pourquoi `alertes` tourne à la quinzième minute.** Sa règle `anomaly` compare une lecture à la
|
||||||
`prediction` du même instant, que `ml_score` écrit à l'heure pile. Le décalage laisse le scoring
|
`prediction` du même instant, que `ml_score` écrit à l'heure pile. Le décalage laisse le scoring
|
||||||
finir. Aucune dépendance n'est déclarée entre les deux DAGs pour autant, ni `ExternalTaskSensor` ni
|
finir. Aucune dépendance n'est déclarée entre les deux DAGs pour autant, ni `ExternalTaskSensor` ni
|
||||||
@@ -149,6 +168,34 @@ est minimale et n'embarque pas la runtime OpenMP dont LightGBM a besoin, sans qu
|
|||||||
(`OSError: libgomp.so.1`) n'apparaît qu'à la première tâche réellement exécutée, pas à la
|
(`OSError: libgomp.so.1`) n'apparaît qu'à la première tâche réellement exécutée, pas à la
|
||||||
construction de l'image.
|
construction de l'image.
|
||||||
|
|
||||||
|
### MLflow (`ml/`)
|
||||||
|
|
||||||
|
Statut : `Fait`, en local uniquement. Défini par `ml/docker-compose.mlflow.yml`, indépendant
|
||||||
|
du `docker-compose.yml` principal (réseau, volumes et démarrage séparés).
|
||||||
|
|
||||||
|
| Service | Image | Points notables |
|
||||||
|
|---|---|---|
|
||||||
|
| `mlflow-db` | `postgres:17` | Stocke le tracking store MLflow. Mot de passe obligatoire via `MLFLOW_DB_PASSWORD` |
|
||||||
|
| `mlflow` | Construite depuis `ml/` | Expose l'UI et l'API MLflow sur `127.0.0.1:5000`. Artefacts sur volume `mlflow-artifacts`, tracking store sur `mlflow-db` |
|
||||||
|
|
||||||
|
Portée actuelle : environnement de tracking et de registre de modèles pour le développement
|
||||||
|
local uniquement. Ce compose n'est relié ni à `docker-compose.prod.yml`, ni aux deux
|
||||||
|
environnements Compose de la VM ENI, ni à la cible k3s. Le magasin utilisé par Airflow pour
|
||||||
|
`ml_train`/`ml_score` (SQLite, volume `airflow_ml_state`) en est distinct : les deux MLflow ne
|
||||||
|
se voient pas tant que `MLFLOW_TRACKING_URI` n'est pas posé côté Airflow.
|
||||||
|
|
||||||
|
Limite connue : le DAG Airflow `ml_train` enregistre lui aussi une version a chaque execution
|
||||||
|
via `registered_model_name` (magasin SQLite du volume `airflow_ml_state`, distinct de ce
|
||||||
|
serveur). Versions et artefacts s'y accumulent sans politique de nettoyage -- fonctionne en
|
||||||
|
l'etat, mais a surveiller si les entrainements deviennent frequents.
|
||||||
|
|
||||||
|
Pour relier les runs Airflow (`ml_train`, magasin SQLite local) a ce serveur MLflow, positionner
|
||||||
|
`MLFLOW_TRACKING_URI=http://mlflow:5000` dans l'environnement du service `airflow-scheduler` (ou
|
||||||
|
`http://host.docker.internal:5000` si le serveur MLflow tourne hors du reseau Compose principal),
|
||||||
|
et s'assurer que le conteneur Airflow peut joindre le service `mlflow` -- ce qui suppose de les
|
||||||
|
rapprocher sur le meme reseau Docker ou d'exposer MLflow autrement qu'en `127.0.0.1` uniquement
|
||||||
|
(cf. point 1 sur l'exposition du port). Non fait a ce jour : aucun besoin de centraliser les runs
|
||||||
|
d'entrainement Airflow et locaux n'a encore ete identifie.
|
||||||
## Machine cible, exécution Docker
|
## Machine cible, exécution Docker
|
||||||
|
|
||||||
Statut : `Fait`. Défini par l'overlay `docker-compose.prod.yml`, appliqué par-dessus le
|
Statut : `Fait`. Défini par l'overlay `docker-compose.prod.yml`, appliqué par-dessus le
|
||||||
@@ -160,11 +207,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
|
||||||
@@ -172,6 +220,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
|
||||||
@@ -186,27 +237,37 @@ 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` |
|
||||||
|
| Garage S3, admin, sur `127.0.0.1` | `3920`, `3923` | `3910`, `3913` | `3900`, `3903` |
|
||||||
|
| 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`.
|
||||||
@@ -226,7 +287,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
|
||||||
```
|
```
|
||||||
@@ -239,6 +300,35 @@ sonde de `deploy.yml` qui le dit.
|
|||||||
Le jeton d'enregistrement du runner est valable une heure et ne vaut que pour une inscription :
|
Le jeton d'enregistrement du runner est valable une heure et ne vaut que pour une inscription :
|
||||||
l'`apply` n'est pas rejouable sans qu'un administrateur du dépôt en crée un nouveau.
|
l'`apply` n'est pas rejouable sans qu'un administrateur du dépôt en crée un nouveau.
|
||||||
|
|
||||||
|
### Coffre LUKS des volumes Docker (issue #42)
|
||||||
|
|
||||||
|
Statut : `Bloqué par la plateforme`. La machine ENI est un conteneur LXC sur Proxmox, sans
|
||||||
|
device-mapper ni loop : `scripts/coffre-luks.sh` s'y arrête sur sa garde, et le chiffrement du
|
||||||
|
disque de ce conteneur relève de l'hôte Proxmox, demandé à l'administrateur de l'école. Ce qui
|
||||||
|
est en place aujourd'hui : le SSE-C des archives déposées sur Garage. Le runbook ci-dessous vaut
|
||||||
|
pour une vraie VM (cible k3s, ou remplacement du conteneur). Décision et modèle de menace dans
|
||||||
|
l'[ADR 0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md). Un fichier image LUKS2
|
||||||
|
(`/srv/enervision/coffre.img`, clé `/root/enervision-coffre.key`) est monté sur
|
||||||
|
`/srv/enervision/coffre`, et `/var/lib/docker/volumes` est bind-monté depuis ce coffre : les
|
||||||
|
volumes des trois environnements sont chiffrés au repos sans qu'un fichier Compose change.
|
||||||
|
`scripts/coffre-luks.sh` pose tout, rejouable ; Terraform le joue aussi quand `coffre_taille` est
|
||||||
|
renseignée. Prérequis : deux fois la taille actuelle des volumes libre sur le disque, le temps de
|
||||||
|
la migration. Runbook, joué en root sur la VM, coupure des trois environnements d'une à trois
|
||||||
|
minutes :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp scripts/coffre-luks.sh root@10.101.200.37:/tmp/
|
||||||
|
ssh root@10.101.200.37 'COFFRE_TAILLE=30G COFFRE_MIGRER=1 bash /tmp/coffre-luks.sh'
|
||||||
|
ssh root@10.101.200.37 'findmnt /var/lib/docker/volumes && lsblk /dev/mapper/enervision-coffre && docker ps'
|
||||||
|
ssh root@10.101.200.37 'curl -k https://localhost:10443/api/v1/health/ready'
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensuite, dans cet ordre : sauvegarder `/root/enervision-coffre.key` hors de la VM (sans elle, les
|
||||||
|
trois bases sont perdues) ; redémarrer la machine et rejouer les deux vérifications, ce qui valide
|
||||||
|
l'ordonnancement crypttab, fstab et drop-in Docker ; alors seulement supprimer la copie en clair,
|
||||||
|
`rm -rf /var/lib/docker/volumes.avant-coffre`. Le script affiche ces trois étapes à la fin et
|
||||||
|
n'exécute jamais la suppression.
|
||||||
|
|
||||||
## Cible à terme, k3s
|
## Cible à terme, k3s
|
||||||
|
|
||||||
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster, depuis la
|
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster, depuis la
|
||||||
@@ -312,11 +402,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
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ Les quatre couches existent désormais, portées par l'authentification.
|
|||||||
flowchart TB
|
flowchart TB
|
||||||
ep["endpoints<br/>health, auth, users, sites, alerts,<br/>recommendations, stats, readings, sensors,<br/>predictions, monitoring"]
|
ep["endpoints<br/>health, auth, users, sites, alerts,<br/>recommendations, stats, readings, sensors,<br/>predictions, monitoring"]
|
||||||
sc["schemas<br/>Pydantic"]
|
sc["schemas<br/>Pydantic"]
|
||||||
sv["services<br/>AuthService, UserService,<br/>SiteService, AlertService, RecommendationService,<br/>StatsService, ReadingService, SensorService,<br/>PredictionService, DriftService"]
|
sv["services<br/>AuthService, UserService,<br/>SiteService, AlertService, RecommendationService,<br/>StatsService, ReadingService, SensorService, PredictionService"]
|
||||||
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, alert, recommendation, reading,<br/>prediction, drift"]
|
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, alert, recommendation, reading, prediction"]
|
||||||
md["models<br/>13 tables"]
|
md["models<br/>10 tables"]
|
||||||
db[("PostgreSQL")]
|
db[("PostgreSQL")]
|
||||||
|
|
||||||
ep --> sc
|
ep --> sc
|
||||||
@@ -103,7 +103,17 @@ 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 |
|
||||||
|
| `APP_S3_ENDPOINT_URL` | absent | Endpoint S3 des archives ; `http://garage:3900` posé par Compose sur `airflow-scheduler`. Vide vaut absent |
|
||||||
|
| `APP_S3_REGION` | `garage` | Région déclarée au client S3 |
|
||||||
|
| `APP_S3_ACCESS_KEY` | absent | Identifiant de la clé Garage. Vide vaut absent |
|
||||||
|
| `APP_S3_SECRET_KEY` | absent | Secret de la clé Garage, `SecretStr`. Vide vaut absent |
|
||||||
|
| `APP_S3_BUCKET` | absent | Bucket des archives, `enervision-archives` en Compose. Vide vaut absent |
|
||||||
|
| `APP_S3_SSE_KEY` | absent | Base64 de 32 octets, clé SSE-C des archives, `SecretStr`. Vide vaut absent |
|
||||||
|
| `APP_READING_RETENTION_DAYS` | `1095` | Profondeur de `reading` en base chaude, 30 jours minimum |
|
||||||
|
|
||||||
|
L'API n'exige aucun des réglages `APP_S3_*` ni `APP_READING_RETENTION_DAYS` : seul
|
||||||
|
`app.etl.reading_retention` les réclame, et refuse de partir sans endpoint, clés et bucket.
|
||||||
|
|
||||||
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
|
||||||
@@ -229,28 +239,24 @@ côté pilote, en `500` plutôt qu'un refus propre.
|
|||||||
`DriftService.evaluate()` joint `prediction` et `reading` sur `(site_id, target_at = timestamp)`
|
`DriftService.evaluate()` joint `prediction` et `reading` sur `(site_id, target_at = timestamp)`
|
||||||
et compare deux fenêtres vives de 168 h, la récente et celle qui la précède. Il rend une ligne par
|
et compare deux fenêtres vives de 168 h, la récente et celle qui la précède. Il rend une ligne par
|
||||||
site plus une ligne globale, que `DriftRepository.enregistre()` écrit dans `drift_report` avec
|
site plus une ligne globale, que `DriftRepository.enregistre()` écrit dans `drift_report` avec
|
||||||
`ON CONFLICT DO NOTHING` sur `uq_drift_report_window`. L'instant de référence est tronqué à
|
`ON CONFLICT DO NOTHING` sur `uq_drift_report_window` : rejouer la commande sur la même fenêtre
|
||||||
l'heure, ce qui est la condition pour que cet index serve : rejouer la commande dans la même
|
n'ajoute rien.
|
||||||
heure n'ajoute rien.
|
|
||||||
|
|
||||||
| Métrique | Ce qu'elle dit |
|
| Métrique | Ce qu'elle dit |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `mae` | Erreur moyenne en kWh, la métrique même qu'optimise LightGBM |
|
| `mae` | Erreur moyenne en kWh, la métrique même qu'optimise LightGBM |
|
||||||
| `bias` | Erreur moyenne **signée** : c'est elle qui distingue un modèle plus bruyant d'un modèle qui se trompe systématiquement du même côté |
|
| `bias` | Erreur moyenne **signée** : c'est elle qui distingue un modèle plus bruyant d'un modèle qui se trompe systématiquement du même côté. Lue et servie, elle ne fait basculer le verdict que sous `--bias-threshold`, faute d'un seuil en kWh transposable d'un site à l'autre ([ADR 0013](../adr/0013-surveillance-de-derive-dans-le-backend.md)) |
|
||||||
| `mape` | Comparable entre sites de tailles différentes, hors réalisés nuls |
|
| `mape` | Comparable entre sites de tailles différentes, hors réalisés nuls |
|
||||||
| `coverage_ratio` | Part des prévisions disponibles qui ont trouvé leur réalisé : mesure le pipeline, pas le modèle |
|
| `coverage_ratio` | Part des prévisions disponibles qui ont trouvé leur réalisé : mesure le pipeline, pas le modèle |
|
||||||
| `insufficient_data_ratio` | Part des sites privés d'historique suffisant |
|
| `insufficient_data_ratio` | Part des sites privés d'historique suffisant |
|
||||||
| `model_references` | Les modèles vus dans la fenêtre : une MAE qui saute à l'instant où le modèle change est une régression de réentraînement, pas une dérive |
|
| `model_references` | Les modèles vus dans la fenêtre : une MAE qui saute à l'instant où le modèle change est une régression de réentraînement, pas une dérive |
|
||||||
|
|
||||||
Le verdict a trois valeurs, `stable`, `derive` et `indetermine` : sous un nombre minimal
|
Le verdict a trois valeurs, `stable`, `derive` et `indetermine` : sous un nombre minimal
|
||||||
d'observations, ou faute de fenêtre de référence à laquelle comparer, le service dit qu'il ne
|
d'observations, le service dit qu'il ne sait pas plutôt que de rendre un chiffre trompeur. La
|
||||||
sait pas plutôt que de rendre un chiffre trompeur. Un site qui figure dans la fenêtre de
|
|
||||||
référence mais plus dans la récente reçoit sa ligne à zéro observation : cesser d'être scoré est
|
|
||||||
la panne que cette surveillance existe pour dire. La
|
|
||||||
fenêtre est fermée à droite par un délai de grâce de 2 h, le temps que l'ingestion livre le
|
fenêtre est fermée à droite par un délai de grâce de 2 h, le temps que l'ingestion livre le
|
||||||
réalisé de la dernière heure. `python -m app.monitoring.drift` l'exécute, le DAG `derive`
|
réalisé de la dernière heure. `python -m app.monitoring.drift` l'exécute, le DAG `derive`
|
||||||
l'ordonnance, et `GET /api/v1/monitoring/drift` sert le dernier rapport de chaque site. Les
|
l'ordonnance, et `GET /api/v1/monitoring/drift` sert le dernier rapport de chaque site. Les
|
||||||
arbitrages sont dans l'[ADR 0011](../adr/0011-surveillance-de-derive-dans-le-backend.md).
|
arbitrages sont dans l'[ADR 0013](../adr/0013-surveillance-de-derive-dans-le-backend.md).
|
||||||
|
|
||||||
### Détection d'alertes internes
|
### Détection d'alertes internes
|
||||||
|
|
||||||
@@ -360,8 +366,10 @@ pas prise :
|
|||||||
| `license_info` | Aucune licence n'est choisie |
|
| `license_info` | Aucune licence n'est choisie |
|
||||||
| `contact` | Aucun canal de support n'existe |
|
| `contact` | Aucun canal de support n'existe |
|
||||||
|
|
||||||
Deux schémas de sécurité sont déclarés : `Jeton d'accès` pour le porteur JWT, et
|
Deux schémas de sécurité sont déclarés : `JetonAcces` pour le porteur JWT, et
|
||||||
`Cookie de rafraîchissement` pour `/auth/refresh` et `/auth/logout`. **Le second est purement
|
`CookieRafraichissement` pour `/auth/refresh` et `/auth/logout`, des noms ASCII délibérés (issue
|
||||||
|
#41 : un outillage tiers comme ZAP peut mal analyser un nom de schéma accentué dans le contrat).
|
||||||
|
**Le second est purement
|
||||||
documentaire** : son `auto_error=False` garantit qu'il ne décide d'aucun refus. Le passer à vrai
|
documentaire** : son `auto_error=False` garantit qu'il ne décide d'aucun refus. Le passer à vrai
|
||||||
ferait répondre 403 avant d'atteindre `lit_le_cookie()`, et `/auth/refresh` cesserait de rendre le
|
ferait répondre 403 avant d'atteindre `lit_le_cookie()`, et `/auth/refresh` cesserait de rendre le
|
||||||
401 sur lequel le frontend déclenche sa déconnexion.
|
401 sur lequel le frontend déclenche sa déconnexion.
|
||||||
@@ -425,8 +433,13 @@ Le reste, par ordre de surface :
|
|||||||
écriture des journaux. C'est la troisième ligne de défense : la première est de ne rien passer
|
écriture des journaux. C'est la troisième ligne de défense : la première est de ne rien passer
|
||||||
de secret au logger, la deuxième de ne jamais mettre un jeton dans une URL.
|
de secret au logger, la deuxième de ne jamais mettre un jeton dans une URL.
|
||||||
- En-têtes posés par l'application : `X-Content-Type-Options`, `X-Frame-Options`,
|
- En-têtes posés par l'application : `X-Content-Type-Options`, `X-Frame-Options`,
|
||||||
`Referrer-Policy`, plus `Cache-Control: no-store` sur `/auth/*`. HSTS et CSP appartiennent au
|
`Referrer-Policy`, `Cross-Origin-Resource-Policy: same-origin`, plus `Cache-Control: no-store`
|
||||||
terminateur TLS, que l'application ne connaît pas : le reverse proxy les pose
|
sur `/auth/*`. Le CORP est fixé à `same-origin` parce qu'aucun client légitime ne charge l'API
|
||||||
|
en `no-cors` (image, script, média) depuis une autre origine : le frontend l'appelle en relatif
|
||||||
|
(`/api/v1`), sur sa propre origine, via `proxy.conf.json` en dev et le reverse proxy nginx
|
||||||
|
(`infra/proxy/conf.d/enervision.conf`) en recette et en production. Les appels `HttpClient`, en
|
||||||
|
mode `cors`, n'y sont de toute façon pas soumis. HSTS et CSP appartiennent au terminateur TLS, que
|
||||||
|
l'application ne connaît pas : le reverse proxy les pose
|
||||||
([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)).
|
([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)).
|
||||||
- Le conteneur tourne en utilisateur non-root, avec un `HEALTHCHECK` sur `/api/v1/health/live`.
|
- Le conteneur tourne en utilisateur non-root, avec un `HEALTHCHECK` sur `/api/v1/health/live`.
|
||||||
- TLS, limitation de débit au frontal et journal d'accès sont portés par le reverse proxy.
|
- TLS, limitation de débit au frontal et journal d'accès sont portés par le reverse proxy.
|
||||||
@@ -437,7 +450,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
|
||||||
|
|
||||||
|
|||||||
+107
-12
@@ -16,8 +16,9 @@ L'ingestion des **mesures** est implémentée pour les deux sources du MVP, le d
|
|||||||
l'API Mock. Celle des **alertes** de l'API Mock, `/alerts`, reste à faire : voir
|
l'API Mock. Celle des **alertes** de l'API Mock, `/alerts`, reste à faire : voir
|
||||||
l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md). Les alertes `source='enervision'`,
|
l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md). Les alertes `source='enervision'`,
|
||||||
elles, sont produites par la détection interne, désormais ordonnancée par le DAG Airflow `alertes`
|
elles, sont produites par la détection interne, désormais ordonnancée par le DAG Airflow `alertes`
|
||||||
(issue #116). L'orchestration de l'ingestion, les agrégats continus, la compression et la
|
(issue #116). L'orchestration de l'ingestion, les agrégats continus et la compression restent
|
||||||
rétention restent des cibles.
|
des cibles. La rétention de `reading` est faite : chaque chunk plus vieux que la borne est exporté
|
||||||
|
vers Garage puis supprimé (issue #36, section « Rétention et archivage » ci-dessous).
|
||||||
|
|
||||||
## Trois emplacements, trois rôles
|
## Trois emplacements, trois rôles
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ au mauvais endroit ne s'exécute jamais, ou s'exécute deux fois.
|
|||||||
| Emplacement | Contenu | Quand ça s'exécute |
|
| Emplacement | Contenu | Quand ça s'exécute |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `db/init/` | Extensions, bases annexes | **Une seule fois**, à la première initialisation du conteneur, quand `PGDATA` est vide. Ne rejoue jamais |
|
| `db/init/` | Extensions, bases annexes | **Une seule fois**, à la première initialisation du conteneur, quand `PGDATA` est vide. Ne rejoue jamais |
|
||||||
| `db/migrations/` | SQL versionné qui ne découle pas du schéma applicatif : rétention, compression | À la main, aujourd'hui vide |
|
| `db/migrations/` | SQL versionné qui ne découle pas du schéma applicatif : compression. La rétention de `reading` n'y est pas : une politique TimescaleDB ignorerait l'export, elle vit dans `apps/backend/app/etl/reading_retention.py`, ordonnancée par le DAG `retention` ([ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)) | À la main, aujourd'hui vide |
|
||||||
| `apps/backend/alembic/` | Le schéma exposé par l'API, et lui seul | `alembic upgrade head`, c'est `Base.metadata` qui fait foi |
|
| `apps/backend/alembic/` | Le schéma exposé par l'API, et lui seul | `alembic upgrade head`, c'est `Base.metadata` qui fait foi |
|
||||||
|
|
||||||
Une hypertable relève des deux derniers : **Alembic crée la table, et le `create_hypertable()`
|
Une hypertable relève des deux derniers : **Alembic crée la table, et le `create_hypertable()`
|
||||||
@@ -75,8 +76,9 @@ Les mécanismes d'ingestion sont maintenant implémentés pour les deux sources
|
|||||||
|
|
||||||
Les traitements sont actuellement exécutables directement depuis le backend.
|
Les traitements sont actuellement exécutables directement depuis le backend.
|
||||||
|
|
||||||
L'orchestration avec Apache Airflow reste une cible, tout comme les agrégats continus,
|
L'orchestration avec Apache Airflow reste une cible, tout comme les agrégats continus et la
|
||||||
la compression et les politiques de rétention.
|
compression. La rétention est faite : le DAG `retention` exporte chaque chunk de `reading` plus
|
||||||
|
vieux que `READING_RETENTION_DAYS` vers Garage, puis le supprime.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -91,7 +93,8 @@ flowchart LR
|
|||||||
|
|
||||||
hy -.-> agg[("Agrégat continu")]
|
hy -.-> agg[("Agrégat continu")]
|
||||||
hy -.-> comp["Compression"]
|
hy -.-> comp["Compression"]
|
||||||
hy -.-> ret["Rétention"]
|
hy --> ret["Rétention : export CSV gzip vers Garage, puis drop_chunks"]
|
||||||
|
ret --> garage[("Garage S3")]
|
||||||
|
|
||||||
agg -.-> backend["API FastAPI"]
|
agg -.-> backend["API FastAPI"]
|
||||||
agg -.-> graf["Grafana"]
|
agg -.-> graf["Grafana"]
|
||||||
@@ -104,6 +107,26 @@ Les flèches pointillées représentent les éléments encore prévus comme cibl
|
|||||||
Les lectures de l'API et de Grafana viseront l'agrégat continu, pas la table brute : c'est tout
|
Les lectures de l'API et de Grafana viseront l'agrégat continu, pas la table brute : c'est tout
|
||||||
l'intérêt de TimescaleDB, et cela doit rester vrai quand les volumes augmenteront.
|
l'intérêt de TimescaleDB, et cela doit rester vrai quand les volumes augmenteront.
|
||||||
|
|
||||||
|
### Rétention et archivage (issue #36)
|
||||||
|
|
||||||
|
Statut : `Fait`.
|
||||||
|
|
||||||
|
`apps/backend/app/etl/reading_retention.py`, ordonnancé chaque nuit à 03:20 UTC par le DAG
|
||||||
|
`retention`, sélectionne dans `timescaledb_information.chunks` les chunks de `reading` dont
|
||||||
|
`range_end` est antérieur ou égal à `now() - READING_RETENTION_DAYS` : seul un chunk entièrement
|
||||||
|
plus vieux que la borne est éligible. Chaque chunk est lu via l'hypertable (`WHERE timestamp >=
|
||||||
|
range_start AND timestamp < range_end`), jamais via la table interne, sérialisé en CSV gzip
|
||||||
|
reproductible (jsonb et tableaux en JSON trié), puis écrit chiffré SSE-C sous la clé
|
||||||
|
`reading/<année>/reading_<début>_<fin>.csv.gz`, bornes UTC compactes. L'objet est relu et son
|
||||||
|
sha256 comparé à celui du corps envoyé ; en cas d'écart le chunk est conservé. Seulement alors
|
||||||
|
`drop_chunks('reading', older_than => range_end, newer_than => range_start)` supprime ce chunk et
|
||||||
|
lui seul, dans une transaction dédiée et courte : `drop_chunks` pose un verrou exclusif sur
|
||||||
|
`reading`, `site` et `dataset` jusqu'au COMMIT. Un objet déjà présent avec le même sha256 n'est pas
|
||||||
|
réécrit et un chunk supprimé n'est plus éligible : rejouer le DAG est sans effet, `--dry-run` liste
|
||||||
|
et mesure sans rien écrire. Le premier passage en production archive les chunks de janvier à
|
||||||
|
septembre 2023 ; la démo, ancrée au 31/12/2024, n'est pas touchée. Restauration manuelle :
|
||||||
|
télécharger l'objet avec la clé SSE-C, `gunzip`, `COPY` dans `reading` ; aucune commande fournie.
|
||||||
|
|
||||||
## Tables d'authentification
|
## Tables d'authentification
|
||||||
|
|
||||||
Statut : `Fait`.
|
Statut : `Fait`.
|
||||||
@@ -239,8 +262,9 @@ colonne de temps : les index déclarés dans la révision le couvrent déjà.
|
|||||||
devient ininterprétable dès le premier changement d'heure.
|
devient ininterprétable dès le premier changement d'heure.
|
||||||
- **La colonne de partitionnement entre dans la clé primaire.** Dans `reading` elle s'appelle
|
- **La colonne de partitionnement entre dans la clé primaire.** Dans `reading` elle s'appelle
|
||||||
`timestamp` : c'est un nom de colonne, son type reste `timestamptz`.
|
`timestamp` : c'est un nom de colonne, son type reste `timestamptz`.
|
||||||
- **Les politiques de rétention et de compression** vont dans `db/migrations/`, pas dans Alembic :
|
- **Les politiques de compression** vont dans `db/migrations/`, pas dans Alembic : elles ne
|
||||||
elles ne découlent pas du schéma applicatif.
|
découlent pas du schéma applicatif. La rétention de `reading` est un traitement ETL
|
||||||
|
(`reading_retention.py`), pas une politique TimescaleDB : elle doit exporter avant de supprimer.
|
||||||
- **Tout modèle doit être importé dans `app/models/__init__.py`**, sans quoi
|
- **Tout modèle doit être importé dans `app/models/__init__.py`**, sans quoi
|
||||||
`alembic revision --autogenerate` ne le voit pas et génère un `drop` de sa table.
|
`alembic revision --autogenerate` ne le voit pas et génère un `drop` de sa table.
|
||||||
|
|
||||||
@@ -251,7 +275,9 @@ livrés : ce qui suit porte sur leur exploitation, plus sur leur forme.
|
|||||||
|
|
||||||
- **Quelle granularité** conserver à long terme à l'ingestion : seconde, minute ou quart d'heure.
|
- **Quelle granularité** conserver à long terme à l'ingestion : seconde, minute ou quart d'heure.
|
||||||
- **Quels agrégats continus** créer et sur quelles fenêtres.
|
- **Quels agrégats continus** créer et sur quelles fenêtres.
|
||||||
- **Quelle profondeur de rétention** conserver en données brutes et à partir de quand compresser.
|
- **Quelle profondeur de rétention** : répondu par l'issue #36. Trois ans en base chaude par
|
||||||
|
défaut (`READING_RETENTION_DAYS`, 1095 jours) ; au-delà, les chunks sont archivés en CSV gzip
|
||||||
|
sur Garage, chiffrés SSE-C, puis supprimés. Reste ouvert : à partir de quand compresser.
|
||||||
- **Multi-tenant ou non** : un site appartient-il à un client et faut-il cloisonner les lectures.
|
- **Multi-tenant ou non** : un site appartient-il à un client et faut-il cloisonner les lectures.
|
||||||
|
|
||||||
## Modélisation détaillée des données
|
## Modélisation détaillée des données
|
||||||
@@ -289,6 +315,10 @@ Chaque table remplit un rôle précis dans le traitement et l'exploitation des d
|
|||||||
| `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d'EnerVision |
|
| `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d'EnerVision |
|
||||||
| `drift_report` | Suivre l'écart entre prévisions et réalisé, par site et tous sites confondus | Surveillance de dérive d'EnerVision |
|
| `drift_report` | Suivre l'écart entre prévisions et réalisé, par site et tous sites confondus | Surveillance de dérive d'EnerVision |
|
||||||
|
|
||||||
|
Le scoring (`ml_score`) charge le modèle depuis un fichier local (`models/lightgbm-consumption.txt`)
|
||||||
|
et trace son empreinte SHA-256 dans `prediction.model_reference`. Il ne lit aucune version depuis
|
||||||
|
le Model Registry MLflow (`ml/`) : ce registre sert aujourd'hui à la traçabilité des
|
||||||
|
entraînements, pas au déploiement du modèle de scoring.
|
||||||
Les anomalies historiques décrites dans les JSON sont conservées dans `dataset.metadata`.
|
Les anomalies historiques décrites dans les JSON sont conservées dans `dataset.metadata`.
|
||||||
|
|
||||||
Elles servent à l'analyse des données et ne sont pas considérées comme des alertes actuelles.
|
Elles servent à l'analyse des données et ne sont pas considérées comme des alertes actuelles.
|
||||||
@@ -297,7 +327,7 @@ Les lignes de `drift_report` sont écrites par `app.monitoring.drift`, ordonnanc
|
|||||||
`derive`. Une ligne dont le `site_id` est `NULL` porte le résultat global, tous sites confondus :
|
`derive`. Une ligne dont le `site_id` est `NULL` porte le résultat global, tous sites confondus :
|
||||||
c'est pourquoi l'unicité passe par un index sur `coalesce(site_id, '')` et non par une contrainte,
|
c'est pourquoi l'unicité passe par un index sur `coalesce(site_id, '')` et non par une contrainte,
|
||||||
qui ne dédoublonnerait jamais deux lignes globales. Le calcul, ses seuils et ce qu'il refuse de
|
qui ne dédoublonnerait jamais deux lignes globales. Le calcul, ses seuils et ce qu'il refuse de
|
||||||
comparer sont dans l'[ADR 0011](../adr/0011-surveillance-de-derive-dans-le-backend.md).
|
comparer sont dans l'[ADR 0013](../adr/0013-surveillance-de-derive-dans-le-backend.md).
|
||||||
|
|
||||||
Les lignes de `recommendation` sont écrites par le moteur de règles du backend
|
Les lignes de `recommendation` sont écrites par le moteur de règles du backend
|
||||||
(`app/services/recommendation_rules.py`), déclenché par `POST /api/v1/recommendations/generate`,
|
(`app/services/recommendation_rules.py`), déclenché par `POST /api/v1/recommendations/generate`,
|
||||||
@@ -433,10 +463,29 @@ Les paramètres de ligne de commande disponibles pour l'import sont :
|
|||||||
```text
|
```text
|
||||||
--start-time
|
--start-time
|
||||||
--end-time
|
--end-time
|
||||||
--limit
|
|
||||||
--dry-run
|
--dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Piège sur `limit`, corrigé dans le code plutôt que documenté** : l'API ne renvoie pas un flux à
|
||||||
|
un rythme naturel, elle répartit exactement `limit` lectures, espacées uniformément, sur toute la
|
||||||
|
fenêtre `[start_time, end_time)` demandée, la première au tout début de la fenêtre (vérifié
|
||||||
|
empiriquement en interrogeant directement l'API). Une fenêtre d'une heure avec `limit=1000`, le
|
||||||
|
réglage d'origine, renvoyait donc 1000 lectures espacées de 3,6 secondes à l'intérieur de cette
|
||||||
|
heure, pas une lecture horaire, incompatible avec les lags positionnels de `build_features`.
|
||||||
|
Plutôt que documenter la règle « `limit` = nombre d'heures de la fenêtre » et compter sur chaque
|
||||||
|
appelant pour la respecter, `limit_for_window()` la porte : `import_mock_api_history()` calcule
|
||||||
|
`limit` depuis la fenêtre reçue, refuse une fenêtre dont `start_time` ne tombe pas pile sur
|
||||||
|
l'heure (c'est elle qui ancre l'alignement), et refuse un intervalle de plus de 1000 heures (le
|
||||||
|
plafond `limit` de l'API). `--limit` n'existe donc plus côté CLI. Deux formes de fenêtre sont
|
||||||
|
gérées : un multiple entier d'heures (`limit` = ce nombre d'heures, une lecture par heure
|
||||||
|
espacée d'1h pile, chemin du backfill manuel) ou une fenêtre plus courte qu'une heure, ou qui
|
||||||
|
n'en est pas un multiple entier (`limit=1`, seule valeur qui reste alignée quand l'espacement
|
||||||
|
`durée / limit` ne peut valoir 1h pile). Le DAG `mock_api_import` est dans ce second cas : il
|
||||||
|
demande la fenêtre `[heure pile précédant le déclenchement, instant du déclenchement)`, plus
|
||||||
|
courte qu'une heure, plutôt que l'intervalle Airflow `[data_interval_start, data_interval_end)`
|
||||||
|
tel quel (`[:45, :45)`) qui aurait placé l'unique lecture à :45, hors de la grille horaire du
|
||||||
|
reste du schéma.
|
||||||
|
|
||||||
### Flux d'ingestion API Mock
|
### Flux d'ingestion API Mock
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -488,7 +537,7 @@ réponse est donc traitée comme une entrée hostile, conformément à API10 dan
|
|||||||
[la traçabilité OWASP](owasp-traceabilite.md). Le risque premier n'est pas la fausse alerte,
|
[la traçabilité OWASP](owasp-traceabilite.md). Le risque premier n'est pas la fausse alerte,
|
||||||
c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction.
|
c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction.
|
||||||
|
|
||||||
Quatre garde-fous, tous dans `mock_api_import.py` :
|
Cinq garde-fous, tous dans `mock_api_import.py` :
|
||||||
|
|
||||||
| Garde-fou | Mise en œuvre |
|
| Garde-fou | Mise en œuvre |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -496,6 +545,7 @@ Quatre garde-fous, tous dans `mock_api_import.py` :
|
|||||||
| Taille de tableau plafonnée | `MAX_SITES` sites, et au plus `--limit` mesures par site |
|
| Taille de tableau plafonnée | `MAX_SITES` sites, et au plus `--limit` mesures par site |
|
||||||
| Bornes physiques | `PHYSICAL_BOUNDS`, une plage par grandeur |
|
| Bornes physiques | `PHYSICAL_BOUNDS`, une plage par grandeur |
|
||||||
| Frontière d'anti-corruption | `build_site_row()` et `build_reading_row()`, qui ne recopient que les champs attendus |
|
| Frontière d'anti-corruption | `build_site_row()` et `build_reading_row()`, qui ne recopient que les champs attendus |
|
||||||
|
| Refus de recouvrir l'historique | `refuse_if_overlaps_historical_dataset()`, voir ci-dessous |
|
||||||
|
|
||||||
Une valeur hors bornes, d'un type inattendu, `NaN` ou infinie devient `NULL`. Elle laisse sa
|
Une valeur hors bornes, d'un type inattendu, `NaN` ou infinie devient `NULL`. Elle laisse sa
|
||||||
trace dans `null_reasons` sous la forme `out_of_physical_bounds:<colonne>`, et `data_quality`
|
trace dans `null_reasons` sous la forme `out_of_physical_bounds:<colonne>`, et `data_quality`
|
||||||
@@ -506,6 +556,51 @@ d'origine intacte : rien n'est perdu, seule son exploitation est bornée.
|
|||||||
Le plafond de taille s'applique après désérialisation de la réponse. Borner le corps HTTP
|
Le plafond de taille s'applique après désérialisation de la réponse. Borner le corps HTTP
|
||||||
lui-même demanderait une lecture en flux, et reste à faire.
|
lui-même demanderait une lecture en flux, et reste à faire.
|
||||||
|
|
||||||
|
### Réconciliation entre les deux sources (issue #15)
|
||||||
|
|
||||||
|
`historical_import` (source `csv`) et `mock_api_import` (source `api_history`) écrivent toutes
|
||||||
|
deux dans `reading`. Trois décisions ferment cette réconciliation :
|
||||||
|
|
||||||
|
- **Le trou temporel est accepté.** Le dataset historique s'arrête au 31/12/2024, et
|
||||||
|
`mock_api_import` n'importe que l'heure précédant chaque déclenchement : rien ne comble
|
||||||
|
automatiquement la période intermédiaire, et rien ne le pourra jamais, aucune mesure réelle
|
||||||
|
n'existe pour ces instants. Conséquence pour le ML, pas nouvelle mais que ce trou rend
|
||||||
|
définitive : `build_features()` calcule ses lags par `shift(n)` positionnel, et `train.py`
|
||||||
|
n'écarte que les lignes où `lag_168h` est `NaN`. Pour un site présent dans les deux sources, les
|
||||||
|
168 premières lectures `api_history` qui suivent le trou héritent donc de lags et de moyennes
|
||||||
|
glissantes calculés sur décembre 2024 (et tant que l'ingestion a moins de 7 jours, c'est le cas
|
||||||
|
de toutes les lectures). Même effet, plus ponctuel, pour chaque heure que le DAG manque
|
||||||
|
(`mock_api_import` en échec, Airflow arrêté). Aucun garde-fou ne détecte aujourd'hui un lag
|
||||||
|
calculé sur un écart réel différent de celui attendu ; issue de suivi à ouvrir.
|
||||||
|
- **Le recouvrement est refusé à l'ingestion.** `uq_reading_source` autorise deux lignes au même
|
||||||
|
`(site_id, timestamp)` dès que `source` diffère : rien dans le schéma n'empêche donc un import
|
||||||
|
Mock API manuel avec une fenêtre passée (le script accepte `--start-time`/`--end-time`
|
||||||
|
arbitraires) de dupliquer un point déjà couvert par le CSV. `import_mock_api_history()` appelle
|
||||||
|
`refuse_if_overlaps_historical_dataset()` avant toute écriture, y compris en `--dry-run` (le
|
||||||
|
contrôle est en lecture seule) et avant le moindre appel à l'API Mock : si la fenêtre demandée
|
||||||
|
recouvre au moins une lecture `source='csv'`, l'import est refusé (`ValueError`) plutôt que
|
||||||
|
d'écrire un doublon inter-source silencieux. Le contrôle ne porte que sur la fenêtre demandée,
|
||||||
|
pas sur les lectures reçues : `fetch_readings()` écarte donc toute lecture dont le `timestamp`
|
||||||
|
déborde de `[start_time, end_time)`, pour qu'une réponse hors fenêtre (bug du mock, ou hostile)
|
||||||
|
ne puisse pas le contourner. Ce contrôle compare des instants, pas des chaînes : `parse_datetime()`
|
||||||
|
pose `tzinfo=UTC` sur une entrée sans fuseau (même pattern que `_vers_utc()` dans
|
||||||
|
`app/services/reading.py`), sans quoi l'encodeur `timestamptz` d'asyncpg lirait un datetime naïf
|
||||||
|
dans le fuseau local du **processus**, correct dans le conteneur Airflow (UTC) mais décalé pour
|
||||||
|
un import manuel lancé depuis un poste en Europe/Paris.
|
||||||
|
- **Le pipeline ML déduplique en défense.** Le garde-fou ci-dessus protège l'ingestion, pas
|
||||||
|
la lecture : si un recouvrement se produisait malgré tout (import direct en base, contournement
|
||||||
|
du script), `ml/enervision_ml/data.py` ne doit pas casser silencieusement l'hypothèse de
|
||||||
|
`build_features` (« une ligne par `(site_id, timestamp)` »). `load_from_database()` et
|
||||||
|
`load_recent_from_database()` utilisent donc `SELECT DISTINCT ON (site_id, timestamp)`, `source
|
||||||
|
= 'csv'` gagnant sur `'api_history'` en cas d'égalité, l'historique étant une source vérifiée,
|
||||||
|
l'API Mock une entrée hostile (cf. ci-dessus). **Cette préférence est spécifique au chargeur
|
||||||
|
ML.** `GET /readings` renvoie les deux lignes sans les fusionner, et `DriftRepository` /
|
||||||
|
`ReadingRepository.latest_by_site()` / `.latest_for_site()` départagent par `reading_id` le plus
|
||||||
|
grand (en pratique la ligne insérée en dernier, pas forcément `csv`) : en cas de recouvrement, la
|
||||||
|
dérive comparerait alors une prévision à une valeur différente de celle sur laquelle le modèle a
|
||||||
|
appris. Pas d'incohérence aujourd'hui tant que le recouvrement reste refusé à l'ingestion ; à
|
||||||
|
aligner si ce garde-fou devait un jour être contourné.
|
||||||
|
|
||||||
### Qualité des données de l'API Mock
|
### Qualité des données de l'API Mock
|
||||||
|
|
||||||
Les valeurs `NULL` ne sont pas remplacées pendant l'ingestion.
|
Les valeurs `NULL` ne sont pas remplacées pendant l'ingestion.
|
||||||
|
|||||||
+257
-111
@@ -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,82 +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 --> 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Déclenchement
|
## Déclenchement
|
||||||
|
|
||||||
Les six workflows hébergés par GitHub se déclenchent sur `push` **et** sur `pull_request`,
|
**Sur une PR**, le job `changes` lit la liste des fichiers modifiés par l'API GitHub
|
||||||
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.
|
|
||||||
|
|
||||||
Le filtre d'`airflow.yml` mérite un mot : il inclut `ml/pyproject.toml`, `ml/uv.lock`,
|
**Sur un push vers `dev` ou `main`**, tous les filtres valent vrai. C'est le moment où l'analyse
|
||||||
`ml/enervision_ml/**`, `apps/backend/pyproject.toml`, `apps/backend/uv.lock` et
|
Sonar doit couvrir tout le dépôt, et paths-filter comparerait sinon le push à sa base de fusion
|
||||||
`apps/backend/app/**` parce que l'image Airflow copie le code et les dépendances des deux
|
avec `main`, en retard de 80 commits. Une branche de travail ne déclenche plus rien par un push :
|
||||||
modules : celles du ML pour `ml_train`/`ml_score`, celles du backend depuis que le DAG `alertes`
|
la CI part de sa PR, une seule fois par commit.
|
||||||
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
|
Deux filtres écoutent plus que leur dossier, parce que ce qu'ils testent dépend d'autres modules :
|
||||||
CI complète à chaque push, et un merge vers n'importe quelle branche la déclenche aussi. C'est
|
|
||||||
délibéré pendant le projet (retour au plus tôt, et la CI tournera sur `main` dès la remontée sans
|
|
||||||
rien changer), mais ce serait à borner sur un dépôt à forte fréquence de push.
|
|
||||||
|
|
||||||
`backend.yml`, `ml.yml` et `airflow.yml` déclarent en plus un groupe de concurrence par référence
|
- `airflow` inclut `ml/pyproject.toml`, `ml/uv.lock`, `ml/enervision_ml/**`,
|
||||||
git avec `cancel-in-progress`, ce qui annule un run devenu obsolète par un push plus récent.
|
`apps/backend/pyproject.toml`, `apps/backend/uv.lock` et `apps/backend/app/**`. L'image
|
||||||
|
Airflow copie le code et les dépendances des deux modules
|
||||||
|
([ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)), et une modification de l'un
|
||||||
|
ou de l'autre peut casser sa construction.
|
||||||
|
- `e2e` inclut le frontend, l'API, ses migrations et son Dockerfile, le proxy, les fichiers
|
||||||
|
Compose, `db/`, `tests/` et les scripts qu'il appelle : tout ce qui change un parcours.
|
||||||
|
|
||||||
|
`dast.yml` reste hors de l'orchestrateur : un scan actif est trop long pour chaque PR. Il se
|
||||||
|
lance à la main, chaque lundi, et sur une PR qui modifie le scan, son jeu de données ou ses
|
||||||
|
comptes.
|
||||||
|
|
||||||
|
Le groupe de concurrence de `ci.yml` annule le run d'une PR devenu obsolète par un push plus
|
||||||
|
récent. Pour un push sur `dev` ou `main`, le groupe est le SHA et rien n'est annulé : un run
|
||||||
|
coupé en plein `make stack-up` laisserait la stack à moitié redémarrée.
|
||||||
|
|
||||||
**Piège de version** : `etl/airflow` tourne en **Python 3.12** et non 3.14 : c'est l'interpréteur
|
**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
|
||||||
@@ -108,34 +94,52 @@ environnement.
|
|||||||
|
|
||||||
## Déploiement
|
## Déploiement
|
||||||
|
|
||||||
`deploy.yml` est le septième workflow, et le seul qui ne tourne pas chez GitHub : il s'exécute sur
|
`deploy.yml` est le seul workflow qui ne tourne pas chez GitHub : il s'exécute sur un runner
|
||||||
un runner auto-hébergé installé sur la VM ENI, label `eni-g3`, parce que les runners hébergés ne
|
auto-hébergé installé sur la VM ENI, label `eni-g3`, parce que les runners hébergés ne joignent
|
||||||
joignent pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port
|
pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port entrant
|
||||||
entrant n'est ouvert.
|
n'est ouvert. Il n'a pas de déclencheur propre en dehors de `workflow_dispatch` : c'est le job
|
||||||
|
`deploy` de `ci.yml` qui l'appelle, sur un push, une fois « CI ok » vert.
|
||||||
|
|
||||||
| Événement | Environnement GitHub | Dossier sur la VM | Garde |
|
| É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>`
|
||||||
@@ -143,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
|
||||||
@@ -165,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 :
|
||||||
|
|
||||||
@@ -195,12 +207,42 @@ avant `alembic upgrade head`.
|
|||||||
La couverture est **désactivée** sur ce job (`pytest -m integration --no-cov`) : il ne joue qu'une
|
La couverture est **désactivée** sur ce job (`pytest -m integration --no-cov`) : il ne joue qu'une
|
||||||
partie de la suite, et son taux n'aurait aucun sens face au seuil de 85 %.
|
partie de la suite, et son taux n'aurait aucun sens face au seuil de 85 %.
|
||||||
|
|
||||||
|
### Pourquoi le job d'intégration ML installe aussi le backend
|
||||||
|
|
||||||
|
Le schéma de la base n'a qu'une source, les six révisions Alembic de `apps/backend/alembic` : le
|
||||||
|
backend est propriétaire du schéma, `ml/` n'en est que consommateur. Reconstruire ce schéma à la
|
||||||
|
main dans le job ML donnerait un job vert sur une base qui n'est pas la nôtre, exactement l'erreur
|
||||||
|
qu'évite déjà le choix de l'image `timescaledb-ha` plutôt qu'un `postgres` nu. Le job installe
|
||||||
|
donc les deux environnements uv, applique `alembic upgrade head`, puis joue `-m integration` côté
|
||||||
|
`ml/` et `-m chaine` côté backend.
|
||||||
|
|
||||||
|
Conséquence sur le déclenchement : le filtre `ml` de `ci.yml` inclut `apps/backend/alembic/**` et
|
||||||
|
`apps/backend/app/models/**`. Sans eux, une migration qui renomme une colonne de `reading` ne
|
||||||
|
déclencherait pas ce job, le SQL brut du pipeline dériverait du schéma, et **rien ne casserait
|
||||||
|
avant la production**. Le prix est qu'une PR touchant seulement une migration lance aussi le lint
|
||||||
|
et le typage de `ml/` : environ deux minutes de runner, en parallèle. Même arbitrage que le filtre
|
||||||
|
d'`airflow.yml`, qui écoute déjà `ml/**` et `apps/backend/app/**` parce que son image réunit les
|
||||||
|
deux.
|
||||||
|
|
||||||
|
Le marqueur `chaine` est distinct d'`integration` pour une raison mécanique : le job `integration`
|
||||||
|
de `backend.yml` n'installe pas `ml/.venv`, et sélectionnerait sinon un test qui lance les
|
||||||
|
binaires du pipeline. Il est aussi exclu d'`addopts`, sans quoi `make test` échouerait sur tout
|
||||||
|
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
|
||||||
@@ -225,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.
|
||||||
|
|
||||||
@@ -256,39 +299,134 @@ Ils ne transitent ni par git ni par GitHub, et le runner, qui travaille dans ce
|
|||||||
à recevoir. Le revers : ils ne sont sauvegardés nulle part ailleurs. Un `.env` perdu se
|
à recevoir. Le revers : ils ne sont sauvegardés nulle part ailleurs. Un `.env` perdu se
|
||||||
régénère, ce qui invalide les sessions et les connexions chiffrées par Airflow.
|
régénère, ce qui invalide les sessions et les connexions chiffrées par Airflow.
|
||||||
|
|
||||||
### Pourquoi le job d'intégration ML installe aussi le backend
|
## Scan DAST (OWASP ZAP)
|
||||||
|
|
||||||
Le schéma de la base n'a qu'une source, les sept révisions Alembic de `apps/backend/alembic` : le
|
Statut : `En cours`. Le workflow `dast.yml` attaque l'API **en fonctionnement**, ce que ni Bandit,
|
||||||
backend est propriétaire du schéma, `ml/` n'en est que consommateur. Reconstruire ce schéma à la
|
ni `pip-audit`, ni Sonar ne font. Il se lance à la main (`workflow_dispatch`), chaque lundi à 3h
|
||||||
main dans le job ML donnerait un job vert sur une base qui n'est pas la nôtre, exactement l'erreur
|
UTC, et sur une PR qui modifie le scan lui-même. Pas à chaque PR : un scan actif dure plusieurs
|
||||||
qu'évite déjà le choix de l'image `timescaledb-ha` plutôt qu'un `postgres` nu. Le job installe
|
minutes.
|
||||||
donc les deux environnements uv, applique `alembic upgrade head`, puis joue `-m integration` côté
|
|
||||||
`ml/` et `-m chaine` côté backend.
|
|
||||||
|
|
||||||
Conséquence sur le déclenchement : les `paths` de `ml.yml` incluent `apps/backend/alembic/**` et
|
Le job démarre sur le runner la base (même image TimescaleDB que `docker-compose.yml`, base
|
||||||
`apps/backend/app/**`. Sans eux, une migration qui renomme une colonne de `reading` ne
|
jetable), applique les migrations, y sème `db/seeds/demo.sql` (sans données, `GET /sites` rend
|
||||||
déclencherait pas ce job, le SQL brut du pipeline dériverait du schéma, et **rien ne casserait
|
`[]`, chaque `/{site_id}` rend 404, et le scan actif ne frappe que des gestionnaires d'erreur),
|
||||||
avant la production**. `app/**` en entier, et non les seuls modèles : ce job est le seul à jouer
|
démarre le backend, puis `scripts/dast-token.sh` s'appuie sur `scripts/comptes-test.sh` pour
|
||||||
`-m chaine`, or la chaîne traverse les endpoints, les services et les schémas jusqu'à
|
créer les comptes et rend le jeton du **`lecteur`**. Le jeu et les comptes sont ceux de l'e2e.
|
||||||
`GET /predictions`. Un filtre plus étroit laisserait le test muet sur la PR même qui le casse. Le
|
|
||||||
prix est qu'une PR backend lance aussi le lint et le typage de `ml/` : environ deux minutes de
|
|
||||||
runner, en parallèle. Même arbitrage que le filtre d'`airflow.yml`, qui écoute déjà `ml/**` et
|
|
||||||
`apps/backend/app/**` parce que son image réunit les deux.
|
|
||||||
|
|
||||||
Le marqueur `chaine` est distinct d'`integration` pour une raison mécanique : le job `integration`
|
ZAP charge le contrat `/openapi.json` depuis un fichier (`zap-api-scan.py -f openapi -t
|
||||||
de `backend.yml` n'installe pas `ml/.venv`, et sélectionnerait sinon un test qui lance les
|
/zap/wrk/openapi.json`) et en importe les 26 opérations **quel que soit le jeton** : c'est le
|
||||||
binaires du pipeline. Il est aussi exclu d'`addopts`, sans quoi `make test` échouerait sur tout
|
contrat qui décide de ce qui est exploré, pas l'authentification. Le jeton ne change que les
|
||||||
poste où `ml/` n'est pas installé.
|
réponses obtenues sur les routes gardées : sans lui, elles répondraient toutes `401` plutôt que
|
||||||
|
de dérouler leur logique. Huit routes n'exigent aucun jeton porteur (les deux sondes, `login`,
|
||||||
|
`refresh`, `logout`, `forgot-password`, `reset-password` et `reset-password/validate`) et
|
||||||
|
répondent donc pareil avec ou sans lui.
|
||||||
|
|
||||||
|
Décisions à savoir défendre :
|
||||||
|
|
||||||
|
- **Le compte du scan est `lecteur`, jamais `admin`.** Un scan actif avec un jeton admin frapperait
|
||||||
|
`POST /users` et la réinitialisation de mots de passe pour de bon. Le script passe par un admin
|
||||||
|
jetable pour créer le lecteur (l'API n'a pas d'inscription publique) puis ne s'en sert plus.
|
||||||
|
- **Un compte neuf est en `must_change_password`**, et toute route gardée le refuse tant que le
|
||||||
|
mot de passe n'est pas changé. Le script fait ce changement et vérifie `GET /sites` = 200 avant
|
||||||
|
de rendre le jeton ; sans cela, tout le scan authentifié ne testerait que des `403`.
|
||||||
|
`POST /auth/password` rend déjà un nouveau jeton valide (l'`iat` tronqué documenté dans
|
||||||
|
`app/api/deps.py` ne le rejette pas comme antérieur à la session) : le script s'en sert
|
||||||
|
directement plutôt que de se reconnecter, deux hachages Argon2id (19456 Kio chacun) et deux
|
||||||
|
allers-retours de refresh-token de moins sur le chemin critique de la CI.
|
||||||
|
- **`APP_ACCESS_TOKEN_TTL_SECONDS=3600`** (plafond de la configuration) : le jeton par défaut
|
||||||
|
dure 15 minutes. `scanner.maxScanDurationInMins=15` (ci-dessous) borne le scan actif très en
|
||||||
|
dessous, marge comprise pour les étapes qui l'entourent.
|
||||||
|
- **Le jeton ne transite ni par `${{ }}` dans le script de l'étape, ni par l'argv de `docker
|
||||||
|
run`.** Le premier finirait en clair dans le fichier de commande que GitHub écrit sur le disque
|
||||||
|
du runner pour toute la durée de l'étape ; le second serait visible par `ps aux` et par
|
||||||
|
`docker inspect zap` tant que le conteneur existe. Il est écrit dans un fichier de
|
||||||
|
configuration ZAP séparé (`-configfile`), monté en lecture seule hors de `/zap/wrk` pour ne
|
||||||
|
jamais atterrir dans l'artefact publié. ZAP journalise malgré tout la valeur de chaque
|
||||||
|
`-config`/`-configfile` chargé à un niveau visible sans `-d` : les copies de `zap.log` et
|
||||||
|
`zap-stdout.log` publiées en artefact sont donc caviardées avant publication.
|
||||||
|
|
||||||
|
**Deux pièges d'autorisation** sur ce fichier de configuration (`zap-auth.conf`), tous les deux
|
||||||
|
propres au montage bind Docker : le conteneur y lit avec son propre uid (1000), distinct de celui
|
||||||
|
du runner qui l'a écrit, sans remappage automatique.
|
||||||
|
|
||||||
|
- Un `chmod 600` seul rend le fichier illisible pour le conteneur (« File not readable :
|
||||||
|
/zap/auth.conf »). ZAP échoue dès le lancement, mais `zap-api-scan.py` attend les `-T` minutes
|
||||||
|
complètes avant d'abandonner : dix minutes qui ressemblent à un scan actif, pour un daemon mort
|
||||||
|
depuis le début. Corrigé par `sudo chown 1000:1000` du fichier avant de le passer à `644`.
|
||||||
|
- Ce `chown` déplace la propriété du fichier hors de l'utilisateur du runner : un `chmod` qui
|
||||||
|
suit sans `sudo` échoue alors (« Operation not permitted »), et le `-e` implicite des étapes
|
||||||
|
bash de GitHub Actions arrête toute l'étape avant même `docker run` — un scan « réussi » en une
|
||||||
|
fraction de seconde, sans le moindre journal ni rapport produit. Les deux commandes doivent
|
||||||
|
passer par `sudo`.
|
||||||
|
|
||||||
|
Les routes d'authentification qui changent l'état du compte (`login`, `password`, `logout-all`,
|
||||||
|
`forgot-password`, `reset-password`) sont exclues du scan actif : elles y déclencheraient la
|
||||||
|
limitation de débit et fermeraient les sessions sans rien apprendre de plus.
|
||||||
|
|
||||||
|
**Un scan vert n'est pas un scan qui a testé quelque chose.** Deux garde-fous, eux, **bloquent** :
|
||||||
|
|
||||||
|
- **Moins de 80% des opérations du contrat importées.** Constaté une première fois : 2 URL sur 26
|
||||||
|
opérations importées, ZAP n'avait envoyé que des requêtes vouées au 404 (l'analyseur de ZAP
|
||||||
|
refusait alors le nom accentué d'un des deux schémas de sécurité du contrat, corrigé depuis en
|
||||||
|
ASCII côté backend). Le seuil est dérivé du contrat (`zap-out/openapi.json`, présent à cette
|
||||||
|
étape) plutôt que d'un nombre fixe : un contrat qui grossit ne doit pas rendre la garde plus
|
||||||
|
permissive qu'elle ne l'était.
|
||||||
|
- **Aucune réponse 2xx.** Constaté une deuxième fois, cause différente : la clé de configuration
|
||||||
|
du nom d'en-tête pour la règle Replacer est `matchstr`, pas `matchstring` (celui-ci n'existe que
|
||||||
|
pour le job d'automatisation ZAP, pas pour `-config`) ; ZAP acceptait la mauvaise clé sans
|
||||||
|
erreur et laissait le nom d'en-tête vide, qu'uvicorn refusait par un `400` sur **toute** requête,
|
||||||
|
y compris les routes publiques. Piège de conception rencontré en corrigeant cette garde : borner
|
||||||
|
le *pourcentage* de 4xx ne marche pas, un scan actif fuzze délibérément un grand nombre
|
||||||
|
d'entrées invalides, si bien qu'un scan sain contre l'API seedée reste à 98% de 4xx avec
|
||||||
|
seulement 1% de 2xx. C'est la forme normale d'un scan actif. Le signal qui distingue vraiment un
|
||||||
|
scan cassé (2xx nul, absent du rapport dans les deux incidents) d'un scan sain (2xx non nul,
|
||||||
|
aussi faible soit-il) est l'absence de succès, pas la part d'échecs. Les deux gardes lisent
|
||||||
|
`zap-out/zap-report.json` (champs structurés `insights[]`), pas le texte libre du rapport
|
||||||
|
Markdown.
|
||||||
|
|
||||||
|
Le journal interne de ZAP (`zap.log`) et sa sortie complète (`zap-stdout.log`) sont publiés dans
|
||||||
|
l'artefact `zap-report` (dossier `zap-logs/`, propriété du runner : `zap-out/` bascule sous l'uid
|
||||||
|
1000 du conteneur ZAP dès que le contrat y est copié, le runner n'y écrit plus ensuite) pour
|
||||||
|
diagnostiquer un futur import raté.
|
||||||
|
|
||||||
|
**Non bloquant pour l'instant** (`continue-on-error`, sur la seule étape du scan) pour ce qui est
|
||||||
|
des alertes elles-mêmes. Le volume d'un premier passage trié est inconnu ; le rapport
|
||||||
|
HTML/JSON/Markdown est publié en artefact `zap-report`, et sa synthèse (jusqu'aux tableaux
|
||||||
|
d'alertes, sans le détail par alerte) dans le résumé du job. Fixer un seuil viendra une fois les
|
||||||
|
alertes triées.
|
||||||
|
|
||||||
|
**Limite à ne pas oublier :** le scan tape la configuration par défaut du backend (`APP_ENV=local`,
|
||||||
|
pas de TLS, pas de reverse proxy). Il remontera des alertes qui n'existent pas derrière le proxy
|
||||||
|
(HSTS absent...) et ne dit **rien** des en-têtes ni du TLS que le proxy pose en production. Un
|
||||||
|
second passage sur la stack complète reste à faire.
|
||||||
|
|
||||||
|
## Tests de bout en bout et de charge
|
||||||
|
|
||||||
|
Le workflow `e2e.yml` démarre la stack telle qu'elle est déployée, derrière le proxy TLS, sur
|
||||||
|
`https://localhost` ([ADR 0015](../adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md)) :
|
||||||
|
|
||||||
|
1. Il construit et démarre `db`, `mailpit`, `backend`, `frontend` et `proxy` avec
|
||||||
|
`docker-compose.prod.yml`, sans Airflow. C'est le seul job qui construit les images backend et
|
||||||
|
frontend avant un déploiement.
|
||||||
|
2. Il migre la base, pose le rôle `supervision`, sème `db/seeds/demo.sql` et crée les comptes
|
||||||
|
(`scripts/comptes-test.sh`).
|
||||||
|
3. Il joue les 18 parcours Playwright de `tests/e2e`, sur un seul worker et avec une session par
|
||||||
|
fichier. Le rapport HTML et les traces du premier réessai sont versés en artefact.
|
||||||
|
4. Il lance le tir k6 `smoke`, directement sur `backend:8000`, puis `limitation-debit` par le
|
||||||
|
proxy. Les synthèses s'affichent dans le résumé du job, et les rapports HTML sont versés en
|
||||||
|
artefact.
|
||||||
|
|
||||||
|
La charge nominale (`make load-test`) et le stress (`make load-stress`) ne tournent pas en CI :
|
||||||
|
voir `tests/load/README.md`.
|
||||||
|
|
||||||
## Ce qui manque, et pourquoi
|
## 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 (OWASP ZAP) | #41 | Aucune vérification sur l'application en fonctionnement, seulement sur le code et les dépendances |
|
| 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
|
||||||
|
|
||||||
@@ -299,12 +437,20 @@ Les tests d'intégration demandent une base **migrée**, et `db/init` ne crée `
|
|||||||
vide :
|
vide :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make db-up migrate-test # la base de test reçoit les sept révisions Alembic
|
make db-up migrate-test # la base de test reçoit les six révisions Alembic
|
||||||
make test-integration # backend, marqueur `integration`
|
make test-integration # backend, marqueur `integration`
|
||||||
make ml-test-integration # pipeline ML, marqueur `integration`
|
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,117 @@
|
|||||||
|
# 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"]
|
||||||
|
|
||||||
|
garage["garage<br/>:3903/metrics"]
|
||||||
|
|
||||||
|
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
|
||||||
|
prom -->|"Bearer GARAGE_METRICS_TOKEN"| garage
|
||||||
|
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 » |
|
||||||
|
| Garage (`/metrics` du port admin, jeton `GARAGE_METRICS_TOKEN`) | `api_s3_request_counter`, `block_bytes_written`, `garage_local_disk_avail`, `cluster_healthy` | Prometheus seulement, aucun tableau dédié ; `CibleInjoignable` couvre son indisponibilité ([ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)) |
|
||||||
|
|
||||||
|
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).
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ lecture seule ; plusieurs lignes resteront à compléter une fois les endpoints
|
|||||||
| Caviardage des jetons, empreintes, mots de passe et cookies dans les journaux | `app/core/logging.py` | A09, A02 |
|
| Caviardage des jetons, empreintes, mots de passe et cookies dans les journaux | `app/core/logging.py` | A09, A02 |
|
||||||
| Cinq gardes de configuration qui refusent le démarrage plutôt que de dégrader silencieusement | `app/core/config.py` | A05 |
|
| Cinq gardes de configuration qui refusent le démarrage plutôt que de dégrader silencieusement | `app/core/config.py` | A05 |
|
||||||
| Documentation interactive fermée hors développement, `/metrics` derrière un jeton, sonde qui ne publie plus de version | `app/main.py`, `app/api/security.py` | A05 |
|
| Documentation interactive fermée hors développement, `/metrics` derrière un jeton, sonde qui ne publie plus de version | `app/main.py`, `app/api/security.py` | A05 |
|
||||||
| En-têtes `nosniff`, `DENY`, `no-referrer`, et `no-store` sur les routes d'authentification | `app/api/middleware.py` | A05 |
|
| Scan dynamique OWASP ZAP de l'API authentifiée (compte `lecteur` jetable), non bloquant, configuration par défaut du backend uniquement (ni TLS ni en-têtes du reverse proxy) | `.github/workflows/dast.yml`, `scripts/dast-token.sh` | A05, API8 Security Misconfiguration |
|
||||||
|
| En-têtes `nosniff`, `DENY`, `no-referrer`, `Cross-Origin-Resource-Policy: same-origin`, et `no-store` sur les routes d'authentification | `app/api/middleware.py` | A05 |
|
||||||
| Refus de rétrograder ou désactiver le dernier administrateur actif | `app/services/user.py` | A04 Insecure Design |
|
| Refus de rétrograder ou désactiver le dernier administrateur actif | `app/services/user.py` | A04 Insecure Design |
|
||||||
| Amorçage du premier administrateur hors dépôt, mot de passe jamais dans `argv` ni dans Git | `app/cli.py` | A02, A05 |
|
| Amorçage du premier administrateur hors dépôt, mot de passe jamais dans `argv` ni dans Git | `app/cli.py` | A02, A05 |
|
||||||
| Réponse de l'API Mock bornée avant écriture : timeout, plafond de sites et de mesures, bornes physiques par grandeur, recopie des seuls champs attendus | `app/etl/mock_api_import.py` | API10 Unsafe Consumption of APIs |
|
| Réponse de l'API Mock bornée avant écriture : timeout, plafond de sites et de mesures, bornes physiques par grandeur, recopie des seuls champs attendus | `app/etl/mock_api_import.py` | API10 Unsafe Consumption of APIs |
|
||||||
|
|||||||
+18
-9
@@ -663,16 +663,25 @@ mock_api_import.py
|
|||||||
|
|
||||||
La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP.
|
La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP.
|
||||||
|
|
||||||
Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`) et orchestre le pipeline
|
Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`) et orchestre cinq DAGs :
|
||||||
ML (`ml_train`/`ml_score`, issue #115), la détection d'alertes et la génération des
|
le pipeline ML (`ml_train` et `ml_score`, issue #115), la détection d'alertes et la génération
|
||||||
recommandations (`alertes`, issue #116), ainsi que l'import historique
|
des recommandations (`alertes`, issue #116), l'import historique (`historical_import`,
|
||||||
(`historical_import`, issue #119).
|
issue #119) et l'import périodique de l'API Mock (`mock_api_import`, issue #15).
|
||||||
|
|
||||||
Le DAG `historical_import` est déclenché manuellement. Il exécute
|
Le DAG `mock_api_import` s'exécute chaque heure, à la minute `:45`, sur une fenêtre qui part de
|
||||||
`app.etl.historical_import` avec les fichiers montés en lecture seule depuis `data/raw` vers
|
l'heure pile précédant son déclenchement jusqu'à l'instant du déclenchement lui-même (pas
|
||||||
`/opt/data/raw`. L'orchestration de l'import API Mock et la réconciliation globale des deux
|
l'intervalle Airflow `data_interval_start`/`end` tel quel). L'API Mock génère autant de points que
|
||||||
sources restent couvertes par l'issue #15.
|
la limite demandée, répartis sur la fenêtre et le premier à son début :
|
||||||
|
`app.etl.mock_api_import.limit_for_window()` dérive donc `limit` de la fenêtre reçue (une seule
|
||||||
|
lecture ici, ancrée sur l'heure pile) plutôt que de dépendre d'une valeur fixée à la main côté
|
||||||
|
DAG, et refuse une fenêtre qui ne démarre pas pile sur l'heure. Une fenêtre calée sur l'intervalle
|
||||||
|
Airflow tel quel (`[:45, :45)`) placerait cette lecture à :45, hors de la grille horaire du reste
|
||||||
|
du schéma (vérifié empiriquement contre l'API Mock) ; partir de l'heure pile évite ce décalage.
|
||||||
|
Les deux pipelines normalisent leurs données vers les tables communes `site` et `reading`, tout en
|
||||||
|
conservant leur source (`csv` ou `api_history`). La réconciliation entre les deux sources
|
||||||
|
(issue #15) est close : voir `docs/architecture/40-data.md`.
|
||||||
|
|
||||||
Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py`, `alertes.py`, `historical_import.py` et `derive.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui).
|
Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py`, `alertes.py`, `historical_import.py` et
|
||||||
|
`mock_api_import.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui).
|
||||||
|
|
||||||
Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning.
|
Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning.
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
"""DAG de surveillance de la dérive du modèle de prévision (issue #45).
|
"""DAG de surveillance de la dérive du modèle de prévision (issue #45).
|
||||||
|
|
||||||
Quotidien, pas horaire : la fenêtre mesurée couvre 168 h, la recalculer chaque heure écrirait
|
Quotidien, pas horaire : la fenêtre mesurée couvre 168 h, la recalculer chaque heure écrirait
|
||||||
vingt-quatre lignes par jour pour un verdict qui ne bouge pas à cette cadence. Planifié après
|
vingt-quatre lignes presque identiques par jour et se heurterait à l'index d'idempotence
|
||||||
les scorings de la nuit, et décalé de `ml_score` (à l'heure pile) comme de `alertes` (à la
|
`uq_drift_report_window`. Planifié après les scorings de la nuit, et décalé de `ml_score` (à
|
||||||
quinzième minute).
|
l'heure pile) comme de `alertes` (à la quinzième minute).
|
||||||
|
|
||||||
Sans `--now` : le service tronque son instant de référence à l'heure, donc deux exécutions de
|
|
||||||
la même heure portent la même clé `uq_drift_report_window` et la seconde n'écrit rien.
|
|
||||||
|
|
||||||
Tâche distincte du DAG `alertes` plutôt qu'ajoutée à lui : un échec de dérive y ferait croire
|
Tâche distincte du DAG `alertes` plutôt qu'ajoutée à lui : un échec de dérive y ferait croire
|
||||||
que la détection d'alertes a échoué, et ce DAG porte un budget temporel déjà argumenté face à
|
que la détection d'alertes a échoué, et ce DAG porte un budget temporel déjà argumenté face à
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""DAG d'import périodique des données de l'API Mock EnerVision (issue #15).
|
||||||
|
|
||||||
|
Orchestre le pipeline existant `app.etl.mock_api_import` sans dupliquer sa logique ETL.
|
||||||
|
Chaque exécution importe la mesure de l'heure pile qui précède son déclenchement.
|
||||||
|
|
||||||
|
Le pipeline backend reste responsable de la validation, de la normalisation, du suivi de la
|
||||||
|
qualité, de l'idempotence et du chargement dans PostgreSQL/TimescaleDB.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from airflow.providers.standard.operators.bash import BashOperator
|
||||||
|
from airflow.sdk import DAG
|
||||||
|
from airflow.timetables.trigger import CronTriggerTimetable
|
||||||
|
|
||||||
|
# Le backend possède son propre environnement uv dans l'image Airflow (ADR 0008).
|
||||||
|
COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m"
|
||||||
|
|
||||||
|
# Deux reprises donnent trois tentatives au total. Même dans le pire cas, l'exécution reste
|
||||||
|
# inférieure au pas horaire du DAG.
|
||||||
|
NOMBRE_REPRISES = 2
|
||||||
|
DELAI_ENTRE_REPRISES = timedelta(minutes=2)
|
||||||
|
PLAFOND_PAR_TENTATIVE = timedelta(minutes=10)
|
||||||
|
|
||||||
|
# L'intervalle est déclaré explicitement pour ne pas dépendre de la valeur du paramètre Airflow
|
||||||
|
# `create_cron_data_intervals`. Le déclenchement à :45 laisse quinze minutes avant `ml_score`,
|
||||||
|
# exécuté à l'heure pile, puis avant `alertes`, exécuté à :15. La fenêtre demandée à l'API Mock
|
||||||
|
# (voir `bash_command` ci-dessous) ne suit pas cet intervalle Airflow tel quel : elle part de
|
||||||
|
# l'heure pile qui précède le déclenchement, pas de `data_interval_start`, pour que l'unique
|
||||||
|
# lecture demandée (`app.etl.mock_api_import.limit_for_window()`) atterrisse à :00 et non à :45
|
||||||
|
# (vérifié empiriquement sur l'API Mock), au pas horaire du reste du schéma, cf. 40-data.md.
|
||||||
|
PLANIFICATION = CronTriggerTimetable(
|
||||||
|
"45 * * * *",
|
||||||
|
timezone="UTC",
|
||||||
|
interval=timedelta(hours=1),
|
||||||
|
)
|
||||||
|
|
||||||
|
with DAG(
|
||||||
|
dag_id="mock_api_import",
|
||||||
|
description="Importe chaque heure les données de l'API Mock dans site et reading.",
|
||||||
|
schedule=PLANIFICATION,
|
||||||
|
start_date=datetime(2026, 1, 1),
|
||||||
|
catchup=False,
|
||||||
|
# Deux exécutions simultanées pourraient demander et traiter le même intervalle.
|
||||||
|
max_active_runs=1,
|
||||||
|
tags=["etl", "mock-api"],
|
||||||
|
) as dag:
|
||||||
|
BashOperator(
|
||||||
|
task_id="import_mock_api",
|
||||||
|
bash_command=(
|
||||||
|
f"{COMMANDE_BACKEND} app.etl.mock_api_import "
|
||||||
|
# `--start-time` part de l'heure pile qui précède le déclenchement, pas de
|
||||||
|
# `data_interval_start` : sur `[:45, :45)`, l'API aurait placé son unique lecture
|
||||||
|
# à :45, hors de la grille horaire du reste du schéma (vérifié empiriquement).
|
||||||
|
"--start-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:00:00') }}\" "
|
||||||
|
"--end-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:%M:%S') }}\""
|
||||||
|
# Pas de --limit : app.etl.mock_api_import.limit_for_window() le dérive de la
|
||||||
|
# fenêtre (ici plus courte qu'une heure, donc une seule lecture, ancrée sur
|
||||||
|
# --start-time) et refuse une fenêtre qui ne démarre pas pile sur l'heure. Porter
|
||||||
|
# la règle dans le code, pas dans ce DAG, évite qu'un appel manuel oublie de la
|
||||||
|
# respecter.
|
||||||
|
),
|
||||||
|
retries=NOMBRE_REPRISES,
|
||||||
|
retry_delay=DELAI_ENTRE_REPRISES,
|
||||||
|
execution_timeout=PLAFOND_PAR_TENTATIVE,
|
||||||
|
)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""DAG de rétention de l'hypertable `reading` : export vers Garage puis suppression (issue #36).
|
||||||
|
|
||||||
|
La nuit, parce que `drop_chunks` pose un verrou exclusif sur `reading`, `site` et `dataset`
|
||||||
|
jusqu'au COMMIT : un chunk est supprimé dans une transaction courte, mais hors des heures où
|
||||||
|
l'API et les DAGs horaires écrivent. À :20 pour se glisser entre `ml_score` (à l'heure pile),
|
||||||
|
`alertes` (à :15) et `mock_api_import` (à :45), bien avant `derive` (05:30). Une reprise est sans
|
||||||
|
risque : le module est idempotent, un objet déjà exporté avec le même sha256 n'est pas réécrit et
|
||||||
|
un chunk déjà supprimé n'est plus éligible. La borne vient de `APP_READING_RETENTION_DAYS`
|
||||||
|
(1095 jours), posée par le compose sur `airflow-scheduler` avec les réglages `APP_S3_*`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from airflow.providers.standard.operators.bash import BashOperator
|
||||||
|
from airflow.sdk import DAG
|
||||||
|
|
||||||
|
# Le backend a son propre environnement uv dans l'image (ADR 0008). `--no-sync` et
|
||||||
|
# `env -u VIRTUAL_ENV` : cf. `ml_train.py`, même raisonnement.
|
||||||
|
COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m"
|
||||||
|
|
||||||
|
TENTATIVES = 1
|
||||||
|
DELAI_ENTRE_TENTATIVES = timedelta(minutes=5)
|
||||||
|
PLAFOND = timedelta(minutes=20)
|
||||||
|
|
||||||
|
with DAG(
|
||||||
|
dag_id="retention",
|
||||||
|
description=(
|
||||||
|
"Exporte vers Garage puis supprime les chunks de reading plus vieux que la borne de "
|
||||||
|
"rétention (app.etl.reading_retention)."
|
||||||
|
),
|
||||||
|
schedule="20 3 * * *",
|
||||||
|
start_date=datetime(2026, 1, 1),
|
||||||
|
catchup=False,
|
||||||
|
max_active_runs=1,
|
||||||
|
tags=["etl", "retention"],
|
||||||
|
) as dag:
|
||||||
|
BashOperator(
|
||||||
|
task_id="archiver",
|
||||||
|
bash_command=f"{COMMANDE_BACKEND} app.etl.reading_retention",
|
||||||
|
retries=TENTATIVES,
|
||||||
|
retry_delay=DELAI_ENTRE_TENTATIVES,
|
||||||
|
execution_timeout=PLAFOND,
|
||||||
|
)
|
||||||
@@ -1,23 +1,34 @@
|
|||||||
"""Tests d'integrite des DAGs : s'importent sans erreur, structure attendue. Pas d'execution
|
"""Tests d'integrite des DAGs : s'importent sans erreur, structure attendue. Pas d'execution
|
||||||
reelle des taches (ca reclamerait le conteneur avec `uv`/`enervision_ml`), juste la definition."""
|
reelle des taches (ca reclamerait le conteneur avec `uv`/`enervision_ml`), juste la definition."""
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from airflow.dag_processing.dagbag import DagBag
|
from airflow.dag_processing.dagbag import DagBag
|
||||||
from airflow.sdk import BaseOperator
|
from airflow.sdk import BaseOperator
|
||||||
|
from airflow.timetables.trigger import CronTriggerTimetable
|
||||||
|
|
||||||
DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags"
|
DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags"
|
||||||
|
|
||||||
DAG_IDS = ["ml_train", "ml_score", "alertes", "historical_import", "derive"]
|
DAG_IDS = [
|
||||||
|
"ml_train",
|
||||||
|
"ml_score",
|
||||||
|
"alertes",
|
||||||
|
"historical_import",
|
||||||
|
"mock_api_import",
|
||||||
|
"derive",
|
||||||
|
"retention",
|
||||||
|
]
|
||||||
TACHES = [
|
TACHES = [
|
||||||
("ml_train", "train"),
|
("ml_train", "train"),
|
||||||
("ml_score", "score"),
|
("ml_score", "score"),
|
||||||
("alertes", "detection"),
|
("alertes", "detection"),
|
||||||
("alertes", "recommandations"),
|
("alertes", "recommandations"),
|
||||||
("historical_import", "import_historical"),
|
("historical_import", "import_historical"),
|
||||||
|
("mock_api_import", "import_mock_api"),
|
||||||
("derive", "derive"),
|
("derive", "derive"),
|
||||||
|
("retention", "archiver"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -53,6 +64,19 @@ def test_historical_import_has_no_schedule(dagbag: DagBag) -> None:
|
|||||||
assert dagbag.dags["historical_import"].schedule is None
|
assert dagbag.dags["historical_import"].schedule is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_mock_api_import_uses_an_explicit_hourly_interval(dagbag: DagBag) -> None:
|
||||||
|
timetable = dagbag.dags["mock_api_import"].timetable
|
||||||
|
|
||||||
|
assert isinstance(timetable, CronTriggerTimetable)
|
||||||
|
assert timetable.serialize()["expression"] == "45 * * * *"
|
||||||
|
|
||||||
|
manual_interval = timetable.infer_manual_data_interval(
|
||||||
|
run_after=datetime.fromisoformat("2026-09-22T12:30:00+00:00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert manual_interval.end - manual_interval.start == timedelta(hours=1)
|
||||||
|
|
||||||
|
|
||||||
def test_ml_train_task_calls_the_training_module(dagbag: DagBag) -> None:
|
def test_ml_train_task_calls_the_training_module(dagbag: DagBag) -> None:
|
||||||
tache = dagbag.dags["ml_train"].get_task("train")
|
tache = dagbag.dags["ml_train"].get_task("train")
|
||||||
assert "enervision_ml.train" in tache.bash_command
|
assert "enervision_ml.train" in tache.bash_command
|
||||||
@@ -85,6 +109,21 @@ def test_historical_import_uses_the_expected_source_files(dagbag: DagBag) -> Non
|
|||||||
assert "--metadata /opt/data/raw/dataset_metadata.json" in commande
|
assert "--metadata /opt/data/raw/dataset_metadata.json" in commande
|
||||||
|
|
||||||
|
|
||||||
|
def test_mock_api_import_calls_the_existing_backend_module(dagbag: DagBag) -> None:
|
||||||
|
commande = dagbag.dags["mock_api_import"].get_task("import_mock_api").bash_command
|
||||||
|
|
||||||
|
assert "app.etl.mock_api_import" in commande
|
||||||
|
|
||||||
|
|
||||||
|
def test_mock_api_import_asks_for_the_on_the_hour_reading(dagbag: DagBag) -> None:
|
||||||
|
commande = dagbag.dags["mock_api_import"].get_task("import_mock_api").bash_command
|
||||||
|
|
||||||
|
assert "--start-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:00:00') }}\"" in commande
|
||||||
|
assert "--end-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:%M:%S') }}\"" in commande
|
||||||
|
# Pas de --limit : app.etl.mock_api_import.limit_for_window() le dérive de la fenêtre.
|
||||||
|
assert "--limit" not in commande
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("task_id", ["detection", "recommandations"])
|
@pytest.mark.parametrize("task_id", ["detection", "recommandations"])
|
||||||
def test_alertes_tasks_run_in_the_backend_environment(dagbag: DagBag, task_id: str) -> None:
|
def test_alertes_tasks_run_in_the_backend_environment(dagbag: DagBag, task_id: str) -> None:
|
||||||
# Le backend a son propre venv dans l'image, distinct de celui de ml/ (ADR 0008).
|
# Le backend a son propre venv dans l'image, distinct de celui de ml/ (ADR 0008).
|
||||||
@@ -96,6 +135,12 @@ def test_historical_import_runs_in_the_backend_environment(dagbag: DagBag) -> No
|
|||||||
assert "/opt/backend" in commande
|
assert "/opt/backend" in commande
|
||||||
|
|
||||||
|
|
||||||
|
def test_mock_api_import_runs_in_the_backend_environment(dagbag: DagBag) -> None:
|
||||||
|
commande = dagbag.dags["mock_api_import"].get_task("import_mock_api").bash_command
|
||||||
|
|
||||||
|
assert "/opt/backend" in commande
|
||||||
|
|
||||||
|
|
||||||
def test_alertes_generates_recommendations_after_detecting(dagbag: DagBag) -> None:
|
def test_alertes_generates_recommendations_after_detecting(dagbag: DagBag) -> None:
|
||||||
# `recommendation.alert_id` est une cle etrangere `NOT NULL` : la generation n'a rien a lire
|
# `recommendation.alert_id` est une cle etrangere `NOT NULL` : la generation n'a rien a lire
|
||||||
# tant que la detection n'a pas ecrit.
|
# tant que la detection n'a pas ecrit.
|
||||||
@@ -137,6 +182,14 @@ def duree_au_pire(tache: BaseOperator) -> timedelta:
|
|||||||
return (tache.retries + 1) * tache.execution_timeout + tache.retries * tache.retry_delay
|
return (tache.retries + 1) * tache.execution_timeout + tache.retries * tache.retry_delay
|
||||||
|
|
||||||
|
|
||||||
|
def test_mock_api_import_worst_case_stays_below_its_hourly_step(
|
||||||
|
dagbag: DagBag,
|
||||||
|
) -> None:
|
||||||
|
tache = dagbag.dags["mock_api_import"].get_task("import_mock_api")
|
||||||
|
|
||||||
|
assert duree_au_pire(tache) < timedelta(hours=1)
|
||||||
|
|
||||||
|
|
||||||
def test_alertes_worst_case_stays_below_its_hourly_step(dagbag: DagBag) -> None:
|
def test_alertes_worst_case_stays_below_its_hourly_step(dagbag: DagBag) -> None:
|
||||||
# Les deux taches s'enchainent : c'est leur somme, reprises comprises, qui doit tenir dans le
|
# Les deux taches s'enchainent : c'est leur somme, reprises comprises, qui doit tenir dans le
|
||||||
# pas horaire, sinon `max_active_runs=1` fait attendre l'execution suivante.
|
# pas horaire, sinon `max_active_runs=1` fait attendre l'execution suivante.
|
||||||
@@ -160,6 +213,10 @@ def test_historical_import_retries_after_a_transient_failure(dagbag: DagBag) ->
|
|||||||
assert dagbag.dags["historical_import"].get_task("import_historical").retries >= 1
|
assert dagbag.dags["historical_import"].get_task("import_historical").retries >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_mock_api_import_retries_after_a_transient_failure(dagbag: DagBag) -> None:
|
||||||
|
assert dagbag.dags["mock_api_import"].get_task("import_mock_api").retries >= 1
|
||||||
|
|
||||||
|
|
||||||
def test_derive_runs_once_a_day(dagbag: DagBag) -> None:
|
def test_derive_runs_once_a_day(dagbag: DagBag) -> None:
|
||||||
assert dagbag.dags["derive"].timetable.expression == "30 5 * * *"
|
assert dagbag.dags["derive"].timetable.expression == "30 5 * * *"
|
||||||
|
|
||||||
@@ -173,6 +230,24 @@ def test_derive_never_retries_a_detected_drift(dagbag: DagBag) -> None:
|
|||||||
assert dagbag.dags["derive"].get_task("derive").retries == 0
|
assert dagbag.dags["derive"].get_task("derive").retries == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_retention_runs_nightly(dagbag: DagBag) -> None:
|
||||||
|
# Entre `ml_score` (:00), `alertes` (:15) et `mock_api_import` (:45) : drop_chunks verrouille
|
||||||
|
# reading, site et dataset jusqu'au COMMIT.
|
||||||
|
assert dagbag.dags["retention"].timetable.expression == "20 3 * * *"
|
||||||
|
|
||||||
|
|
||||||
|
def test_retention_calls_the_backend_retention_module(dagbag: DagBag) -> None:
|
||||||
|
commande = dagbag.dags["retention"].get_task("archiver").bash_command
|
||||||
|
|
||||||
|
assert "app.etl.reading_retention" in commande
|
||||||
|
|
||||||
|
|
||||||
|
def test_retention_runs_in_the_backend_environment(dagbag: DagBag) -> None:
|
||||||
|
commande = dagbag.dags["retention"].get_task("archiver").bash_command
|
||||||
|
|
||||||
|
assert "/opt/backend" in commande
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("dag_id", "task_id"), TACHES)
|
@pytest.mark.parametrize(("dag_id", "task_id"), TACHES)
|
||||||
def test_tasks_never_resync_the_baked_environment(
|
def test_tasks_never_resync_the_baked_environment(
|
||||||
dagbag: DagBag, dag_id: str, task_id: str
|
dagbag: DagBag, dag_id: str, task_id: str
|
||||||
|
|||||||
+18
-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,21 @@ 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`.
|
||||||
|
|
||||||
|
Chiffrement au repos ([ADR 0020](../docs/adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md)) :
|
||||||
|
`coffre_taille = "30G"` fait poser par `scripts/coffre-luks.sh` un coffre LUKS2 sous `/var/lib/docker/volumes` ;
|
||||||
|
vide par defaut, rien n'est pose. La premiere pose arrete Docker le temps de copier les volumes, et la cle
|
||||||
|
`/root/enervision-coffre.key` est a sauvegarder hors de la VM.
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Garage
|
||||||
|
|
||||||
|
Stockage objet S3 de la stack, un conteneur par projet Compose (ADR 0019). Il ne sert qu'au DAG
|
||||||
|
`retention`, qui y archive les chunks de `reading` avant de les supprimer.
|
||||||
|
|
||||||
|
- `garage.toml` : configuration versionnée, sans secret, montée en lecture seule. Les secrets
|
||||||
|
arrivent par l'environnement : `GARAGE_RPC_SECRET`, `GARAGE_ADMIN_TOKEN`, `GARAGE_METRICS_TOKEN`.
|
||||||
|
- Démarrage `--single-node --default-bucket` : le premier démarrage crée le layout, la clé
|
||||||
|
`GARAGE_ACCESS_KEY` et le bucket `GARAGE_BUCKET`. Rejouable tant que les volumes `garage_meta`
|
||||||
|
et `garage_data` sont conservés. Changer `GARAGE_SECRET_KEY` ensuite fait refuser le démarrage.
|
||||||
|
- Ports, sur `127.0.0.1` seulement : `GARAGE_S3_PORT` (3900) pour l'API S3, `GARAGE_ADMIN_PORT`
|
||||||
|
(3903) pour `/health` (sans jeton) et `/metrics` (jeton `GARAGE_METRICS_TOKEN`, scruté par
|
||||||
|
Prometheus). Le RPC 3901 n'est pas publié.
|
||||||
|
- Nœud unique, `replication_factor = 1`, moteur sqlite : aucune redondance, l'instantané des
|
||||||
|
métadonnées toutes les six heures est la seule protection ; chiffrement au repos : ADR 0020.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec garage /garage status
|
||||||
|
docker compose exec garage /garage bucket info enervision-archives
|
||||||
|
docker compose exec garage /garage key info --show-secret "$GARAGE_ACCESS_KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests de fumée : `tests/garage/test_smoke.py`, joués par la CI contre le vrai conteneur (job
|
||||||
|
« Validation des fichiers Compose et de la supervision »). En local, stack démarrée et `.env` chargé :
|
||||||
|
`uvx --with boto3 pytest tests/garage`.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Contrainte : aucun secret ici, ce fichier est versionné et monté en lecture seule. Garage lit
|
||||||
|
# GARAGE_RPC_SECRET, GARAGE_ADMIN_TOKEN et GARAGE_METRICS_TOKEN dans son environnement, posés par
|
||||||
|
# docker-compose.yml depuis le .env (ADR 0019). `--single-node` exige replication_factor = 1.
|
||||||
|
|
||||||
|
metadata_dir = "/var/lib/garage/meta"
|
||||||
|
data_dir = "/var/lib/garage/data"
|
||||||
|
db_engine = "sqlite"
|
||||||
|
metadata_auto_snapshot_interval = "6h"
|
||||||
|
|
||||||
|
replication_factor = 1
|
||||||
|
|
||||||
|
rpc_bind_addr = "[::]:3901"
|
||||||
|
rpc_public_addr = "127.0.0.1:3901"
|
||||||
|
|
||||||
|
[s3_api]
|
||||||
|
s3_region = "garage"
|
||||||
|
api_bind_addr = "[::]:3900"
|
||||||
|
root_domain = ".s3.garage.localhost"
|
||||||
|
|
||||||
|
[admin]
|
||||||
|
api_bind_addr = "[::]:3903"
|
||||||
|
metrics_require_token = true
|
||||||
+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,12 +6,15 @@
|
|||||||
# 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.
|
# Piege : seule exception a « un apply n'interrompt pas la stack » : la premiere pose du coffre
|
||||||
|
# (`coffre_taille` non vide, ADR 0020) arrete Docker le temps de copier les volumes.
|
||||||
|
# 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 "
|
||||||
en_tant_que = "${var.ssh_user == "root" ? "" : "sudo "}runuser -u ${var.proprietaire} --"
|
en_tant_que = "${var.ssh_user == "root" ? "" : "sudo "}runuser -u ${var.proprietaire} --"
|
||||||
provisionneur = "${path.root}/../../../../scripts/provision-host.sh"
|
provisionneur = "${path.root}/../../../../scripts/provision-host.sh"
|
||||||
|
coffre = "${path.root}/../../../../scripts/coffre-luks.sh"
|
||||||
runner_archive = "actions-runner-linux-x64-${var.runner_version}.tar.gz"
|
runner_archive = "actions-runner-linux-x64-${var.runner_version}.tar.gz"
|
||||||
# Substitution shell, evaluee par le sh -c distant : un nom de runner doit etre unique dans
|
# Substitution shell, evaluee par le sh -c distant : un nom de runner doit etre unique dans
|
||||||
# le depot, le nom d'hote l'est deja et le reste si cette racine sert a une autre machine.
|
# le depot, le nom d'hote l'est deja et le reste si cette racine sert a une autre machine.
|
||||||
@@ -50,16 +53,53 @@ resource "null_resource" "docker_engine" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Optionnel : un coffre LUKS2 dans un fichier image, bind-monte sur /var/lib/docker/volumes
|
||||||
|
# (ADR 0020). Rejouable : deja en place, le script affiche l'etat et sort sans rien toucher.
|
||||||
|
resource "null_resource" "coffre" {
|
||||||
|
count = var.coffre_taille == "" ? 0 : 1
|
||||||
|
depends_on = [null_resource.docker_engine]
|
||||||
|
|
||||||
|
triggers = {
|
||||||
|
script = filesha256(local.coffre)
|
||||||
|
taille = var.coffre_taille
|
||||||
|
}
|
||||||
|
|
||||||
|
connection {
|
||||||
|
type = "ssh"
|
||||||
|
host = var.ssh_host
|
||||||
|
port = var.ssh_port
|
||||||
|
user = var.ssh_user
|
||||||
|
private_key = file(pathexpand(var.ssh_private_key_path))
|
||||||
|
timeout = "5m"
|
||||||
|
}
|
||||||
|
|
||||||
|
provisioner "file" {
|
||||||
|
source = local.coffre
|
||||||
|
destination = "/tmp/coffre-luks.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
provisioner "remote-exec" {
|
||||||
|
inline = [
|
||||||
|
<<-EOT
|
||||||
|
set -eu
|
||||||
|
${local.sudo}env COFFRE_TAILLE='${var.coffre_taille}' COFFRE_MIGRER=1 bash /tmp/coffre-luks.sh
|
||||||
|
rm -f /tmp/coffre-luks.sh
|
||||||
|
EOT
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# `provision-host.sh` verifie lui-meme docker, compose et la sortie HTTPS, puis prepare un clone
|
# `provision-host.sh` verifie lui-meme docker, compose et la sortie HTTPS, puis prepare un clone
|
||||||
# par environnement, son `.env` et son certificat. Il est rejouable : un `.env` existant n'est
|
# par environnement, son `.env` et son certificat. Il est rejouable : un `.env` existant n'est
|
||||||
# jamais reecrit, un certificat present jamais regenere.
|
# jamais reecrit, un certificat present jamais regenere.
|
||||||
resource "null_resource" "environnements" {
|
resource "null_resource" "environnements" {
|
||||||
depends_on = [null_resource.docker_engine]
|
depends_on = [null_resource.docker_engine, null_resource.coffre]
|
||||||
|
|
||||||
triggers = {
|
triggers = {
|
||||||
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 +123,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
|
||||||
|
|||||||
@@ -20,3 +20,7 @@ runner_token = "A_RENSEIGNER"
|
|||||||
# Nom du runner cote GitHub. Vide par defaut : le nom d'hote de la machine. A renseigner
|
# Nom du runner cote GitHub. Vide par defaut : le nom d'hote de la machine. A renseigner
|
||||||
# seulement si deux runners doivent tourner sur la meme machine, leurs noms devant differer.
|
# seulement si deux runners doivent tourner sur la meme machine, leurs noms devant differer.
|
||||||
# runner_nom = "eni-g3-bis"
|
# runner_nom = "eni-g3-bis"
|
||||||
|
|
||||||
|
# Coffre LUKS des volumes Docker (ADR 0020). Vide ou absent : rien n'est pose. La premiere pose
|
||||||
|
# arrete Docker le temps de copier les volumes ; la cle reste sur la machine, a sauvegarder ailleurs.
|
||||||
|
# coffre_taille = "30G"
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -88,3 +94,9 @@ variable "runner_dossier" {
|
|||||||
description = "Dossier d'installation du runner sur la machine."
|
description = "Dossier d'installation du runner sur la machine."
|
||||||
default = "/opt/actions-runner"
|
default = "/opt/actions-runner"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "coffre_taille" {
|
||||||
|
type = string
|
||||||
|
description = "Taille du coffre LUKS qui chiffre /var/lib/docker/volumes (ADR 0020, scripts/coffre-luks.sh), ex. 30G. Vide : le coffre n'est pas pose. La premiere pose arrete Docker le temps de copier les volumes ; la cle reste sur la machine, a sauvegarder ailleurs."
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.venv
|
||||||
|
data
|
||||||
|
mlruns
|
||||||
|
mlflow.db*
|
||||||
|
models
|
||||||
|
.env
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
MLFLOW_DB_PASSWORD=change-me
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
FROM python:3.14-slim
|
||||||
|
RUN pip install --no-cache-dir --only-binary :all: mlflow==3.16.1 psycopg2-binary==2.9.13
|
||||||
|
RUN useradd --create-home --uid 1000 mlflow \
|
||||||
|
&& mkdir /mlartifacts \
|
||||||
|
&& chown mlflow /mlartifacts
|
||||||
|
USER mlflow
|
||||||
|
EXPOSE 5000
|
||||||
@@ -57,6 +57,50 @@ validation. La coupure est **chronologique**, jamais un tirage aleatoire de lign
|
|||||||
aleatoire laisserait des lignes de validation "voir" des lignes d'entrainement via leurs
|
aleatoire laisserait des lignes de validation "voir" des lignes d'entrainement via leurs
|
||||||
lags/moyennes glissantes, une fuite qui masquerait un surapprentissage.
|
lags/moyennes glissantes, une fuite qui masquerait un surapprentissage.
|
||||||
|
|
||||||
|
## Serveur MLflow (conteneur)
|
||||||
|
|
||||||
|
Premiere utilisation : copier `.env.example` en `.env` et y choisir un mot de passe PostgreSQL
|
||||||
|
(lettres et chiffres uniquement). Le fichier `.env` est ignore par git.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Un serveur MLflow (PostgreSQL pour les metadonnees, volume pour les artefacts) se lance avec
|
||||||
|
Docker. Prerequis : Docker Desktop demarre.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make mlflow-up
|
||||||
|
```
|
||||||
|
|
||||||
|
La cible vérifie que `MLFLOW_DB_PASSWORD` (définie dans `ml/.env`) ne contient que des lettres et
|
||||||
|
des chiffres avant de démarrer le serveur : ce mot de passe est interpolé directement dans l'URI
|
||||||
|
PostgreSQL (`postgresql://mlflow:${MLFLOW_DB_PASSWORD}@...`), un caractère spécial la rendrait
|
||||||
|
invalide sans message d'erreur clair.
|
||||||
|
|
||||||
|
Interface : http://localhost:5000. Entrainer vers ce serveur :
|
||||||
|
|
||||||
|
```
|
||||||
|
uv run python -m enervision_ml.train --csv data/all_sites_combined.csv --mlflow-tracking-uri http://localhost:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
Arreter : `docker compose -f docker-compose.mlflow.yml down` (ajouter `-v` pour effacer aussi les
|
||||||
|
runs et les modeles).
|
||||||
|
|
||||||
|
Pour voir les runs dans l'interface (MLflow 3.x) :
|
||||||
|
|
||||||
|
- Passer le selecteur en haut a gauche sur **Model training**. Le mode **GenAI** affiche des
|
||||||
|
traces LLM et reste vide pour un entrainement LightGBM.
|
||||||
|
- **Runs** liste les entrainements, **Models** les artefacts de modele de chaque run (tous nommes
|
||||||
|
`model`), et **Model registry** les versions numerotees de `consumption-forecast-lightgbm`.
|
||||||
|
|
||||||
|
Limites : l'identifiant PostgreSQL du compose est fixe a `mlflow`, le mot de passe vient de la
|
||||||
|
variable obligatoire `MLFLOW_DB_PASSWORD` (aucune valeur par defaut, le compose refuse de
|
||||||
|
demarrer sans elle) -- ce mot de passe est choisi lors de la copie de `.env.example`, il ne
|
||||||
|
convient donc qu'au developpement local tel quel. Un deploiement partage demandera des secrets,
|
||||||
|
de l'authentification et un stockage d'artefacts dedie (S3/MinIO). Le port 5000 doit etre libre : arreter `mlflow ui` avant,
|
||||||
|
ou changer le mapping (`"5001:5000"`) dans le compose.
|
||||||
|
|
||||||
## Scoring
|
## Scoring
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -83,6 +127,13 @@ section 2 :
|
|||||||
fichier : `train.py` reecrit toujours le meme chemin a chaque entrainement, donc le nom seul ne
|
fichier : `train.py` reecrit toujours le meme chemin a chaque entrainement, donc le nom seul ne
|
||||||
distinguerait pas deux versions du modele.
|
distinguerait pas deux versions du modele.
|
||||||
|
|
||||||
|
**Le scoring ne lit pas le Model Registry.** Le fichier charge par `--model` est local
|
||||||
|
(`models/lightgbm-consumption.txt`), independant des versions enregistrees dans le
|
||||||
|
**Model registry** MLflow (`consumption-forecast-lightgbm`). `train.py` enregistre bien une
|
||||||
|
version a chaque entrainement (tracabilite), mais aucun alias (`champion` par exemple) n'est
|
||||||
|
pose, et `enervision_ml.score` ne les lit pas. Le registre sert aujourd'hui a la tracabilite des
|
||||||
|
entrainements, pas au deploiement du modele utilise en scoring.
|
||||||
|
|
||||||
En mode `--csv`, rien n'est ecrit en base : c'est un instantane historique fige (l'heure "future"
|
En mode `--csv`, rien n'est ecrit en base : c'est un instantane historique fige (l'heure "future"
|
||||||
calculee a partir de la fin du CSV n'existe dans aucune base reelle), utile pour valider le
|
calculee a partir de la fin du CSV n'existe dans aucune base reelle), utile pour valider le
|
||||||
pipeline sans base joignable.
|
pipeline sans base joignable.
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
services:
|
||||||
|
mlflow-db:
|
||||||
|
image: postgres:17
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: mlflow
|
||||||
|
POSTGRES_PASSWORD: ${MLFLOW_DB_PASSWORD:?definir MLFLOW_DB_PASSWORD dans ml/.env}
|
||||||
|
POSTGRES_DB: mlflow
|
||||||
|
volumes:
|
||||||
|
- mlflow-db-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U mlflow"]
|
||||||
|
interval: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
mlflow:
|
||||||
|
build: .
|
||||||
|
depends_on:
|
||||||
|
mlflow-db:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5000:5000"
|
||||||
|
volumes:
|
||||||
|
- mlflow-artifacts:/mlartifacts
|
||||||
|
environment:
|
||||||
|
MLFLOW_DB_PASSWORD: ${MLFLOW_DB_PASSWORD}
|
||||||
|
entrypoint: [ "/bin/sh", "-c" ]
|
||||||
|
command:
|
||||||
|
- exec mlflow server --host 0.0.0.0 --port 5000 --backend-store-uri "postgresql://mlflow:$$MLFLOW_DB_PASSWORD@mlflow-db:5432/mlflow" --artifacts-destination /mlartifacts --serve-artifacts
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mlflow-db-data:
|
||||||
|
mlflow-artifacts:
|
||||||
@@ -43,13 +43,19 @@ NUMERIC_COLUMNS = [
|
|||||||
"capacity_kw",
|
"capacity_kw",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Piege : `reading.is_working_hours` est nullable et entre dans les features. Une seule lecture a
|
# Piege : `is_working_hours` est nullable et entre dans les features. Toujours `float64`, jamais
|
||||||
# NULL rend la colonne `object`, que LightGBM refuse ("pandas dtypes must be int, float or bool").
|
# `bool` : `astype(bool)` ferait un `True` d'une absence, et les deux chargeurs divergeraient.
|
||||||
FLAG_COLUMNS = ["is_working_hours"]
|
FLAG_COLUMNS = ["is_working_hours"]
|
||||||
|
|
||||||
|
# `uq_reading_source` autorise deux lignes au meme (site_id, timestamp) des que `source` differe
|
||||||
|
# (cf. `app/etl/mock_api_import.py`, qui refuse desormais d'importer une fenetre deja couverte par
|
||||||
|
# le CSV, mais ne protege pas le sens inverse). `build_features` suppose une ligne par
|
||||||
|
# (site_id, timestamp) sans doublon : le `DISTINCT ON` l'impose plutot que de la supposer.
|
||||||
|
# 'csv' gagne sur 'api_history' en cas de recouvrement, l'historique etant une source verifiee
|
||||||
|
# alors que l'API Mock est traitee comme une entree hostile (cf. OWASP API10).
|
||||||
_READING_QUERY = text(
|
_READING_QUERY = text(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT DISTINCT ON (r.site_id, r.timestamp)
|
||||||
r.site_id,
|
r.site_id,
|
||||||
r.timestamp,
|
r.timestamp,
|
||||||
r.consumption_kwh,
|
r.consumption_kwh,
|
||||||
@@ -61,14 +67,14 @@ _READING_QUERY = text(
|
|||||||
s.capacity_kw
|
s.capacity_kw
|
||||||
FROM reading r
|
FROM reading r
|
||||||
JOIN site s ON s.site_id = r.site_id
|
JOIN site s ON s.site_id = r.site_id
|
||||||
ORDER BY r.site_id, r.timestamp
|
ORDER BY r.site_id, r.timestamp, (r.source = 'csv') DESC, r.reading_id DESC
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_RECENT_READING_QUERY = text(
|
_RECENT_READING_QUERY = text(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT DISTINCT ON (r.site_id, r.timestamp)
|
||||||
r.site_id,
|
r.site_id,
|
||||||
r.timestamp,
|
r.timestamp,
|
||||||
r.consumption_kwh,
|
r.consumption_kwh,
|
||||||
@@ -81,7 +87,7 @@ _RECENT_READING_QUERY = text(
|
|||||||
FROM reading r
|
FROM reading r
|
||||||
JOIN site s ON s.site_id = r.site_id
|
JOIN site s ON s.site_id = r.site_id
|
||||||
WHERE r.timestamp >= :since AND r.timestamp <= :until
|
WHERE r.timestamp >= :since AND r.timestamp <= :until
|
||||||
ORDER BY r.site_id, r.timestamp
|
ORDER BY r.site_id, r.timestamp, (r.source = 'csv') DESC, r.reading_id DESC
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -113,10 +119,15 @@ def load_recent_from_database(
|
|||||||
|
|
||||||
|
|
||||||
def load_from_csv(csv_path: Path) -> pd.DataFrame:
|
def load_from_csv(csv_path: Path) -> pd.DataFrame:
|
||||||
"""Lit le jeu de donnees CSV historique (chemin de demarrage, hors base)."""
|
"""Lit le jeu de donnees CSV historique (chemin de demarrage, hors base).
|
||||||
|
|
||||||
|
`is_working_hours` passe par `_typer` comme le chemin base, et non par un `astype(bool)` : le
|
||||||
|
fichier livre porte cette colonne en `0`/`1`, donc une case vide arrive en `NaN` et `astype`
|
||||||
|
la rendrait `True` sans rien signaler. Les deux chargeurs rendent ainsi le meme schema, ce que
|
||||||
|
`docs/ML-START.md` promet.
|
||||||
|
"""
|
||||||
frame = pd.read_csv(csv_path, parse_dates=["timestamp"])
|
frame = pd.read_csv(csv_path, parse_dates=["timestamp"])
|
||||||
frame["capacity_kw"] = float("nan")
|
frame["capacity_kw"] = float("nan")
|
||||||
frame["is_working_hours"] = frame["is_working_hours"].astype(bool)
|
|
||||||
|
|
||||||
return _typer(frame[OUTPUT_COLUMNS])
|
return _typer(frame[OUTPUT_COLUMNS])
|
||||||
|
|
||||||
@@ -131,12 +142,18 @@ def _typer(frame: pd.DataFrame) -> pd.DataFrame:
|
|||||||
n'importe quelle autre colonne mesuree entierement absente sur une fenetre de scoring, pas
|
n'importe quelle autre colonne mesuree entierement absente sur une fenetre de scoring, pas
|
||||||
seulement `capacity_kw`.
|
seulement `capacity_kw`.
|
||||||
|
|
||||||
|
Les colonnes de `FLAG_COLUMNS` sont en outre ramenees a `float64` : ce sont des drapeaux
|
||||||
|
nullables, et c'est le seul dtype qui survive a l'absence sans inventer de valeur. Sans cela,
|
||||||
|
le meme chargeur rendrait `bool`, `int64` ou `float64` selon le contenu de la fenetre lue.
|
||||||
|
|
||||||
Piege additionnel : `NUMERIC_COLUMNS` inclut `consumption_kwh`, la cible du modele, pas
|
Piege additionnel : `NUMERIC_COLUMNS` inclut `consumption_kwh`, la cible du modele, pas
|
||||||
seulement des variables explicatives. Une valeur non numerique y devient donc silencieusement
|
seulement des variables explicatives. Une valeur non numerique y devient donc silencieusement
|
||||||
`NaN` aussi bien a l'entrainement (ou `train.py` l'exclura ensuite via son `dropna`) qu'au
|
`NaN` aussi bien a l'entrainement (ou `train.py` l'exclura ensuite via son `dropna`) qu'au
|
||||||
scoring -- ce n'est pas un effet de bord limite aux colonnes mesurees.
|
scoring -- ce n'est pas un effet de bord limite aux colonnes mesurees.
|
||||||
"""
|
"""
|
||||||
typee = frame.copy()
|
typee = frame.copy()
|
||||||
for colonne in (*NUMERIC_COLUMNS, *FLAG_COLUMNS):
|
for colonne in NUMERIC_COLUMNS:
|
||||||
typee[colonne] = pd.to_numeric(typee[colonne], errors="coerce")
|
typee[colonne] = pd.to_numeric(typee[colonne], errors="coerce")
|
||||||
|
for colonne in FLAG_COLUMNS:
|
||||||
|
typee[colonne] = pd.to_numeric(typee[colonne], errors="coerce").astype("float64")
|
||||||
return typee
|
return typee
|
||||||
|
|||||||
@@ -181,7 +181,11 @@ def _log_to_mlflow(
|
|||||||
)
|
)
|
||||||
mlflow.log_metrics({f"model_{cle}": valeur for cle, valeur in model_metrics.items()})
|
mlflow.log_metrics({f"model_{cle}": valeur for cle, valeur in model_metrics.items()})
|
||||||
mlflow.log_metrics({f"baseline_{cle}": valeur for cle, valeur in baseline_metrics.items()})
|
mlflow.log_metrics({f"baseline_{cle}": valeur for cle, valeur in baseline_metrics.items()})
|
||||||
mlflow.lightgbm.log_model(booster, name="model")
|
mlflow.lightgbm.log_model(
|
||||||
|
booster,
|
||||||
|
name="model",
|
||||||
|
registered_model_name="consumption-forecast-lightgbm",
|
||||||
|
)
|
||||||
mlflow.log_artifact(str(model_output))
|
mlflow.log_artifact(str(model_output))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+38
-5
@@ -16,7 +16,7 @@ from collections.abc import Iterator
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import lightgbm as lgb
|
import lightgbm as lgb
|
||||||
@@ -44,20 +44,30 @@ _INSERT_SITE = text(
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
# `source = 'api_history'` impose `dataset_id IS NULL` (ck_reading_dataset_source), ce qui evite
|
# `source = 'api_history'` impose `dataset_id IS NULL` (ck_reading_dataset_source) : le defaut
|
||||||
# de creer une ligne `dataset`. `raw_data` est NOT NULL, d'ou le litteral jsonb.
|
# `dataset_id=None` evite de creer une ligne `dataset` pour la plupart des tests. `source='csv'`
|
||||||
|
# impose l'inverse, d'ou `insere_dataset()` quand un test a besoin de cette source precise.
|
||||||
|
# `raw_data` est NOT NULL, d'ou le litteral jsonb.
|
||||||
_INSERT_READING = text(
|
_INSERT_READING = text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO reading (
|
INSERT INTO reading (
|
||||||
site_id, timestamp, source, consumption_kwh, temperature_celsius,
|
site_id, timestamp, source, dataset_id, consumption_kwh, temperature_celsius,
|
||||||
humidity_percent, solar_irradiance_wm2, is_working_hours, raw_data
|
humidity_percent, solar_irradiance_wm2, is_working_hours, raw_data
|
||||||
) VALUES (
|
) VALUES (
|
||||||
:site_id, :timestamp, :source, :consumption_kwh, :temperature_celsius,
|
:site_id, :timestamp, :source, :dataset_id, :consumption_kwh, :temperature_celsius,
|
||||||
:humidity_percent, :solar_irradiance_wm2, :is_working_hours, '{}'::jsonb
|
:humidity_percent, :solar_irradiance_wm2, :is_working_hours, '{}'::jsonb
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_INSERT_DATASET = text(
|
||||||
|
"""
|
||||||
|
INSERT INTO dataset (dataset_name, archive_sha256, storage_uri, source_timezone, metadata)
|
||||||
|
VALUES (:dataset_name, :archive_sha256, :storage_uri, 'UTC', '{}'::jsonb)
|
||||||
|
RETURNING dataset_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
_SELECT_PREDICTIONS = text(
|
_SELECT_PREDICTIONS = text(
|
||||||
"""
|
"""
|
||||||
SELECT target_at, predicted_value, status, failure_reason, model_reference
|
SELECT target_at, predicted_value, status, failure_reason, model_reference
|
||||||
@@ -111,6 +121,25 @@ def insere_site(
|
|||||||
return site_id
|
return site_id
|
||||||
|
|
||||||
|
|
||||||
|
def insere_dataset(connexion: Connection) -> int:
|
||||||
|
"""Ligne `dataset` minimale, requise pour inserer une lecture `source='csv'`
|
||||||
|
|
||||||
|
(`ck_reading_dataset_source` impose `dataset_id IS NOT NULL` pour cette seule source).
|
||||||
|
"""
|
||||||
|
marque = uuid4().hex
|
||||||
|
return cast(
|
||||||
|
int,
|
||||||
|
connexion.execute(
|
||||||
|
_INSERT_DATASET,
|
||||||
|
{
|
||||||
|
"dataset_name": f"jeu de test {marque}",
|
||||||
|
"archive_sha256": marque.rjust(64, "0"),
|
||||||
|
"storage_uri": f"file:///test/{marque}.csv",
|
||||||
|
},
|
||||||
|
).scalar_one(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def insere_lectures(
|
def insere_lectures(
|
||||||
connexion: Connection,
|
connexion: Connection,
|
||||||
site_id: str,
|
site_id: str,
|
||||||
@@ -119,6 +148,7 @@ def insere_lectures(
|
|||||||
fin: datetime,
|
fin: datetime,
|
||||||
valeur: float = 50.0,
|
valeur: float = 50.0,
|
||||||
source: str = "api_history",
|
source: str = "api_history",
|
||||||
|
dataset_id: int | None = None,
|
||||||
is_working_hours: bool | None = True,
|
is_working_hours: bool | None = True,
|
||||||
) -> list[datetime]:
|
) -> list[datetime]:
|
||||||
"""Grille horaire contigue finissant a `fin`, incluse.
|
"""Grille horaire contigue finissant a `fin`, incluse.
|
||||||
@@ -134,6 +164,7 @@ def insere_lectures(
|
|||||||
"site_id": site_id,
|
"site_id": site_id,
|
||||||
"timestamp": instant,
|
"timestamp": instant,
|
||||||
"source": source,
|
"source": source,
|
||||||
|
"dataset_id": dataset_id,
|
||||||
"consumption_kwh": valeur + math.sin(rang / 12.0) * 10.0,
|
"consumption_kwh": valeur + math.sin(rang / 12.0) * 10.0,
|
||||||
"temperature_celsius": 15.0,
|
"temperature_celsius": 15.0,
|
||||||
"humidity_percent": 50.0,
|
"humidity_percent": 50.0,
|
||||||
@@ -153,6 +184,7 @@ def insere_lecture(
|
|||||||
instant: datetime,
|
instant: datetime,
|
||||||
consumption_kwh: float | None = 50.0,
|
consumption_kwh: float | None = 50.0,
|
||||||
source: str = "api_history",
|
source: str = "api_history",
|
||||||
|
dataset_id: int | None = None,
|
||||||
is_working_hours: bool | None = True,
|
is_working_hours: bool | None = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Une lecture isolee, quand le test pilote sa valeur plutot que sa forme."""
|
"""Une lecture isolee, quand le test pilote sa valeur plutot que sa forme."""
|
||||||
@@ -162,6 +194,7 @@ def insere_lecture(
|
|||||||
"site_id": site_id,
|
"site_id": site_id,
|
||||||
"timestamp": instant,
|
"timestamp": instant,
|
||||||
"source": source,
|
"source": source,
|
||||||
|
"dataset_id": dataset_id,
|
||||||
"consumption_kwh": consumption_kwh,
|
"consumption_kwh": consumption_kwh,
|
||||||
"temperature_celsius": 15.0,
|
"temperature_celsius": 15.0,
|
||||||
"humidity_percent": 50.0,
|
"humidity_percent": 50.0,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
from enervision_ml.data import NUMERIC_COLUMNS, load_from_csv
|
from enervision_ml.data import NUMERIC_COLUMNS, load_from_csv
|
||||||
|
|
||||||
@@ -53,3 +54,44 @@ def test_load_from_csv_always_types_capacity_kw_as_float(tmp_path: Path) -> None
|
|||||||
|
|
||||||
assert frame["capacity_kw"].dtype == "float64"
|
assert frame["capacity_kw"].dtype == "float64"
|
||||||
assert pd.isna(frame["capacity_kw"].iloc[0])
|
assert pd.isna(frame["capacity_kw"].iloc[0])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("present", ["1", "True"], ids=["entier", "booleen_textuel"])
|
||||||
|
def test_load_from_csv_keeps_a_missing_is_working_hours_as_nan(
|
||||||
|
tmp_path: Path, present: str
|
||||||
|
) -> None:
|
||||||
|
# Une case vide vaut "on ne sait pas", que LightGBM sait traiter. La rendre `True` inventerait
|
||||||
|
# une heure ouvree, et le modele apprendrait sur une valeur que personne n'a mesuree.
|
||||||
|
csv_path = write_csv(
|
||||||
|
tmp_path,
|
||||||
|
f"SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,{present},office",
|
||||||
|
"SITE001,2026-01-01T01:00:00,11.5,15.2,50.5,0.0,,office",
|
||||||
|
)
|
||||||
|
|
||||||
|
frame = load_from_csv(csv_path)
|
||||||
|
|
||||||
|
assert frame["is_working_hours"].iloc[0] == 1
|
||||||
|
assert pd.isna(frame["is_working_hours"].iloc[1])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"valeurs",
|
||||||
|
[("1", "0"), ("True", "False")],
|
||||||
|
ids=["entier", "booleen_textuel"],
|
||||||
|
)
|
||||||
|
def test_load_from_csv_always_types_is_working_hours_as_float(
|
||||||
|
tmp_path: Path, valeurs: tuple[str, str]
|
||||||
|
) -> None:
|
||||||
|
# Le dtype ne doit pas dependre de l'ecriture du fichier ni de la presence d'un trou : c'est
|
||||||
|
# ce qui rend comparable le schema des deux chargeurs, cf. `test_data_integration.py`.
|
||||||
|
present, absent = valeurs
|
||||||
|
csv_path = write_csv(
|
||||||
|
tmp_path,
|
||||||
|
f"SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,{present},office",
|
||||||
|
f"SITE001,2026-01-01T01:00:00,11.5,15.2,50.5,0.0,{absent},office",
|
||||||
|
)
|
||||||
|
|
||||||
|
frame = load_from_csv(csv_path)
|
||||||
|
|
||||||
|
assert frame["is_working_hours"].dtype == "float64"
|
||||||
|
assert list(frame["is_working_hours"]) == [1.0, 0.0]
|
||||||
|
|||||||
@@ -11,17 +11,11 @@ from enervision_ml.data import (
|
|||||||
load_from_database,
|
load_from_database,
|
||||||
load_recent_from_database,
|
load_recent_from_database,
|
||||||
)
|
)
|
||||||
from tests.conftest import ANCRAGE, insere_lecture, insere_lectures, insere_site
|
from tests.conftest import ANCRAGE, insere_dataset, insere_lecture, insere_lectures, insere_site
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
def du_site(frame: pd.DataFrame, site_id: str) -> pd.DataFrame:
|
|
||||||
"""Piege : les chargeurs ne filtrent pas par site, et `enervision_test` est partagee avec
|
|
||||||
les tests qui valident leurs ecritures. Juger le contenu de toute la fenetre les couplerait."""
|
|
||||||
return frame[frame["site_id"] == site_id].reset_index(drop=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_from_database_returns_the_nine_contract_columns(connexion_ml: Connection) -> None:
|
def test_load_from_database_returns_the_nine_contract_columns(connexion_ml: Connection) -> None:
|
||||||
site_id = insere_site(connexion_ml)
|
site_id = insere_site(connexion_ml)
|
||||||
insere_lectures(connexion_ml, site_id, heures=3, fin=ANCRAGE)
|
insere_lectures(connexion_ml, site_id, heures=3, fin=ANCRAGE)
|
||||||
@@ -45,6 +39,65 @@ def test_load_from_database_joins_the_site_attributes_to_every_reading(
|
|||||||
assert set(mien["capacity_kw"]) == {250.0}
|
assert set(mien["capacity_kw"]) == {250.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_from_database_deduplicates_two_sources_at_the_same_instant(
|
||||||
|
connexion_ml: Connection,
|
||||||
|
) -> None:
|
||||||
|
# `uq_reading_source` autorise deux lignes au meme (site_id, timestamp) des que `source`
|
||||||
|
# differe : le garde-fou vit dans `mock_api_import.py`, pas dans le schema. Le chargeur ML
|
||||||
|
# doit donc imposer lui-meme "une ligne par (site_id, timestamp)", pas la supposer.
|
||||||
|
#
|
||||||
|
# `csv` est inseree en premier (reading_id le plus bas) et `api_history` en second (le plus
|
||||||
|
# haut) : un depart par `reading_id DESC` seul choisirait `api_history` a tort. Seule la
|
||||||
|
# preference explicite pour `source='csv'` fait gagner le bon reading_id ici, et le test
|
||||||
|
# cesserait de proteger cette regle si l'ordre d'insertion etait inverse.
|
||||||
|
site_id = insere_site(connexion_ml)
|
||||||
|
dataset_id = insere_dataset(connexion_ml)
|
||||||
|
insere_lecture(
|
||||||
|
connexion_ml,
|
||||||
|
site_id,
|
||||||
|
instant=ANCRAGE,
|
||||||
|
consumption_kwh=99.0,
|
||||||
|
source="csv",
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
)
|
||||||
|
insere_lecture(
|
||||||
|
connexion_ml, site_id, instant=ANCRAGE, consumption_kwh=10.0, source="api_history"
|
||||||
|
)
|
||||||
|
|
||||||
|
frame = load_from_database(connexion_ml)
|
||||||
|
|
||||||
|
mien = frame[frame["site_id"] == site_id]
|
||||||
|
assert len(mien) == 1
|
||||||
|
assert mien["consumption_kwh"].iloc[0] == 99.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_recent_from_database_prefers_csv_when_two_sources_share_an_instant(
|
||||||
|
connexion_ml: Connection,
|
||||||
|
) -> None:
|
||||||
|
# Meme ordre d'insertion que ci-dessus, et pour la meme raison : `csv` doit gagner malgre un
|
||||||
|
# `reading_id` plus bas que celui d'`api_history`.
|
||||||
|
site_id = insere_site(connexion_ml)
|
||||||
|
dataset_id = insere_dataset(connexion_ml)
|
||||||
|
insere_lecture(
|
||||||
|
connexion_ml,
|
||||||
|
site_id,
|
||||||
|
instant=ANCRAGE,
|
||||||
|
consumption_kwh=99.0,
|
||||||
|
source="csv",
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
)
|
||||||
|
insere_lecture(
|
||||||
|
connexion_ml, site_id, instant=ANCRAGE, consumption_kwh=10.0, source="api_history"
|
||||||
|
)
|
||||||
|
|
||||||
|
frame = load_recent_from_database(
|
||||||
|
connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(frame) == 1
|
||||||
|
assert frame["consumption_kwh"].iloc[0] == 99.0
|
||||||
|
|
||||||
|
|
||||||
def test_load_recent_from_database_excludes_readings_before_the_since_bound(
|
def test_load_recent_from_database_excludes_readings_before_the_since_bound(
|
||||||
connexion_ml: Connection,
|
connexion_ml: Connection,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -55,7 +108,7 @@ def test_load_recent_from_database_excludes_readings_before_the_since_bound(
|
|||||||
connexion_ml, since=ANCRAGE - timedelta(hours=2), until=ANCRAGE
|
connexion_ml, since=ANCRAGE - timedelta(hours=2), until=ANCRAGE
|
||||||
)
|
)
|
||||||
|
|
||||||
assert list(du_site(frame, site_id)["timestamp"]) == [
|
assert list(frame["timestamp"]) == [
|
||||||
ANCRAGE - timedelta(hours=2),
|
ANCRAGE - timedelta(hours=2),
|
||||||
ANCRAGE - timedelta(hours=1),
|
ANCRAGE - timedelta(hours=1),
|
||||||
ANCRAGE,
|
ANCRAGE,
|
||||||
@@ -72,7 +125,7 @@ def test_load_recent_from_database_includes_a_reading_exactly_at_the_since_bound
|
|||||||
connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3)
|
connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(du_site(frame, site_id)) == 1
|
assert len(frame) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_load_recent_from_database_keeps_timestamps_timezone_aware(
|
def test_load_recent_from_database_keeps_timestamps_timezone_aware(
|
||||||
@@ -99,7 +152,7 @@ def test_load_recent_from_database_orders_readings_by_site_then_timestamp(
|
|||||||
connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3)
|
connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert list(du_site(frame, site_id)["timestamp"]) == [
|
assert list(frame["timestamp"]) == [
|
||||||
ANCRAGE,
|
ANCRAGE,
|
||||||
ANCRAGE + timedelta(hours=1),
|
ANCRAGE + timedelta(hours=1),
|
||||||
ANCRAGE + timedelta(hours=2),
|
ANCRAGE + timedelta(hours=2),
|
||||||
@@ -128,7 +181,7 @@ def test_load_recent_from_database_types_a_fully_null_capacity_kw_as_float64(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert frame["capacity_kw"].dtype == "float64"
|
assert frame["capacity_kw"].dtype == "float64"
|
||||||
assert du_site(frame, site_id)["capacity_kw"].isna().all()
|
assert frame["capacity_kw"].isna().all()
|
||||||
|
|
||||||
|
|
||||||
def test_load_recent_from_database_types_a_null_is_working_hours_as_float64(
|
def test_load_recent_from_database_types_a_null_is_working_hours_as_float64(
|
||||||
@@ -145,7 +198,22 @@ def test_load_recent_from_database_types_a_null_is_working_hours_as_float64(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert frame["is_working_hours"].dtype == "float64"
|
assert frame["is_working_hours"].dtype == "float64"
|
||||||
assert list(du_site(frame, site_id)["is_working_hours"].isna()) == [True, False]
|
assert list(frame["is_working_hours"].isna()) == [True, False]
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_recent_from_database_types_is_working_hours_as_float64_even_without_a_null(
|
||||||
|
connexion_ml: Connection,
|
||||||
|
) -> None:
|
||||||
|
# Sans cette garantie, le dtype dependrait du contenu de la fenetre lue : `bool` ici, `float64`
|
||||||
|
# des qu'une seule lecture est a NULL, et le schema des deux chargeurs cesserait d'etre egal.
|
||||||
|
site_id = insere_site(connexion_ml)
|
||||||
|
insere_lectures(connexion_ml, site_id, heures=2, fin=ANCRAGE)
|
||||||
|
|
||||||
|
frame = load_recent_from_database(
|
||||||
|
connexion_ml, since=ANCRAGE - timedelta(hours=2), until=ANCRAGE
|
||||||
|
)
|
||||||
|
|
||||||
|
assert frame["is_working_hours"].dtype == "float64"
|
||||||
|
|
||||||
|
|
||||||
def test_both_loaders_produce_the_same_columns_in_the_same_order(
|
def test_both_loaders_produce_the_same_columns_in_the_same_order(
|
||||||
@@ -186,4 +254,4 @@ def test_load_recent_from_database_excludes_readings_after_the_until_bound(
|
|||||||
connexion_ml, since=ANCRAGE - timedelta(days=1), until=ANCRAGE
|
connexion_ml, since=ANCRAGE - timedelta(days=1), until=ANCRAGE
|
||||||
)
|
)
|
||||||
|
|
||||||
assert list(du_site(frame, site_id)["timestamp"]) == [ANCRAGE]
|
assert list(frame["timestamp"]) == [ANCRAGE]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
from enervision_ml.features import TARGET_COLUMN, build_features, feature_columns
|
from enervision_ml.features import TARGET_COLUMN, build_features, feature_columns
|
||||||
from enervision_ml.train import chronological_split, prepare_dataset, train
|
from enervision_ml.train import chronological_split, prepare_dataset, train
|
||||||
@@ -74,3 +75,19 @@ def test_train_runs_end_to_end_on_synthetic_data_and_beats_a_dummy_baseline(
|
|||||||
assert model_metrics["n_observations"] > 0
|
assert model_metrics["n_observations"] > 0
|
||||||
assert model_metrics["mae"] >= 0
|
assert model_metrics["mae"] >= 0
|
||||||
assert baseline_metrics["n_observations"] == model_metrics["n_observations"]
|
assert baseline_metrics["n_observations"] == model_metrics["n_observations"]
|
||||||
|
assert model_metrics["mae"] < baseline_metrics["mae"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_train_raises_when_the_validation_window_is_empty(tmp_path: Path) -> None:
|
||||||
|
depart = datetime(2026, 1, 1, tzinfo=UTC)
|
||||||
|
frame = make_frame("site-a", heures=50, depart=depart) # trop court pour un lag de 168h
|
||||||
|
csv_path = tmp_path / "trop_court.csv"
|
||||||
|
frame.to_csv(csv_path, index=False)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Fenetre d'entrainement ou de validation vide"):
|
||||||
|
train(
|
||||||
|
csv_path=csv_path,
|
||||||
|
model_output=tmp_path / "model.txt",
|
||||||
|
test_fraction=0.2,
|
||||||
|
tracking_uri=f"sqlite:///{tmp_path / 'mlflow.db'}",
|
||||||
|
)
|
||||||
|
|||||||
+81
-7
@@ -1,10 +1,84 @@
|
|||||||
# 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 |
|
||||||
|
| `garage` (cible) | service de la stack | Requêtes S3, octets lus et écrits, disque local, santé du nœud, sur `garage:3903/metrics` avec `GARAGE_METRICS_TOKEN` | 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.
|
||||||
|
|
||||||
|
Quatre 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`) |
|
||||||
|
| `GARAGE_METRICS_TOKEN` | Jeton que Prometheus présente sur `/metrics` de Garage (ADR 0019), passé en secret Compose |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user