Compare commits

..
Author SHA1 Message Date
ineszang44 6301ffedd7 Merge branch 'dev' into feat/deploy 2026-09-23 09:50:54 +02:00
Johan LEROYandGitHub 59f050ec5e Merge pull request #147 from ineszang/test/integration-api-db-ml
test(ml,backend): tests d'intégration API ↔ DB ↔ ML, et surveillance de dérive
2026-09-22 16:52:55 +02:00
Johan LEROY 6e9c830557 Fusionne dev dans test/integration-api-db-ml
Quatre conflits, tous additifs, nés du DAG `mock_api_import` (#146) arrivé sur `dev` pendant
que cette branche ajoutait `derive` : la liste des DAGs du README, celle de la vue d'ensemble
et du tableau d'infrastructure, et `DAG_IDS`/`TACHES` dans les tests d'intégrité. Les six DAGs
sont conservés de part et d'autre.

Collision que git ne voyait pas : `dev` a reçu un ADR 0011 et un 0012 (procédure de
déploiement, état de la VM ENI) pendant que cette branche en ajoutait un autre sous le même
numéro. L'ADR de la surveillance de dérive devient 0013, avec ses onze références, et la table
de `docs/README.md` reprend les trois.
2026-09-22 16:40:06 +02:00
Meryemel-ghamandGitHub 5a3c526856 Merge pull request #146 from ineszang/feat/dag-mock-api-import
feat(airflow): orchestre l'import de la Mock API
2026-09-22 16:32:45 +02:00
Johan LEROY 314e3b72c0 fix(ml,backend): corrige la revue, le typage du drapeau CSV et la portée du biais
`load_from_csv` gardait un `astype(bool)` sur `is_working_hours`, joué avant `_typer` :
une case vide du CSV arrivait en `NaN` et en ressortait `True`, soit une heure ouvrée
inventée. Le chemin base était corrigé, pas celui-ci, et rien ne le couvrait. La ligne
disparaît, et `_typer` ramène désormais les colonnes de `FLAG_COLUMNS` à `float64` quel
que soit le contenu lu : sans cela le dtype dépendait de l'écriture du fichier (`0`/`1`
contre `True`/`False`) et de la présence d'un trou, et l'égalité de schéma entre les deux
chargeurs que promet ML-START n'était vraie que par accident du jeu de test.

`Seuils.seuil_biais` valait `0` et `_verdict` exigeait `> 0` : la règle était inerte
partout, CLI et DAG compris, et aucun test ne l'exerçait. Elle reste désactivée par
défaut, parce qu'un seuil en kWh ne se transpose pas d'un bureau de 10 kWh à une usine
de 1 000 kWh et qu'aucune valeur n'a été calibrée sur la vraie série, mais `--bias-threshold`
la rend atteignable et l'ADR 0011 porte l'arbitrage. Trois tests couvrent le chemin :
inerte par défaut, dérive au-delà du seuil réglé, et priorité de la MAE sur le biais.

Deux lignes de doc devenues fausses au passage : la signature de `load_recent_from_database`
dans ML-START, qui omettait `until` devenu obligatoire, et la ligne `bias` de 20-backend,
qui laissait croire que la métrique décide du verdict.
2026-09-22 16:25:02 +02:00
Meryemel-gham e118c008bf fix(airflow): fiabilise l'import horaire de la Mock API
Airflow / Construction de l'image (push) Successful in 1m4s
Backend / Analyse statique de sécurité (push) Successful in 7s
Backend / Tests exigeant une base (push) Failing after 4m55s
Airflow / Lint et intégrité des DAGs (push) Successful in 9m49s
Backend / Lint, typage et tests (push) Successful in 10m12s
Backend / Audit des dépendances (push) Successful in 9m36s
SonarQube / build-front (push) Successful in 9m35s
SonarQube / test-ml (push) Failing after 5m37s
SonarQube / build-back (push) Successful in 9m46s
SonarQube / test-front (push) Failing after 5m4s
SonarQube / test-back (push) Failing after 5m9s
SonarQube / SonarQube (push) Skipped
2026-09-22 16:24:05 +02:00
Meryemel-gham d86224a0f7 feat(airflow): orchestre l'import de la Mock API 2026-09-22 15:47:01 +02:00
Johan LEROY f21a843fc2 Fusionne dev dans test/integration-api-db-ml
La PR #123 (MLflow) est arrivée sur dev entre-temps. Un seul conflit, la liste
.PHONY du Makefile : elle garde `migrate-test` d'ici et `mlflow-up` de dev, les
deux cibles existant chacune de leur côté.

Rien d'autre ne se recoupe : le test de chaîne passait déjà son propre
`--mlflow-tracking-uri` sur un SQLite jetable, et `modele_jetable` entraîne son
Booster sans passer par `train()`, qui journalise dans MLflow sans garde.
2026-09-22 15:34:38 +02:00
ineszangandGitHub 6b3908d321 Add files via upload 2026-09-22 15:34:33 +02:00
ineszang44 da5d2d838a feat: saisie des logins pour accéder au vault 2026-09-22 15:26:30 +02:00
Johan LEROY b16861e211 Fusionne dev dans test/integration-api-db-ml
Un seul conflit, docs/architecture/50-cicd.md : les deux côtés ajoutaient une
section au même endroit, après « Secrets ». Les deux sont conservées. Celle de
la branche, « Pourquoi le job d'intégration ML installe aussi le backend »,
remonte sous « Le job d'intégration, et pourquoi il ne suffisait pas d'un
postgres », dont elle est le prolongement : posée après « Secrets », elle en
devenait une sous-section.

openapi.json régénéré : dev a renommé le schéma de sécurité « Jeton d'accès »
en « JetonAcces » pour l'analyseur de contrat de ZAP, et la route
/api/v1/monitoring/drift ajoutée ici portait encore l'ancien nom dans le
contrat figé. Aucune fusion textuelle ne pouvait le voir.
2026-09-22 15:22:28 +02:00
ValentinDeFariaandGitHub 57b9735804 Merge pull request #123 from ineszang/feat/entrainement-du-modele
feat(ml): enregistrer le modèle dans le MLflow Model Registry
2026-09-22 15:21:24 +02:00
PhyriosandGitHub c007ea01bd Merge pull request #140 from ineszang/feat/scan-dast-owasp-zap
ci(backend): ajoute un scan DAST OWASP ZAP de l'API avec un compte le…
2026-09-22 15:17:18 +02:00
Johan LEROY 5472b19504 fix(backend): repare ce que la CI a trouve sur les tests de derive
Deux causes distinctes, toutes deux invisibles sans base.

`creer_lecture` ne posait pas `consumption_kwh` : l'override etait ignore en silence, la colonne
restait nulle, et la jointure de derive, qui ecarte les lectures sans mesure, ne trouvait donc
aucune paire. Le helper accepte desormais ce champ, nul par defaut, ce qui ne change rien pour
les dix fichiers qui l'utilisent deja.

`test_the_operator_rank_opens_nothing_more_than_the_reader_rank` figeait l'egalite des deux rangs
en annoncant, dans son propre commentaire, qu'il devait sonner « le jour ou une route d'operateur
arrive ». Ce jour est arrive avec `GET /monitoring/drift`. Le test compare maintenant chaque
route a ce que `ROLE_MINIMUM` lui reserve : il continue d'attraper une route d'operateur ajoutee
sans etre classee, et attrape en plus une garde d'operateur posee par erreur sur une route de
lecture.
2026-09-22 15:08:40 +02:00
Valentin 44163bfb98 fix(ml): corrige le build MLflow (psycopg2), le garde-fou Makefile, la doc et la fuite de mot de passe
ML / Analyse statique de sécurité (push) Successful in 6s
SonarQube / test-ml (push) Failing after 6m4s
SonarQube / build-front (push) Successful in 9m44s
SonarQube / build-back (push) Successful in 9m50s
ML / Lint, typage et tests (push) Successful in 11m41s
SonarQube / test-front (push) Failing after 5m1s
SonarQube / test-back (push) Failing after 5m11s
SonarQube / SonarQube (push) Skipped
2026-09-22 15:04:21 +02:00
Dorian ea8f9d0a3a fix(ci): applique aussi le chmod du fichier d'authentification DAST via sudo 2026-09-22 14:57:29 +02:00
Dorian f58fc4ba81 fix(ci): corrige les permissions du fichier d'authentification qui bloquait le scan DAST 10 minutes 2026-09-22 14:31:29 +02:00
Johan LEROY cb961ec2c5 fix(ml): borne la fenetre de scoring a l'instant demande, pour que --now rejoue l'historique
`load_recent_from_database` n'avait qu'une borne basse. `build_scoring_frame` repartait donc de
la derniere lecture de toute la table quel que soit `--now` : `target_at` valait toujours
"fin du jeu + 1h", et `_age = instant - derniere_lecture` devenait negatif, ce qui passait le
seuil de peremption sans rien signaler.

Consequence concrete : sur le jeu historique, arrete au 31/12/2024, aucune boucle de rattrapage
ne pouvait produire une prevision dont le realise existe deja. La surveillance de derive livree
par la migration precedente n'aurait donc rien eu a comparer en demonstration.

`until` est desormais obligatoire sur ce chargeur, ce qui interdit de l'oublier, et le mode CSV
filtre symetriquement. En exploitation rien ne change, aucune lecture n'etant posterieure a
l'heure courante.
2026-09-22 14:29:03 +02:00
Johan LEROY 68239371f6 docs(ml,backend,etl): ordonnance la derive et corrige ce que le depot disait faux
Trois phrases du depot annonçaient une surveillance de derive inexistante, et une quatrieme
disait qu'aucune base PostgreSQL n'etait joignable pour tester le chargement ML. Les quatre
sont maintenant fausses, donc reecrites plutot que laissees en dette.

- ADR 0011 : ou vit le calcul et pourquoi pas dans `ml/`, les deux dedoublonnages qu'impose la
  jointure, et quatre alternatives ecartees avec la contrainte qui les interdit (la metrique
  MLflow n'est pas la meme grandeur, `alert` borne ses valeurs et refuse un site nul, Prometheus
  n'a pas de collecteur, ne rien persister ne repond pas a la question du jury).
- DAG `derive` quotidien, hors du DAG `alertes` : un echec de derive y ferait croire que la
  detection a echoue, et la fenetre de 168 h ne se recalcule pas toutes les heures.
- ML-START : la limite de `--now` est dite au lieu d'etre decouverte en demonstration. Elle ne
  decale que l'instant de reference, pas la fenetre de lecture, donc aucun rattrapage ne peut
  fabriquer de paires prevu/realise sur un jeu fige.
- 50-cicd : pourquoi le job ML installe aussi le backend (le schema n'a qu'une source), et ce
  que coute le filtre de chemins qui l'accompagne.
2026-09-22 14:27:13 +02:00
Johan LEROY 9bf2f27127 feat(backend): surveille la derive du modele de prevision
EC06 attendait une reponse a « comment savez-vous que le modele se degrade ? ». Elle n'existait
nulle part : `docs/architecture/00-vue-ensemble.md` et `docs/ML-START.md` le disaient tous les
deux.

Le calcul vit dans le backend, et `ml/` ne gagne pas une ligne. Trois raisons : `prediction`
n'est pas dans le perimetre de lecture que `ML_DATABASE_URL` vise (ADR 0003 et ML-START le
bornent a `reading` et `site`) ; l'alignement prevu contre realise existe deja une fois ici,
dans `AlertService._detect_anomaly`, et le dupliquer en SQL brut creerait une seconde source de
verite, ce que l'ADR 0006 refuse ; et FastAPI continue de ne jamais faire tourner LightGBM.

Ce qui est mesure : la jointure `prediction` x `reading` sur `(site_id, target_at)`, avec un
`DISTINCT ON` des deux cotes. Les runs de scoring s'empilent volontairement, et
`uq_reading_source` autorise deux lectures au meme instant quand la source differe : sans ce
dedoublonnage, la meme heure pesait plusieurs fois dans la moyenne. La fenetre est fermee a
droite par un delai de grace, sinon la derniere heure, dont le realise n'est pas encore
ingere, ferait chuter la couverture a chaque execution.

Le verdict a trois valeurs, pas deux : avec trois points on ne declare pas une derive, on dit
qu'on ne sait pas. La comparaison se fait entre deux fenetres vives de meme duree, jamais
contre la metrique loguee a l'entrainement : celle-ci mesure un backtest a meteo connue, le
scoring prevoit une heure dont la meteo ne l'est pas.

`drift_report` porte une ligne par site plus une ligne globale, que `site_id` a NULL designe.
L'idempotence passe par un index a `coalesce` et non par une contrainte d'unicite, sans quoi
deux lignes globales ne seraient jamais egales.
2026-09-22 14:22:54 +02:00
Johan LEROY 568060a903 test(backend): traverse l'API jusqu'a PostgreSQL sur les sites et les recommandations
Les tests d'API remplacaient tous leur service par un double : rien ne prouvait que
`endpoint -> service -> repository -> SQL` rende ce que l'endpoint serialise. Seules
l'authentification et la matrice de roles traversaient vraiment la base.

`tests/api/conftest.py` seme un jeu metier valide en base et nettoie derriere lui. Il valide
ses ecritures, contrairement aux fixtures de `tests/repositories` : un endpoint ouvre sa
propre session et ne verrait pas une transaction en cours. L'isolation vient de la marque
portee par chaque `site_id`, jamais d'un total : ces routes listent toute la base.

Les recommandations d'abord, parce que `POST /generate` est la seule route d'ecriture : son
idempotence tient a une contrainte d'unicite et a un `on_conflict_do_nothing`, invérifiables
hors base, et sa relecture par une seconde requete HTTP est la seule assertion du depot qui
prouve que la validation atteint le disque. Cote sites, le depart des ex aequo par
`reading_id` quand deux sources ecrivent la meme heure ne peut se demontrer qu'ainsi.
2026-09-22 14:13:00 +02:00
Dorian 2c7ee2c064 Merge remote-tracking branch 'origin/dev' into feat/scan-dast-owasp-zap 2026-09-22 14:11:31 +02:00
Johan LEROY aa4af62290 test(ml,backend): couvre ML vers DB, puis la chaine complete jusqu'a l'API
Le pipeline ML n'avait aucun test touchant PostgreSQL : `ml/README.md` le disait, faute de
base joignable en CI. Le marqueur `integration` de `ml/pyproject.toml` etait declare et porte
par zero test.

- `ml/tests/conftest.py` : deux fixtures d'acces a la base, jamais interchangeables.
  `connexion_ml` annule sa transaction, `parc` valide ses ecritures parce que `run_scoring`
  ouvre sa propre connexion et ne verrait rien d'autre. Garde sur le nom de base, marque uuid
  sur chaque site, nettoyage dans l'ordre des cles etrangeres.
- `test_data_integration.py` : les neuf colonnes du contrat confrontees au schema Alembic
  reel, la borne `since`, l'ordre de tri dont dependent des lags positionnels, et le typage
  des colonnes entierement nulles.
- `test_score_integration.py` : les contraintes de `prediction` vues depuis le code qui
  ecrit, l'empilement volontaire de deux runs, et `run_scoring` de bout en bout sur un
  booster reel.
- `apps/backend/tests/test_chaine_ml_api.py` : lance les vrais binaires `enervision_ml.train`
  et `.score` en sous-processus, comme les DAGs, puis relit par `GET /api/v1/predictions`.
  Marqueur `chaine` distinct : le job `integration` du backend n'a pas l'environnement de ml/.
- `ml.yml` : job `integration`, seul du depot a reunir les deux environnements uv et une base.
  Ses `paths` incluent les migrations du backend, sans quoi le schema deriverait du SQL du
  pipeline sans que rien ne casse.
- Makefile : `migrate-test`, qui manquait (`enervision_test` n'a jamais recu de table),
  `ml-test-integration` et `test-chaine`.
2026-09-22 14:10:50 +02:00
Johan LEROY 5b5c97532d fix(ml): type is_working_hours pour que LightGBM accepte une lecture sans valeur
`reading.is_working_hours` est nullable et fait partie des features. Une seule lecture a
NULL dans la fenetre suffisait a rendre la colonne `object` au retour de `pd.read_sql`, et
LightGBM refuse alors de construire son Dataset : "pandas dtypes must be int, float or
bool". La panne n'arrivait qu'au scoring, sur la vraie base, jamais en test.

`_typer` coerce donc aussi cette colonne, comme les colonnes mesurees : NaN vaut valeur
manquante, que LightGBM gere nativement. Effet de bord voulu, les deux chargeurs rendent
enfin le meme dtype : `load_from_csv` faisait un `astype(bool)` qui ecrasait silencieusement
une valeur absente en `False`.
2026-09-22 14:10:35 +02:00
Dorian e220f8f0c6 fix(ci,backend): securise le jeton du scan DAST, seme des donnees et refait ses garde-fous 2026-09-22 14:08:51 +02:00
Johan LEROYandGitHub fb4235df3a Merge pull request #144 from ineszang/feat/terraform-provisionnement
feat(infra): Terraform provisionne la VM, GitHub Actions continue de déployer
2026-09-22 12:39:12 +02:00
Johan LEROYandClaude Opus 5 357cf996ed fix(infra): donne au runner un nom distinct de son label
Infra / Formatage et validation Terraform (push) Successful in 9s
--name reprenait runner_labels : un runner et son label portaient le meme
eni-g3. Deux machines qui reprendraient cette racine s'enregistreraient sous
le meme nom, ce que GitHub refuse. Le nom vaut desormais celui de l'hote,
resolu sur la machine, et runner_nom permet de le forcer.

Point 2 de la revue de la PR #144.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 12:36:38 +02:00
PhyriosandGitHub 4fb8b5a784 Merge pull request #143 from ineszang/fix/deploiement-airflow-3
fix(infra,ci): aligne le provisionnement et le déploiement sur Airflow 3
2026-09-22 11:44:16 +02:00
Dorian d9103ee4ed fix(ci): prune-cache set false 2026-09-22 11:29:54 +02:00
Johan LEROY fca36649f9 ci(infra): épingle setup-terraform sur un SHA de commit
Action tierce sur un workflow qui tourne avec les droits du dépôt : un tag
mobile se redéplace. Constat SonarCloud githubactions:S7637, seul écart de la
quality gate sur la PR.
2026-09-22 11:29:24 +02:00
Johan LEROY 0db69fd023 feat(infra): Terraform provisionne la VM, GitHub Actions continue de déployer
Le seul Terraform du dépôt installait un cluster k3s que rien ne consomme, et
sa racine ne passait même pas `terraform init` : depuis Terraform 1.x, un
provisioner `destroy` impose que tout le bloc `connection` ne lise que `self`,
et celui du module k3s lisait `var.*`. Il est corrigé en batissant la connexion
sur `triggers`, où ne figurent que l'adresse, le port, l'utilisateur et le
chemin de la clé : jamais la clé ni un mot de passe.

`environments/vm-eni` provisionne la machine qui porte la recette et la
production : Docker et le plugin Compose, `scripts/provision-host.sh`, puis
l'enregistrement du runner. Rien n'y construit d'image ni ne lance de
conteneur, et un `apply` n'interrompt pas la stack qui tourne. Le déploiement
continu ne bouge pas : `deploy.yml` reste le seul chemin de livraison.

`environments/dev` devient `k3s-cible`, parce qu'il provisionnait un cluster et
pas un environnement applicatif, et que `rec` et `prod` vivent sur la même
machine. `environments/prod`, dossier vide, disparaît.

`infra.yml` formate et valide chaque racine à chaque push : c'est l'absence
d'un tel job qui a laissé ce Terraform cassé sans que rien ne le dise. La
boucle parcourt `environments/*`, une racine ajoutée est couverte sans y
toucher.

ADR 0010 pour la frontière entre provisionnement et livraison, et la doc
d'architecture alignée dessus.
2026-09-22 11:27:21 +02:00
Dorian 3ddeb24207 Merge remote-tracking branch 'origin/dev' into feat/scan-dast-owasp-zap 2026-09-22 11:16:51 +02:00
Valentin 8760ebc701 fix(ml): applique les corrections de la review MLflow (securite, documentation, robustesse) 2026-09-22 10:41:02 +02:00
Valentin a013dfa87f Merge remote-tracking branch 'origin/dev' into feat/registry-modele-prediction 2026-09-22 10:11:24 +02:00
Dorian 6d741e45fb fix(ci): corrige la cle matchstr de l'en-tete Authorization dans le scan DAST et retire le diagnostic socat 2026-09-21 16:59:13 +02:00
Dorian 1bec2c1376 fix(ci): diagnostique les 400 du scan DAST avec socat et echoue si toutes les reponses sont des 4xx 2026-09-21 16:49:44 +02:00
Dorian 00fab49d80 fix(ci): charge le contrat OpenAPI depuis un fichier dans le scan DAST et publie les journaux ZAP 2026-09-21 16:42:25 +02:00
Dorian de88c4f156 fix(ci): corrige les issues Sonar du scan DAST et fait echouer un scan qui n'importe pas le contrat 2026-09-21 16:26:29 +02:00
Dorian 67dcf506e9 ci(backend): ajoute un scan DAST OWASP ZAP de l'API avec un compte lecteur jetable* 2026-09-21 16:11:36 +02:00
ineszang44 8386e05f31 fix: déploiement pour le frontend 2026-09-21 16:07:43 +02:00
Valentin 9cd4f0de1c Merge branch 'feat/entrainement-du-modele' of https://github.com/ineszang/ProjetPiscine_EnerVision into feat/registry-modele-prediction 2026-09-21 15:41:14 +02:00
Valentin 5cc99178c2 fix(ml): execute MLflow en non-root et installe uniquement des wheels 2026-09-21 15:41:02 +02:00
ValentinDeFariaandGitHub b41a16364b Merge branch 'dev' into feat/entrainement-du-modele 2026-09-21 15:14:57 +02:00
Valentin 33aeea835b fix(ml): retire le mot de passe PostgreSQL du compose 2026-09-21 15:06:05 +02:00
Valentin 9312d3b60f feat(ml): enregistrer le modèle dans le MLflow Model Registry 2026-09-21 14:52:00 +02:00
ineszang44 2a7e1c25fc Merge branch 'dev' into feat/deploy 2026-09-21 14:06:42 +02:00
ineszang44 5d955ca991 fix: suppression du flag "--mount" 2026-09-21 14:05:55 +02:00
Valentin 268496a8c4 feat(ml): enregistrer le modèle dans le MLflow Model Registry 2026-09-21 12:04:49 +02:00
ineszang44 cefb07067d init: fichier terraform pour le backend 2026-09-21 11:49:10 +02:00
ineszang44 3cc6c67e0d Merge branch 'dev' into feat/deploy 2026-09-21 11:05:12 +02:00
ineszang44 93afc6031c fix: lancement de la commande npm run build, source directe pour l'image du runner 2026-09-21 10:40:14 +02:00
ineszang44 99db807e3c feat: création auto de l'image et du conteneur 2026-09-18 15:41:54 +02:00
ineszang44 47c4fd445b init: terraform 2026-09-18 09:48:20 +02:00
ineszang44 667592033b chore(frontend): suppression des variables inutiles, copie des fichiers terraform communs aux environnements 2026-09-17 15:01:17 +02:00
ineszang44 6785c70f6f Merge remote-tracking branch 'origin/dev' into feat/deploy 2026-09-17 14:51:42 +02:00
ineszang44 692ec436a5 feat+chore(frontend): déploiement selon l'env sélectionné, commentaires sur le fichier terraform 2026-09-17 11:28:22 +02:00
ineszang44 a3d32a6fb1 chore(frontend): fichier tf pour le front 2026-09-16 16:24:59 +02:00
103 changed files with 6097 additions and 169 deletions
+4 -3
View File
@@ -93,11 +93,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. # Les commandes des DAGs `alertes`, historique et API Mock sont couvertes.
- name: Vérifie que les trois commandes backend s'importent sans réseau - name: Vérifie que les quatre commandes backend s'importent sans réseau
run: > run: >
docker run --rm --network none enervision-airflow:ci docker run --rm --network none enervision-airflow:ci
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"
+325
View File
@@ -0,0 +1,325 @@
name: DAST
# Scan dynamique OWASP ZAP de l'API (issue #41). Il attaque une API qui tourne : le job démarre
# la base et le backend sur le runner, sème un site et quelques relevés (sans ça le scan ne
# frappe que des gestionnaires d'erreur), crée un compte `lecteur` jetable
# (scripts/dast-token.sh), puis lance ZAP sur le contrat OpenAPI avec le jeton de ce compte.
#
# 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"
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@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
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 --frozen --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. `db/seeds/` est vide
# (pas encore d'outillage de jeu de données pour la CI) : un site et deux relevés à la main,
# juste assez pour que les routes de lecture aient quelque chose à rendre.
- name: Insère un site et des relevés minimaux pour le scan
run: |
psql -h localhost -p 5433 -U enervision -d enervision_dast <<'SQL'
INSERT INTO site (site_id, site_name, site_type, location, capacity_kw, status)
VALUES ('dast-site', 'Site du scan DAST', 'bureau', 'CI', 50, 'actif')
ON CONFLICT (site_id) DO NOTHING;
INSERT INTO reading (site_id, timestamp, source, consumption_kw, consumption_kwh, is_working_hours, data_quality, raw_data)
VALUES
('dast-site', now() - interval '2 hours', 'api_current', 12.5, 12.5, true, 'good', '{}'),
('dast-site', now() - interval '1 hour', 'api_current', 13.0, 13.0, true, 'good', '{}')
ON CONFLICT DO NOTHING;
SQL
- 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 >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
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
+52
View File
@@ -0,0 +1,52 @@
name: Infra
# Pourquoi : le Terraform du dépôt est resté cassé sans que rien ne le dise, faute de job qui le
# joue. Ce workflow n'applique rien : il vérifie le formatage et la validité de chaque racine.
# Piège : la boucle parcourt `environments/*`, pour qu'une racine ajoutée soit couverte sans
# toucher à ce fichier.
on:
push:
paths:
- "infra/terraform/**"
- ".github/workflows/infra.yml"
pull_request:
paths:
- "infra/terraform/**"
- ".github/workflows/infra.yml"
permissions:
contents: read
concurrency:
group: infra-${{ github.ref }}
cancel-in-progress: true
jobs:
terraform:
name: Formatage et validation Terraform
runs-on: ubuntu-latest
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
# Action tierce, donc epinglee sur un SHA de commit et pas sur un tag mobile : un tag se
# redeplace, et ce workflow tourne avec les droits du depot (regle Sonar githubactions:S7637).
- name: Installe Terraform
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: 1.16.3
terraform_wrapper: false
- name: Vérifie le formatage
run: terraform fmt -check -recursive infra/terraform
- name: Valide chaque racine
run: |
for racine in infra/terraform/environments/*/; do
echo "::group::${racine}"
terraform -chdir="${racine}" init -backend=false -input=false
terraform -chdir="${racine}" validate
echo "::endgroup::"
done
+100 -2
View File
@@ -8,10 +8,28 @@ on:
paths: paths:
- "ml/**" - "ml/**"
- ".github/workflows/ml.yml" - ".github/workflows/ml.yml"
# Le job `integration` monte son schema avec les migrations du backend et joue le test de
# chaine qui vit dans ses tests : sans ces chemins, une migration modifiee ne declencherait
# rien et le schema deriverait du SQL du pipeline sans que rien ne casse. Meme raisonnement
# que le filtre d'airflow.yml, qui inclut deja des chemins de ml/ et de apps/backend/.
- "apps/backend/alembic/**"
- "apps/backend/app/models/**"
- "apps/backend/tests/test_chaine_ml_api.py"
- "apps/backend/pyproject.toml"
- "apps/backend/uv.lock"
pull_request: pull_request:
paths: paths:
- "ml/**" - "ml/**"
- ".github/workflows/ml.yml" - ".github/workflows/ml.yml"
# Le job `integration` monte son schema avec les migrations du backend et joue le test de
# chaine qui vit dans ses tests : sans ces chemins, une migration modifiee ne declencherait
# rien et le schema deriverait du SQL du pipeline sans que rien ne casse. Meme raisonnement
# que le filtre d'airflow.yml, qui inclut deja des chemins de ml/ et de apps/backend/.
- "apps/backend/alembic/**"
- "apps/backend/app/models/**"
- "apps/backend/tests/test_chaine_ml_api.py"
- "apps/backend/pyproject.toml"
- "apps/backend/uv.lock"
permissions: permissions:
contents: read contents: read
@@ -53,11 +71,91 @@ jobs:
- name: Typage - name: Typage
run: uv run mypy enervision_ml tests run: uv run mypy enervision_ml tests
# Aucun test ne touche PostgreSQL ni MLflow distant : tout tourne sur donnees # Les tests exigeant une base portent le marqueur `integration`, ecarte par defaut et
# synthetiques ou un magasin SQLite local jetable (cf. ml/tests/test_train.py). # joue par le job `integration` ci-dessous.
- name: Tests - name: Tests
run: uv run pytest run: uv run pytest
# Le seul job du depot qui dispose a la fois des deux environnements uv et d'une base. Piege :
# le schema de la base ML est celui du backend (apps/backend/alembic, proprietaire du schema).
# Le reconstruire ici a la main rendrait ce job vert sur une base qui n'est pas la notre.
integration:
name: ML - DB et chaîne ML - DB - API
runs-on: ubuntu-latest
services:
db:
image: timescale/timescaledb-ha:pg17
env:
POSTGRES_USER: enervision
POSTGRES_PASSWORD: change_me
POSTGRES_DB: enervision_test
ports:
- "5433:5432"
options: >-
--health-cmd "pg_isready -U enervision -d enervision_test"
--health-interval 10s
--health-timeout 5s
--health-retries 12
--health-start-period 40s
env:
# Deux variables, deux dialectes : Alembic et l'API parlent asyncpg, le pipeline ML parle
# psycopg en synchrone. Cf. docs/ML-START.md, section 1.
DATABASE_URL: postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test
ML_DATABASE_URL: postgresql+psycopg://enervision:change_me@localhost:5433/enervision_test
APP_SECRET_KEY: secret-de-test-assez-long-pour-le-validateur
PGPASSWORD: change_me
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: |
ml/uv.lock
apps/backend/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
working-directory: ml
run: uv python install
- name: Synchronise le pipeline ML sans dévier du verrou
working-directory: ml
run: uv sync --all-groups --frozen
# Le backend est installé ici parce qu'il porte les migrations, seule source du schéma, et
# le test de chaîne, qui interroge l'API.
- name: Synchronise le backend sans dévier du verrou
working-directory: apps/backend
run: uv sync --all-groups --frozen
# 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.
- name: Active TimescaleDB sur la base de test
run: psql -h localhost -p 5433 -U enervision -d enervision_test -c "CREATE EXTENSION IF NOT EXISTS timescaledb"
- name: Applique les migrations du backend, propriétaire du schéma
working-directory: apps/backend
run: uv run alembic upgrade head
# `-m` en ligne de commande écrase celui d'addopts. Couverture désactivée : ce job ne joue
# qu'une partie de la suite, son taux n'aurait pas de sens (même raison que backend.yml).
- name: Tests ML exigeant une base
working-directory: ml
run: uv run pytest -m integration --no-cov
# Lance les vrais binaires enervision_ml.train et .score en sous-processus, comme les DAGs
# ml_train et ml_score, puis relit le résultat par GET /api/v1/predictions.
- name: Chaîne complète ML vers DB vers API
working-directory: apps/backend
env:
ML_PYTHON: ${{ github.workspace }}/ml/.venv/bin/python
run: uv run pytest -m chaine --no-cov
sast: sast:
name: Analyse statique de sécurité name: Analyse statique de sécurité
runs-on: ubuntu-latest runs-on: ubuntu-latest
+2 -1
View File
@@ -63,7 +63,8 @@ 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/
+34 -3
View File
@@ -20,11 +20,20 @@ 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)
export ML_DATABASE_URL export ML_DATABASE_URL
# Piege : la base des tests d'integration n'est pas la base de developpement. Ces tests ecrivent
# et suppriment des lignes, et leurs fixtures refusent de demarrer ailleurs que sur
# `enervision_test` (garde sur le nom, cf. ml/tests/conftest.py).
PG_TEST_DB ?= enervision_test
TEST_DATABASE_URL ?= postgresql+asyncpg://$(PG_USER):$(PG_PASSWORD)@localhost:$(PG_PORT)/$(PG_TEST_DB)
ML_TEST_DATABASE_URL ?= postgresql+psycopg://$(PG_USER):$(PG_PASSWORD)@localhost:$(PG_PORT)/$(PG_TEST_DB)
# 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
@@ -32,10 +41,11 @@ DEMO_NOW ?= 2024-12-31T00:00:00Z
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
.PHONY: help install install-backend install-frontend install-ml install-airflow \ .PHONY: help install install-backend install-frontend install-ml install-airflow \
dev dev-backend dev-frontend \ dev dev-backend dev-frontend \
lint format typecheck test test-cov test-integration check \ lint format typecheck test test-cov test-integration ml-test-integration \
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 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 stack-up stack-down stack-logs
@@ -112,12 +122,30 @@ ml-test: ## Exécute les tests du pipeline ML (donnees synthetiques, sans base n
ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du pipeline ML ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du pipeline ML
# La cible surcharge ML_DATABASE_URL, que ce Makefile exporte vers la base de développement : la
# garde du conftest ferait échouer la cible sans cette surcharge.
ml-test-integration: ML_DATABASE_URL := $(ML_TEST_DATABASE_URL)
ml-test-integration: ## Tests ML exigeant une base migrée. Faire `make db-up migrate-test` avant
cd $(ML) && uv run pytest -m integration --no-cov
test-chaine: ## Chaîne ML -> DB -> API, vrais binaires. Exige les deux environnements uv
cd $(BACKEND) && DATABASE_URL=$(TEST_DATABASE_URL) ML_PYTHON=$(CURDIR)/$(ML)/.venv/bin/python \
uv run pytest -m chaine --no-cov
ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DATABASE_URL ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DATABASE_URL
cd $(ML) && uv run python -m enervision_ml.train $(if $(CSV),--csv $(CSV),) cd $(ML) && uv run python -m enervision_ml.train $(if $(CSV),--csv $(CSV),)
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),)
@@ -209,6 +237,9 @@ db-ensure-airflow: ## Crée la base de métadonnées Airflow si le volume pgdata
migrate: ## Applique les migrations Alembic migrate: ## Applique les migrations Alembic
cd $(BACKEND) && uv run alembic upgrade head cd $(BACKEND) && uv run alembic upgrade head
migrate-test: ## Applique les migrations sur enervision_test, la base des tests d'intégration
cd $(BACKEND) && DATABASE_URL=$(TEST_DATABASE_URL) uv run alembic upgrade head
bootstrap-admin: ## Crée le premier administrateur, mot de passe saisi au clavier bootstrap-admin: ## Crée le premier administrateur, mot de passe saisi au clavier
cd $(BACKEND) && uv run python -m app.cli create-admin --email $${EMAIL:?EMAIL=... requis} cd $(BACKEND) && uv run python -m app.cli create-admin --email $${EMAIL:?EMAIL=... requis}
+2 -2
View File
@@ -23,7 +23,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
| Backend | FastAPI, Python 3.14 | `apps/backend` | En place | | Backend | FastAPI, Python 3.14 | `apps/backend` | En place |
| Frontend | Angular 22, Node 26 | `apps/frontend` | En place | | Frontend | Angular 22, Node 26 | `apps/frontend` | En place |
| Base | PostgreSQL 17 + TimescaleDB | `db` | En place | | Base | PostgreSQL 17 + TimescaleDB | `db` | En place |
| ETL | Apache Airflow | `etl/airflow` | Quatre DAGs | | ETL | Apache Airflow | `etl/airflow` | Cinq DAGs |
| 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 |
@@ -50,7 +50,7 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
│ ├── migrations/ Migrations SQL versionnees │ ├── migrations/ Migrations SQL versionnees
│ └── seeds/ Jeux de donnees de reference │ └── seeds/ Jeux de donnees de reference
├── etl/airflow/ ├── etl/airflow/
│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, import historique) │ ├── 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
+8 -4
View File
@@ -1,3 +1,8 @@
# syntax=docker/dockerfile:1
FROM docker
COPY --from=docker/buildx-bin /buildx /usr/libexec/docker/cli-plugins/docker-buildx
RUN docker buildx version
FROM python:3.14-slim AS builder FROM python:3.14-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /uvx /bin/ COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /uvx /bin/
@@ -8,10 +13,8 @@ ENV UV_COMPILE_BYTECODE=1 \
WORKDIR /app WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \ COPY uv.lock pyproject.toml /app/
--mount=type=bind,source=uv.lock,target=uv.lock \ RUN uv sync --locked --no-install-project --no-dev
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev --no-build
# Le projet lui-meme n'est pas installe (pas de second `uv sync`) : il tourne depuis /app, le # Le projet lui-meme n'est pas installe (pas de second `uv sync`) : il tourne depuis /app, le
# repertoire de travail, et rien ne lit ses metadonnees. L'installer imposerait de le construire # repertoire de travail, et rien ne lit ses metadonnees. L'installer imposerait de le construire
@@ -19,6 +22,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# l'installation des dependances n'execute aucun script de build (regle Sonar docker:S8541). # l'installation des dependances n'execute aucun script de build (regle Sonar docker:S8541).
COPY . /app COPY . /app
RUN uv sync --locked --no-dev
FROM python:3.14-slim AS runtime FROM python:3.14-slim AS runtime
@@ -0,0 +1,81 @@
"""rapports de derive du modele de prevision
Revision ID: d3f1a2b7c904
Revises: c0adab96238c
Create Date: 2026-09-22 14:40:00.000000
`site_id` est nullable, et c'est le coeur du schema : une ligne par site, plus une ligne
globale tous sites confondus, que `NULL` designe. Un seul site qui derive est invisible dans
une moyenne d'ensemble, et une derive d'ensemble sans rupture par site signale un changement
de modele ou de saison, pas une panne.
L'unicite passe par un index a `coalesce` et non par une `UniqueConstraint` : deux lignes
globales successives ont toutes deux `site_id` a NULL, et NULL n'est egal a aucune valeur, pas
meme a lui-meme. Meme forme que `uq_reading_source`.
Les trois `CHECK` sont portees par la base, comme `ck_prediction_status` : un verdict sans
motif, ou un statut inconnu, ne doit pas dependre de la vigilance de l'appelant.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "d3f1a2b7c904"
down_revision: str | Sequence[str] | None = "c0adab96238c"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"drift_report",
sa.Column("drift_report_id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column(
"computed_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("site_id", sa.Text(), nullable=True),
sa.Column("window_start", sa.DateTime(timezone=True), nullable=False),
sa.Column("window_end", sa.DateTime(timezone=True), nullable=False),
sa.Column("reference_start", sa.DateTime(timezone=True), nullable=True),
sa.Column("reference_end", sa.DateTime(timezone=True), nullable=True),
sa.Column("n_observations", sa.Integer(), nullable=False),
sa.Column("mae", sa.Double(), nullable=True),
sa.Column("mape", sa.Double(), nullable=True),
sa.Column("bias", sa.Double(), nullable=True),
sa.Column("reference_mae", sa.Double(), nullable=True),
sa.Column("coverage_ratio", sa.Double(), nullable=True),
sa.Column("insufficient_data_ratio", sa.Double(), nullable=True),
sa.Column("model_references", postgresql.ARRAY(sa.Text()), nullable=False),
sa.Column("status", sa.Text(), nullable=False),
sa.Column("reason", sa.Text(), nullable=True),
sa.CheckConstraint(
"status IN ('stable', 'derive', 'indetermine')", name="ck_drift_report_status"
),
sa.CheckConstraint(
"status = 'stable' OR reason IS NOT NULL", name="ck_drift_report_reason"
),
sa.CheckConstraint("n_observations >= 0", name="ck_drift_report_observations"),
sa.ForeignKeyConstraint(
["site_id"], ["site.site_id"], name="fk_drift_report_site", ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("drift_report_id"),
)
op.create_index(
"ix_drift_report_site_computed", "drift_report", ["site_id", "computed_at"], unique=False
)
op.create_index(
"uq_drift_report_window",
"drift_report",
["window_end", sa.literal_column("coalesce(site_id, '')")],
unique=True,
)
def downgrade() -> None:
op.drop_table("drift_report")
+12 -1
View File
@@ -24,6 +24,7 @@ from app.core.security import decode_access_token as decode_token
from app.db.session import get_session from app.db.session import get_session
from app.repositories.alert import AlertRepository from app.repositories.alert import AlertRepository
from app.repositories.audit_log import AuditLogRepository from app.repositories.audit_log import AuditLogRepository
from app.repositories.drift import DriftRepository
from app.repositories.login_attempt import LoginAttemptRepository from app.repositories.login_attempt import LoginAttemptRepository
from app.repositories.password_reset_attempt import PasswordResetAttemptRepository from app.repositories.password_reset_attempt import PasswordResetAttemptRepository
from app.repositories.password_reset_token import PasswordResetTokenRepository from app.repositories.password_reset_token import PasswordResetTokenRepository
@@ -35,6 +36,7 @@ from app.repositories.site import SiteRepository
from app.repositories.user import UserRepository from app.repositories.user import UserRepository
from app.services.alert import AlertService from app.services.alert import AlertService
from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy
from app.services.drift import DriftService
from app.services.prediction import PredictionService from app.services.prediction import PredictionService
from app.services.reading import ReadingService from app.services.reading import ReadingService
from app.services.recommendation import RecommendationService from app.services.recommendation import RecommendationService
@@ -48,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)]
@@ -232,6 +236,13 @@ def get_prediction_service(session: SessionDep) -> PredictionService:
PredictionServiceDep = Annotated[PredictionService, Depends(get_prediction_service)] PredictionServiceDep = Annotated[PredictionService, Depends(get_prediction_service)]
def get_drift_service(session: SessionDep) -> DriftService:
return DriftService(DriftRepository(session))
DriftServiceDep = Annotated[DriftService, Depends(get_drift_service)]
async def get_current_principal( async def get_current_principal(
credentials: CredentialsDep, credentials: CredentialsDep,
session: SessionDep, session: SessionDep,
+22 -1
View File
@@ -90,11 +90,21 @@ TAGS: Final[list[dict[str, Any]]] = [
"de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`." "de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`."
), ),
}, },
{
"name": "monitoring",
"description": (
"Surveillance de la dérive du modèle : écart entre les prévisions déjà écrites et "
"les lectures réellement arrivées, par site et tous sites confondus. Réservé à "
"partir du rôle `operateur`, qui agit sur un pipeline dégradé."
),
},
] ]
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 "
@@ -153,6 +163,17 @@ REPONSES_ADMIN: Final[Reponses] = {
}, },
} }
REPONSES_OPERATEUR: Final[Reponses] = {
**REPONSES_AUTHENTIFIEES,
403: {
"model": ErrorResponse,
"description": (
"Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut "
"`password_change_required`."
),
},
}
# `lecteur` est le rôle minimum : `require_role` n'y refuse jamais un 403 pour droits # `lecteur` est le rôle minimum : `require_role` n'y refuse jamais un 403 pour droits
# insuffisants, seulement pour le mot de passe provisoire. # insuffisants, seulement pour le mot de passe provisoire.
REPONSES_LECTEUR: Final[Reponses] = { REPONSES_LECTEUR: Final[Reponses] = {
@@ -0,0 +1,20 @@
from fastapi import APIRouter
from app.api.deps import DriftServiceDep, OperateurDep
from app.api.openapi import REPONSE_VALIDATION
from app.schemas.drift import DriftReportResponse
router = APIRouter()
@router.get(
"/drift",
response_model=list[DriftReportResponse],
summary="Dernier rapport de dérive par site, plus la ligne globale",
responses=REPONSE_VALIDATION,
)
async def get_drift(
_: OperateurDep, service: DriftServiceDep, site_id: str | None = None
) -> list[DriftReportResponse]:
rapports = await service.derniers(site_id=site_id)
return [DriftReportResponse.model_validate(rapport) for rapport in rapports]
+10 -1
View File
@@ -1,10 +1,16 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR from app.api.openapi import (
REPONSE_SERVEUR,
REPONSES_ADMIN,
REPONSES_LECTEUR,
REPONSES_OPERATEUR,
)
from app.api.v1.endpoints import ( from app.api.v1.endpoints import (
alerts, alerts,
auth, auth,
health, health,
monitoring,
predictions, predictions,
readings, readings,
recommendations, recommendations,
@@ -38,3 +44,6 @@ api_router.include_router(
api_router.include_router( api_router.include_router(
predictions.router, prefix="/predictions", tags=["predictions"], responses=REPONSES_LECTEUR predictions.router, prefix="/predictions", tags=["predictions"], responses=REPONSES_LECTEUR
) )
api_router.include_router(
monitoring.router, prefix="/monitoring", tags=["monitoring"], responses=REPONSES_OPERATEUR
)
+9 -5
View File
@@ -45,15 +45,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,
) )
+10 -1
View File
@@ -2,7 +2,15 @@
# --autogenerate`, qui générerait alors un drop de sa table. # --autogenerate`, qui générerait alors un drop de sa table.
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site from app.models.energy import (
Alert,
Dataset,
DriftReport,
Prediction,
Reading,
Recommendation,
Site,
)
from app.models.login_attempt import LoginAttempt from app.models.login_attempt import LoginAttempt
from app.models.password_reset_attempt import PasswordResetAttempt from app.models.password_reset_attempt import PasswordResetAttempt
from app.models.password_reset_token import PasswordResetToken from app.models.password_reset_token import PasswordResetToken
@@ -14,6 +22,7 @@ __all__ = [
"AppUser", "AppUser",
"AuditLog", "AuditLog",
"Dataset", "Dataset",
"DriftReport",
"LoginAttempt", "LoginAttempt",
"PasswordResetAttempt", "PasswordResetAttempt",
"PasswordResetToken", "PasswordResetToken",
+46
View File
@@ -208,3 +208,49 @@ class Recommendation(Base):
explanation: Mapped[str] = mapped_column(Text) explanation: Mapped[str] = mapped_column(Text)
rule_reference: Mapped[str] = mapped_column(Text) rule_reference: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class DriftReport(Base):
__tablename__ = "drift_report"
__table_args__ = (
CheckConstraint(
"status IN ('stable', 'derive', 'indetermine')", name="ck_drift_report_status"
),
CheckConstraint("status = 'stable' OR reason IS NOT NULL", name="ck_drift_report_reason"),
CheckConstraint("n_observations >= 0", name="ck_drift_report_observations"),
Index("ix_drift_report_site_computed", "site_id", "computed_at"),
)
drift_report_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
computed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
# `NULL` porte la ligne globale, tous sites confondus : une derive d'ensemble et la derive
# d'un seul site ne se lisent pas dans le meme chiffre.
site_id: Mapped[str | None] = mapped_column(
Text, ForeignKey("site.site_id", name="fk_drift_report_site", ondelete="RESTRICT")
)
window_start: Mapped[datetime] = mapped_column(DateTime(timezone=True))
window_end: Mapped[datetime] = mapped_column(DateTime(timezone=True))
reference_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
reference_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
n_observations: Mapped[int] = mapped_column(Integer)
mae: Mapped[float | None] = mapped_column(Double)
mape: Mapped[float | None] = mapped_column(Double)
bias: Mapped[float | None] = mapped_column(Double)
reference_mae: Mapped[float | None] = mapped_column(Double)
coverage_ratio: Mapped[float | None] = mapped_column(Double)
insufficient_data_ratio: Mapped[float | None] = mapped_column(Double)
model_references: Mapped[list[str]] = mapped_column(ARRAY(Text))
status: Mapped[str] = mapped_column(Text)
reason: Mapped[str | None] = mapped_column(Text)
# Piège : une `UniqueConstraint` ne dédoublonnerait pas les lignes globales, dont `site_id` est
# NULL et qu'aucune n'est égale à une autre. Même forme que `uq_reading_source`.
Index(
"uq_drift_report_window",
DriftReport.window_end,
func.coalesce(DriftReport.site_id, text("''")),
unique=True,
)
+115
View File
@@ -0,0 +1,115 @@
# Surveillance de dérive du modèle de prévision (EC06, issue #45) : même gabarit que
# `app.detection.internal_alerts`, ordonnancé par le DAG `derive`.
from __future__ import annotations
import argparse
import asyncio
import sys
from datetime import UTC, datetime, timedelta
from app.core.config import get_settings
from app.db.session import get_session_factory
from app.repositories.drift import DriftRepository, NouveauRapportDerive
from app.services.drift import STATUT_DERIVE, DriftService, Seuils
async def run_drift(
*, now: datetime | None = None, site_id: str | None = None, seuils: Seuils | None = None
) -> list[NouveauRapportDerive]:
"""Calcule les rapports de la fenêtre et les enregistre. Rend ce qui a été calculé, que la
ligne ait été écrite ou ignorée par l'index d'idempotence."""
async with get_session_factory()() as session:
depot = DriftRepository(session)
rapports = await DriftService(depot, seuils=seuils).evaluate(now=now, site_id=site_id)
await depot.enregistre(rapports)
await session.commit()
return rapports
def _parse_instant(valeur: str) -> datetime:
instant = datetime.fromisoformat(valeur)
return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
defauts = Seuils()
parser = argparse.ArgumentParser(
prog="python -m app.monitoring.drift",
description="Surveillance de dérive du modèle de prévision EnerVision",
)
parser.add_argument("--site-id", default=None, help="Limite le calcul à un seul site.")
parser.add_argument(
"--now",
type=_parse_instant,
default=None,
help=(
"Instant de référence (ISO 8601, UTC si le fuseau est omis). Défaut : l'heure courante."
),
)
parser.add_argument(
"--window-hours",
type=int,
default=int(defauts.fenetre.total_seconds() // 3600),
help="Durée de la fenêtre récente, et de la fenêtre de référence qui la précède.",
)
parser.add_argument(
"--grace-hours",
type=int,
default=int(defauts.grace.total_seconds() // 3600),
help="Délai laissé à l'ingestion avant qu'une prévision soit jugée vérifiable.",
)
parser.add_argument(
"--min-observations",
type=int,
default=defauts.min_observations,
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(
"--fail-on-drift",
action="store_true",
help="Sort en code non nul si une dérive est constatée, pour que la tâche rougisse.",
)
return parser.parse_args(argv)
def seuils_depuis(args: argparse.Namespace) -> Seuils:
return Seuils(
fenetre=timedelta(hours=args.window_hours),
grace=timedelta(hours=args.grace_hours),
min_observations=args.min_observations,
seuil_biais=args.bias_threshold,
)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
# Échoue tôt si `APP_SECRET_KEY`/`DATABASE_URL` manquent, avant toute requête à la base.
get_settings()
rapports = asyncio.run(
run_drift(now=args.now, site_id=args.site_id, seuils=seuils_depuis(args))
)
for rapport in rapports:
cible = rapport.site_id or "TOUS SITES"
mae = f"{rapport.mae:.2f}" if rapport.mae is not None else "-"
print(
f"{cible} : {rapport.status}, MAE {mae} kWh sur {rapport.n_observations} prévision(s)"
f"{' : ' + rapport.reason if rapport.reason else ''}"
)
derive = any(rapport.status == STATUT_DERIVE for rapport in rapports)
return 1 if derive and args.fail_on_drift else 0
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
+184
View File
@@ -0,0 +1,184 @@
"""Piège : deux dédoublonnages, pas un - DriftRepository.paires()
`prediction` n'a pas d'unicité sur `(site_id, target_at)` : chaque run de scoring empile une
ligne de plus. `uq_reading_source` autorise de son côté deux lectures au même instant quand la
`source` diffère. Joindre les deux tables sans `DISTINCT ON` des deux côtés compterait donc la
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
`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.
"""
from collections.abc import Sequence
from dataclasses import asdict, dataclass
from datetime import datetime
from sqlalchemy import Subquery, func, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import DriftReport, Prediction, Reading
TARGET_METRIC = "consumption_kwh"
STATUT_DISPONIBLE = "available"
@dataclass(frozen=True, slots=True)
class PaireDerive:
site_id: str
target_at: datetime
predicted_value: float
actual_value: float
model_reference: str
@dataclass(frozen=True, slots=True)
class NouveauRapportDerive:
site_id: str | None
window_start: datetime
window_end: datetime
reference_start: datetime | None
reference_end: datetime | None
n_observations: int
mae: float | None
mape: float | None
bias: float | None
reference_mae: float | None
coverage_ratio: float | None
insufficient_data_ratio: float | None
model_references: list[str]
status: str
reason: str | None
@dataclass(frozen=True, slots=True)
class ComptageStatut:
site_id: str
status: str
nombre: int
def _predictions_retenues(*, debut: datetime, fin: datetime, site_id: str | None) -> Subquery:
requete = (
select(
Prediction.site_id,
Prediction.target_at,
Prediction.predicted_value,
Prediction.model_reference,
Prediction.status,
)
.distinct(Prediction.site_id, Prediction.target_at)
.where(
Prediction.target_metric == TARGET_METRIC,
Prediction.target_at >= debut,
Prediction.target_at < fin,
)
.order_by(Prediction.site_id, Prediction.target_at, Prediction.prediction_id.desc())
)
if site_id is not None:
requete = requete.where(Prediction.site_id == site_id)
return requete.subquery()
def _lectures_retenues(*, debut: datetime, fin: datetime, site_id: str | None) -> Subquery:
requete = (
select(Reading.site_id, Reading.timestamp, Reading.consumption_kwh)
.distinct(Reading.site_id, Reading.timestamp)
.where(
Reading.timestamp >= debut,
Reading.timestamp < fin,
Reading.consumption_kwh.is_not(None),
)
.order_by(Reading.site_id, Reading.timestamp, Reading.reading_id.desc())
)
if site_id is not None:
requete = requete.where(Reading.site_id == site_id)
return requete.subquery()
class DriftRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def paires(
self, *, debut: datetime, fin: datetime, site_id: str | None = None
) -> Sequence[PaireDerive]:
predictions = _predictions_retenues(debut=debut, fin=fin, site_id=site_id)
lectures = _lectures_retenues(debut=debut, fin=fin, site_id=site_id)
requete = (
select(
predictions.c.site_id,
predictions.c.target_at,
predictions.c.predicted_value,
lectures.c.consumption_kwh,
predictions.c.model_reference,
)
.select_from(predictions)
.join(
lectures,
(lectures.c.site_id == predictions.c.site_id)
& (lectures.c.timestamp == predictions.c.target_at),
)
.where(predictions.c.status == STATUT_DISPONIBLE)
.order_by(predictions.c.site_id, predictions.c.target_at)
)
lignes = await self._session.execute(requete)
return [
PaireDerive(
site_id=ligne[0],
target_at=ligne[1],
predicted_value=ligne[2],
actual_value=ligne[3],
model_reference=ligne[4],
)
for ligne in lignes
]
async def comptages(
self, *, debut: datetime, fin: datetime, site_id: str | None = None
) -> Sequence[ComptageStatut]:
predictions = _predictions_retenues(debut=debut, fin=fin, site_id=site_id)
requete = (
select(predictions.c.site_id, predictions.c.status, func.count())
.select_from(predictions)
.group_by(predictions.c.site_id, predictions.c.status)
)
lignes = await self._session.execute(requete)
return [
ComptageStatut(site_id=ligne[0], status=ligne[1], nombre=ligne[2]) for ligne in lignes
]
# Pourquoi : l'idempotence est déléguée à `uq_drift_report_window` plutôt qu'à une lecture
# préalable, comme pour les recommandations. Rejouer la commande sur la même fenêtre ne
# duplique donc rien.
async def enregistre(self, rapports: Sequence[NouveauRapportDerive]) -> int:
if not rapports:
return 0
valeurs = [asdict(rapport) for rapport in rapports]
requete = (
insert(DriftReport)
.values(valeurs)
.on_conflict_do_nothing(
index_elements=[DriftReport.window_end, func.coalesce(DriftReport.site_id, "")]
)
.returning(DriftReport.drift_report_id)
)
return len((await self._session.scalars(requete)).all())
async def derniers(self, *, site_id: str | None = None) -> Sequence[DriftReport]:
requete = (
select(DriftReport)
.distinct(DriftReport.site_id)
.order_by(
DriftReport.site_id,
DriftReport.computed_at.desc(),
DriftReport.drift_report_id.desc(),
)
)
if site_id is not None:
requete = requete.where(DriftReport.site_id == site_id)
return (await self._session.scalars(requete)).all()
+31
View File
@@ -0,0 +1,31 @@
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict
class DriftStatus(StrEnum):
STABLE = "stable"
DERIVE = "derive"
INDETERMINE = "indetermine"
class DriftReportResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
site_id: str | None
computed_at: datetime
window_start: datetime
window_end: datetime
reference_start: datetime | None
reference_end: datetime | None
n_observations: int
mae: float | None
mape: float | None
bias: float | None
reference_mae: float | None
coverage_ratio: float | None
insufficient_data_ratio: float | None
model_references: list[str]
status: DriftStatus
reason: str | None
+233
View File
@@ -0,0 +1,233 @@
"""Contrainte : la dérive se mesure sur ce qui a déjà eu lieu - DriftService.evaluate()
Une prévision ne devient vérifiable que quand la lecture de son instant cible est ingérée. La
fenêtre est donc fermée à droite par un délai de grâce : sans lui, la dernière heure ferait
chuter le taux de couverture à chaque exécution, et le verdict dirait « dérive » alors que
seule l'ingestion n'avait pas fini son tour.
La comparaison se fait entre deux fenêtres vives de même durée, pas contre la métrique de
référence du modèle journalisée à l'entraînement. Ce ne sont pas les mêmes grandeurs :
l'entraînement mesure un backtest où la météo de l'heure cible est connue, le scoring prévoit
une heure future dont la météo ne l'est pas. Les comparer classerait le modèle « en dérive »
dès le premier jour, ce qui ne prouverait rien.
"""
from collections.abc import Sequence
from dataclasses import dataclass, replace
from datetime import UTC, datetime, timedelta
from app.models.energy import DriftReport
from app.repositories.drift import (
ComptageStatut,
DriftRepository,
NouveauRapportDerive,
PaireDerive,
)
STATUT_STABLE = "stable"
STATUT_DERIVE = "derive"
STATUT_INDETERMINE = "indetermine"
STATUT_INSUFFISANT = "insufficient_data"
STATUT_DISPONIBLE = "available"
@dataclass(frozen=True, slots=True)
class Seuils:
# 168 h, la saisonnalité hebdomadaire que le modèle apprend par son lag principal : une
# fenêtre plus courte comparerait un week-end à une semaine ouvrée.
fenetre: timedelta = timedelta(hours=168)
grace: timedelta = timedelta(hours=2)
min_observations: int = 24
ratio_derive: float = 1.25
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_couverture: float = 0.8
@dataclass(frozen=True, slots=True)
class Metriques:
n_observations: int
mae: float | None
mape: float | None
bias: float | None
model_references: list[str]
def mesure(paires: Sequence[PaireDerive]) -> Metriques:
if not paires:
return Metriques(n_observations=0, mae=None, mape=None, bias=None, model_references=[])
ecarts = [paire.predicted_value - paire.actual_value for paire in paires]
# Le MAPE diverge sur une consommation nulle : les sites à l'arrêt sortent de ce seul
# rapport, jamais des autres métriques.
ratios = [
abs(ecart / paire.actual_value)
for ecart, paire in zip(ecarts, paires, strict=True)
if paire.actual_value != 0
]
return Metriques(
n_observations=len(paires),
mae=sum(abs(ecart) for ecart in ecarts) / len(ecarts),
mape=(sum(ratios) / len(ratios) * 100) if ratios else None,
bias=sum(ecarts) / len(ecarts),
model_references=sorted({paire.model_reference for paire in paires}),
)
@dataclass(frozen=True, slots=True)
class Verdict:
status: str
reason: str | None
class DriftService:
def __init__(self, depot: DriftRepository, *, seuils: Seuils | None = None) -> None:
self._depot = depot
self._seuils = seuils or Seuils()
async def derniers(self, *, site_id: str | None = None) -> Sequence[DriftReport]:
"""Ce que sert l'API : le dernier rapport de chaque site, plus la ligne globale."""
return await self._depot.derniers(site_id=site_id)
async def evaluate(
self, *, now: datetime | None = None, site_id: str | None = None
) -> list[NouveauRapportDerive]:
"""Une ligne par site, plus une ligne globale dont le `site_id` est nul."""
fin = (now or datetime.now(UTC)) - self._seuils.grace
debut = fin - self._seuils.fenetre
reference_fin = debut
reference_debut = reference_fin - self._seuils.fenetre
recentes = await self._depot.paires(debut=debut, fin=fin, site_id=site_id)
anciennes = await self._depot.paires(
debut=reference_debut, fin=reference_fin, site_id=site_id
)
comptages = await self._depot.comptages(debut=debut, fin=fin, site_id=site_id)
gabarit = NouveauRapportDerive(
site_id=None,
window_start=debut,
window_end=fin,
reference_start=reference_debut,
reference_end=reference_fin,
n_observations=0,
mae=None,
mape=None,
bias=None,
reference_mae=None,
coverage_ratio=None,
insufficient_data_ratio=None,
model_references=[],
status=STATUT_INDETERMINE,
reason=None,
)
rapports = [
self._rapport(
gabarit,
site=site,
recentes=[p for p in recentes 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],
)
for site in sorted(
{paire.site_id for paire in recentes} | {c.site_id for c in comptages}
)
]
rapports.append(
self._rapport(
gabarit, site=None, recentes=recentes, anciennes=anciennes, comptages=comptages
)
)
return rapports
def _rapport(
self,
gabarit: NouveauRapportDerive,
*,
site: str | None,
recentes: Sequence[PaireDerive],
anciennes: Sequence[PaireDerive],
comptages: Sequence[ComptageStatut],
) -> NouveauRapportDerive:
metriques = mesure(recentes)
reference = mesure(anciennes)
couverture = _couverture(len(recentes), comptages)
verdict = self._verdict(metriques, reference_mae=reference.mae, couverture=couverture)
return replace(
gabarit,
site_id=site,
n_observations=metriques.n_observations,
mae=metriques.mae,
mape=metriques.mape,
bias=metriques.bias,
reference_mae=reference.mae,
coverage_ratio=couverture,
insufficient_data_ratio=_part_insuffisante(comptages),
model_references=metriques.model_references,
status=verdict.status,
reason=verdict.reason,
)
def _verdict(
self, metriques: Metriques, *, reference_mae: float | None, couverture: float | None
) -> Verdict:
seuils = self._seuils
if metriques.n_observations < seuils.min_observations:
return Verdict(
STATUT_INDETERMINE,
f"{metriques.n_observations} prévision(s) vérifiée(s) sur la fenêtre, "
f"minimum {seuils.min_observations}.",
)
if couverture is not None and couverture < seuils.seuil_couverture:
return Verdict(
STATUT_DERIVE,
f"Couverture de {couverture:.0%}, sous le seuil de {seuils.seuil_couverture:.0%} : "
"le pipeline, pas le modèle.",
)
plafond = _plafond(reference_mae, ratio=seuils.ratio_derive, plancher=seuils.mae_plancher)
if metriques.mae is not None and plafond is not None and metriques.mae > plafond:
return Verdict(
STATUT_DERIVE,
f"MAE de {metriques.mae:.2f} kWh au-delà de {plafond:.2f} kWh, "
"seuil dérivé de la fenêtre de référence.",
)
if (
seuils.seuil_biais > 0
and metriques.bias is not None
and abs(metriques.bias) > seuils.seuil_biais
):
return Verdict(
STATUT_DERIVE,
f"Biais de {metriques.bias:+.2f} kWh : le modèle se trompe toujours du même côté.",
)
return Verdict(STATUT_STABLE, None)
def _plafond(reference_mae: float | None, *, ratio: float, plancher: float) -> float | None:
if reference_mae is None:
return plancher or None
return max(plancher, reference_mae * ratio)
def _couverture(apparie: int, comptages: Sequence[ComptageStatut]) -> float | None:
"""Part des prévisions disponibles qui ont trouvé leur réalisé. Mesure l'ingestion et
l'ordonnancement, pas la qualité du modèle."""
disponibles = sum(c.nombre for c in comptages if c.status == STATUT_DISPONIBLE)
return apparie / disponibles if disponibles else None
def _part_insuffisante(comptages: Sequence[ComptageStatut]) -> float | None:
total = sum(c.nombre for c in comptages)
if not total:
return None
return sum(c.nombre for c in comptages if c.status == STATUT_INSUFFISANT) / total
+288 -22
View File
@@ -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,10 +1855,98 @@
}, },
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
] ]
} }
},
"/api/v1/monitoring/drift": {
"get": {
"tags": [
"monitoring"
],
"summary": "Dernier rapport de dérive par site, plus la ligne globale",
"operationId": "get_drift_api_v1_monitoring_drift_get",
"security": [
{
"JetonAcces": []
}
],
"parameters": [
{
"name": "site_id",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Site Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DriftReportResponse"
},
"title": "Response Get Drift Api V1 Monitoring Drift Get"
}
}
}
},
"500": {
"description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InternalErrorResponse"
}
}
}
},
"401": {
"description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"403": {
"description": "Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut `password_change_required`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"422": {
"description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationErrorResponse"
}
}
}
}
}
}
} }
}, },
"components": { "components": {
@@ -1977,6 +2065,180 @@
], ],
"title": "AlertType" "title": "AlertType"
}, },
"DriftReportResponse": {
"properties": {
"site_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Site Id"
},
"computed_at": {
"type": "string",
"format": "date-time",
"title": "Computed At"
},
"window_start": {
"type": "string",
"format": "date-time",
"title": "Window Start"
},
"window_end": {
"type": "string",
"format": "date-time",
"title": "Window End"
},
"reference_start": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Reference Start"
},
"reference_end": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Reference End"
},
"n_observations": {
"type": "integer",
"title": "N Observations"
},
"mae": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Mae"
},
"mape": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Mape"
},
"bias": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Bias"
},
"reference_mae": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Reference Mae"
},
"coverage_ratio": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Coverage Ratio"
},
"insufficient_data_ratio": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Insufficient Data Ratio"
},
"model_references": {
"items": {
"type": "string"
},
"type": "array",
"title": "Model References"
},
"status": {
"$ref": "#/components/schemas/DriftStatus"
},
"reason": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Reason"
}
},
"type": "object",
"required": [
"site_id",
"computed_at",
"window_start",
"window_end",
"reference_start",
"reference_end",
"n_observations",
"mae",
"mape",
"bias",
"reference_mae",
"coverage_ratio",
"insufficient_data_ratio",
"model_references",
"status",
"reason"
],
"title": "DriftReportResponse"
},
"DriftStatus": {
"type": "string",
"enum": [
"stable",
"derive",
"indetermine"
],
"title": "DriftStatus"
},
"ErrorResponse": { "ErrorResponse": {
"properties": { "properties": {
"detail": { "detail": {
@@ -3225,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"
} }
@@ -3277,6 +3539,10 @@
{ {
"name": "predictions", "name": "predictions",
"description": "Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`." "description": "Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`."
},
{
"name": "monitoring",
"description": "Surveillance de la dérive du modèle : écart entre les prévisions déjà écrites et les lectures réellement arrivées, par site et tous sites confondus. Réservé à partir du rôle `operateur`, qui agit sur un pipeline dégradé."
} }
] ]
} }
+5 -2
View File
@@ -87,8 +87,11 @@ disallow_untyped_defs = false
testpaths = ["tests"] testpaths = ["tests"]
asyncio_mode = "auto" asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function" asyncio_default_fixture_loop_scope = "function"
addopts = "-q --strict-markers -m 'not integration' --cov=app --cov-report=term-missing" addopts = "-q --strict-markers -m 'not integration and not chaine' --cov=app --cov-report=term-missing"
markers = ["integration: requiert une base PostgreSQL joignable, hors `make test`"] markers = [
"integration: requiert une base PostgreSQL joignable, hors `make test`",
"chaine: requiert en plus l'environnement uv de ml/, hors `make test` et hors `-m integration`",
]
[tool.coverage.run] [tool.coverage.run]
source = ["app"] source = ["app"]
+1
View File
@@ -56,6 +56,7 @@ ROLE_MINIMUM: Final[dict[Route, Role]] = {
("GET", "/api/v1/readings"): Role.LECTEUR, ("GET", "/api/v1/readings"): Role.LECTEUR,
("GET", "/api/v1/predictions"): Role.LECTEUR, ("GET", "/api/v1/predictions"): Role.LECTEUR,
("GET", "/api/v1/sensors/status"): Role.ADMIN, ("GET", "/api/v1/sensors/status"): Role.ADMIN,
("GET", "/api/v1/monitoring/drift"): Role.OPERATEUR,
("GET", "/api/v1/users"): Role.ADMIN, ("GET", "/api/v1/users"): Role.ADMIN,
("POST", "/api/v1/users"): Role.ADMIN, ("POST", "/api/v1/users"): Role.ADMIN,
("PATCH", "/api/v1/users/{user_id}"): Role.ADMIN, ("PATCH", "/api/v1/users/{user_id}"): Role.ADMIN,
+131
View File
@@ -0,0 +1,131 @@
"""Piège : ces fixtures valident leurs écritures, contrairement à celles de tests/repositories.
Un endpoint ouvre sa propre session par `get_session` : il ne verrait pas une ligne semée dans
une transaction en cours. Lui passer la session de la fixture par `dependency_overrides`
supprimerait justement ce que ces tests prouvent, et `RecommendationService.generate` valide de
toute façon lui-même. L'isolation vient donc de la marque portée par chaque `site_id`, et le
nettoyage est explicite, dans l'ordre imposé par les clés étrangères `RESTRICT`.
Contrainte : toutes ces fixtures sont à portée fonction. `engine_per_test` vide le cache du
moteur après chaque test ; une fixture de module verrait un moteur déjà fermé à son démontage,
et ses lignes resteraient en base.
"""
from collections.abc import AsyncIterator, Callable, Iterator
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
from fastapi import FastAPI
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_principal
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.db.session import get_session_factory
from app.models.energy import Alert, Prediction, Reading, Recommendation, Site
from tests.repositories.test_alert import creer_alerte
from tests.repositories.test_prediction import creer_prediction
from tests.repositories.test_reading import creer_lecture
from tests.repositories.test_site import creer as creer_site
INSTANT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
@dataclass(frozen=True)
class JeuMetier:
"""Identifiants seuls, jamais d'instance ORM : un attribut relu sur une session fermée
déclenche un `MissingGreenlet`."""
site_id: str
site_voisin: str
alert_id: int
prediction_id: int
instant: datetime
async def _supprime(session: AsyncSession, sites: list[str]) -> None:
# La suppression des recommandations est inconditionnelle : `POST /generate` en cree hors du
# controle de la fixture, et `alert` les retient par une cle etrangere `RESTRICT`.
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(Alert).where(Alert.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(Site).where(Site.site_id.in_(sites)))
await session.commit()
@pytest.fixture
def marque() -> str:
return uuid4().hex[:12]
@pytest.fixture
async def jeu_metier(marque: str) -> AsyncIterator[JeuMetier]:
"""Un site instrumenté, un site voisin, trois lectures horaires, une prédiction, une alerte.
Le voisin existe pour que les tests de filtre prouvent qu'ils écartent quelque chose.
"""
site_id = f"SITE-{marque}"
voisin = f"SITE-{marque}-VOISIN"
async with get_session_factory()() as session:
await creer_site(session, site_id=site_id, capacity_kw=100.0)
await creer_site(session, site_id=voisin, capacity_kw=100.0)
for decalage in range(3):
await creer_lecture(
session,
site_id=site_id,
timestamp=INSTANT - timedelta(hours=decalage),
consumption_kw=10.0 + decalage,
)
prediction = await creer_prediction(session, site_id=site_id, target_at=INSTANT)
alerte = await creer_alerte(session, site_id=site_id, timestamp=INSTANT)
jeu = JeuMetier(
site_id=site_id,
site_voisin=voisin,
alert_id=alerte.alert_id,
prediction_id=prediction.prediction_id,
instant=INSTANT,
)
await session.commit()
try:
yield jeu
finally:
async with get_session_factory()() as session:
await _supprime(session, [site_id, voisin])
@pytest.fixture
async def site_nu(marque: str) -> AsyncIterator[str]:
"""Un site sans lecture ni prédiction : le cas que seul un vrai `LEFT JOIN` distingue."""
site_id = f"SITE-{marque}-NU"
async with get_session_factory()() as session:
await creer_site(session, site_id=site_id, capacity_kw=100.0)
await session.commit()
try:
yield site_id
finally:
async with get_session_factory()() as session:
await _supprime(session, [site_id])
@pytest.fixture
def principal_injecte(app: FastAPI) -> Iterator[Callable[[Role], None]]:
def installe(role: Role = Role.LECTEUR) -> None:
app.dependency_overrides[get_current_principal] = lambda: Principal(
id=uuid4(),
email="parcours@enervision.fr",
role=role,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
yield installe
app.dependency_overrides.pop(get_current_principal, None)
+11 -9
View File
@@ -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
+99
View File
@@ -0,0 +1,99 @@
from collections.abc import Iterator, Sequence
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from app.api.deps import get_current_principal, get_drift_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.models.energy import DriftReport
INSTANT = datetime(2026, 9, 22, 12, tzinfo=UTC)
def operateur() -> Principal:
return Principal(
id=uuid4(),
email="operateur@enervision.fr",
role=Role.OPERATEUR,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
def rapport(*, site_id: str | None) -> DriftReport:
return DriftReport(
drift_report_id=1,
computed_at=INSTANT,
site_id=site_id,
window_start=INSTANT - timedelta(hours=168),
window_end=INSTANT,
reference_start=None,
reference_end=None,
n_observations=48,
mae=1.5,
mape=12.0,
bias=0.3,
reference_mae=1.2,
coverage_ratio=0.95,
insufficient_data_ratio=0.0,
model_references=["lightgbm-aaa"],
status="stable",
reason=None,
)
class FauxService:
def __init__(self, rapports: Sequence[DriftReport]) -> None:
self.rapports = list(rapports)
self.site_demande: str | None = None
async def derniers(self, *, site_id: str | None = None) -> Sequence[DriftReport]:
self.site_demande = site_id
return self.rapports
@pytest.fixture
def servi(app: FastAPI) -> Iterator[list[DriftReport]]:
rapports = [rapport(site_id="SITE001"), rapport(site_id=None)]
service = FauxService(rapports)
app.dependency_overrides[get_current_principal] = operateur
app.dependency_overrides[get_drift_service] = lambda: service
yield rapports
app.dependency_overrides.clear()
async def test_drift_returns_the_latest_report_of_every_site(
servi: list[DriftReport], client: AsyncClient
) -> None:
reponse = await client.get("/api/v1/monitoring/drift")
assert reponse.status_code == 200
assert [ligne["site_id"] for ligne in reponse.json()] == ["SITE001", None]
async def test_drift_exposes_the_metrics_of_the_stored_report(
servi: list[DriftReport], client: AsyncClient
) -> None:
reponse = await client.get("/api/v1/monitoring/drift")
premier = reponse.json()[0]
assert premier["status"] == "stable"
assert premier["mae"] == 1.5
assert premier["model_references"] == ["lightgbm-aaa"]
async def test_drift_returns_an_empty_list_when_no_report_exists(
app: FastAPI, client: AsyncClient
) -> None:
app.dependency_overrides[get_current_principal] = operateur
app.dependency_overrides[get_drift_service] = lambda: FauxService([])
reponse = await client.get("/api/v1/monitoring/drift")
assert reponse.status_code == 200
assert reponse.json() == []
app.dependency_overrides.clear()
+2 -2
View File
@@ -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:
@@ -0,0 +1,92 @@
from collections.abc import Callable
import pytest
from httpx import AsyncClient
from app.core.roles import Role
from tests.api.conftest import JeuMetier
pytestmark = pytest.mark.integration
async def genere(client: AsyncClient, site_id: str) -> dict[str, int]:
# Toujours borne a un site : sans `site_id`, le service examine toutes les alertes de la
# base, y compris celles d'un autre test, et le rapport cesse d'etre deterministe.
reponse = await client.post(f"/api/v1/recommendations/generate?site_id={site_id}")
assert reponse.status_code == 200
return dict(reponse.json())
async def test_generate_creates_a_recommendation_for_the_alert_of_the_requested_site(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.ADMIN)
rapport = await genere(client, jeu_metier.site_id)
assert rapport["alerts_examined"] == 1
assert rapport["recommendations_created"] >= 1
assert rapport["already_present"] == 0
async def test_generate_creates_nothing_more_when_it_runs_twice_on_the_same_alerts(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.ADMIN)
premier = await genere(client, jeu_metier.site_id)
second = await genere(client, jeu_metier.site_id)
assert second["recommendations_created"] == 0
assert second["already_present"] == premier["recommendations_created"]
async def test_generate_examines_no_alert_when_the_requested_site_has_none(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.ADMIN)
rapport = await genere(client, jeu_metier.site_voisin)
assert rapport["alerts_examined"] == 0
assert rapport["recommendations_created"] == 0
async def test_list_recommendations_returns_what_generate_persisted_in_another_session(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.ADMIN)
await genere(client, jeu_metier.site_id)
reponse = await client.get("/api/v1/recommendations")
assert reponse.status_code == 200
miennes = [r for r in reponse.json() if r["alert_id"] == jeu_metier.alert_id]
assert miennes != []
assert all(r["rule_reference"] for r in miennes)
async def test_get_recommendation_returns_the_row_created_by_generate(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.ADMIN)
await genere(client, jeu_metier.site_id)
liste = await client.get("/api/v1/recommendations")
creee = next(r for r in liste.json() if r["alert_id"] == jeu_metier.alert_id)
reponse = await client.get(f"/api/v1/recommendations/{creee['recommendation_id']}")
assert reponse.status_code == 200
assert reponse.json() == creee
async def test_get_recommendation_returns_404_when_the_identifier_is_unknown(
principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.LECTEUR)
reponse = await client.get("/api/v1/recommendations/9999999")
assert reponse.status_code == 404
assert reponse.json()["detail"] == "Recommandation introuvable"
@@ -0,0 +1,80 @@
from collections.abc import Callable
import pytest
from httpx import AsyncClient
from app.core.roles import Role
from app.db.session import get_session_factory
from tests.api.conftest import JeuMetier
from tests.repositories.test_reading import creer_lecture
pytestmark = pytest.mark.integration
async def test_list_sites_returns_the_seeded_site_with_its_stored_attributes(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.LECTEUR)
reponse = await client.get("/api/v1/sites")
assert reponse.status_code == 200
mien = next(site for site in reponse.json() if site["site_id"] == jeu_metier.site_id)
assert mien["capacity_kw"] == 100.0
assert mien["site_name"] == "Site de test"
async def test_get_site_returns_404_when_the_identifier_is_absent_from_the_database(
principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.LECTEUR)
reponse = await client.get("/api/v1/sites/SITE-JAMAIS-INSERE")
assert reponse.status_code == 404
assert reponse.json()["detail"] == "Site introuvable"
async def test_get_current_returns_the_most_recent_reading_when_several_hours_are_stored(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.LECTEUR)
reponse = await client.get(f"/api/v1/sites/{jeu_metier.site_id}/current")
assert reponse.status_code == 200
corps = reponse.json()
assert corps["consumption_kw"] == 10.0
assert corps["timestamp"].startswith("2026-09-16T12:00")
async def test_get_current_keeps_the_highest_reading_id_when_two_sources_share_the_timestamp(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.LECTEUR)
async with get_session_factory()() as session:
await creer_lecture(
session,
site_id=jeu_metier.site_id,
timestamp=jeu_metier.instant,
source="api_history",
consumption_kw=999.0,
)
await session.commit()
reponse = await client.get(f"/api/v1/sites/{jeu_metier.site_id}/current")
assert reponse.json()["consumption_kw"] == 999.0
async def test_get_current_reports_a_critical_quality_when_the_site_has_no_reading(
site_nu: str, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.LECTEUR)
reponse = await client.get(f"/api/v1/sites/{site_nu}/current")
assert reponse.status_code == 200
corps = reponse.json()
assert corps["timestamp"] is None
assert corps["data_quality"] == "critical"
+69 -2
View File
@@ -1,5 +1,5 @@
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@@ -9,7 +9,15 @@ from sqlalchemy.exc import IntegrityError
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.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site from app.models.energy import (
Alert,
Dataset,
DriftReport,
Prediction,
Reading,
Recommendation,
Site,
)
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
MOMENT = datetime(2024, 1, 1, tzinfo=UTC) MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
@@ -269,3 +277,62 @@ async def test_recommendation_is_unique_when_alert_and_rule_match(
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
async with savepoint: async with savepoint:
await data_connection.execute(statement) await data_connection.execute(statement)
def _rapport(**remplacements: object) -> dict[str, object]:
defauts: dict[str, object] = {
"site_id": None,
"window_start": MOMENT,
"window_end": MOMENT,
"n_observations": 12,
"model_references": ["lightgbm-aaa"],
"status": "stable",
"reason": None,
}
return {**defauts, **remplacements}
async def test_drift_report_rejects_an_unknown_status(data_connection: AsyncConnection) -> None:
statement = insert(DriftReport).values(**_rapport(status="douteux", reason="x"))
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError):
async with savepoint:
await data_connection.execute(statement)
async def test_drift_report_rejects_a_drift_without_a_reason(
data_connection: AsyncConnection,
) -> None:
statement = insert(DriftReport).values(**_rapport(status="derive"))
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError):
async with savepoint:
await data_connection.execute(statement)
async def test_drift_report_accepts_one_global_row_without_a_site(
data_connection: AsyncConnection,
) -> None:
identifiant = (
await data_connection.execute(
insert(DriftReport).values(**_rapport()).returning(DriftReport.drift_report_id)
)
).scalar_one()
assert identifiant is not None
async def test_drift_report_is_unique_when_window_and_site_match(
data_connection: AsyncConnection,
) -> None:
fenetre = MOMENT + timedelta(days=1)
statement = insert(DriftReport).values(**_rapport(window_end=fenetre))
await data_connection.execute(statement)
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError):
async with savepoint:
await data_connection.execute(statement)
@@ -283,6 +283,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:
@@ -0,0 +1,187 @@
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy.dialects import postgresql
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import ClauseElement
from app.repositories.drift import (
DriftRepository,
NouveauRapportDerive,
_lectures_retenues,
_predictions_retenues,
)
from tests.repositories.test_prediction import creer_prediction
from tests.repositories.test_reading import creer_lecture
from tests.repositories.test_site import creer as creer_site
DEBUT = datetime(2026, 9, 15, tzinfo=UTC)
FIN = datetime(2026, 9, 22, tzinfo=UTC)
CIBLE = datetime(2026, 9, 16, 12, tzinfo=UTC)
def sql(requete: ClauseElement) -> str:
return str(requete.compile(dialect=postgresql.dialect())) # type: ignore[no-untyped-call]
def rapport(**remplacements: object) -> NouveauRapportDerive:
defauts: dict[str, object] = {
"site_id": None,
"window_start": DEBUT,
"window_end": FIN,
"reference_start": None,
"reference_end": None,
"n_observations": 10,
"mae": 1.0,
"mape": 5.0,
"bias": 0.1,
"reference_mae": None,
"coverage_ratio": 1.0,
"insufficient_data_ratio": 0.0,
"model_references": ["lightgbm-aaa"],
"status": "stable",
"reason": None,
}
return NouveauRapportDerive(**{**defauts, **remplacements}) # type: ignore[arg-type]
def test_predictions_keep_one_row_per_site_and_target_in_sql() -> None:
requete = sql(_predictions_retenues(debut=DEBUT, fin=FIN, site_id=None).element)
assert "DISTINCT ON (prediction.site_id, prediction.target_at)" in requete
assert "prediction.prediction_id DESC" in requete
def test_readings_keep_one_row_per_site_and_instant_in_sql() -> None:
requete = sql(_lectures_retenues(debut=DEBUT, fin=FIN, site_id=None).element)
assert "DISTINCT ON (reading.site_id, reading.timestamp)" in requete
assert "reading.reading_id DESC" in requete
def test_predictions_restrict_themselves_to_the_requested_site_in_sql() -> None:
requete = sql(_predictions_retenues(debut=DEBUT, fin=FIN, site_id="SITE001").element)
assert requete.count("prediction.site_id = ") == 1
def test_readings_ignore_a_missing_consumption_in_sql() -> None:
requete = sql(_lectures_retenues(debut=DEBUT, fin=FIN, site_id=None).element)
assert "reading.consumption_kwh IS NOT NULL" in requete
@pytest.mark.integration
async def test_repository_pairs_a_prediction_with_the_reading_of_the_same_instant(
session: AsyncSession,
) -> None:
site = await creer_site(session)
await creer_prediction(session, site_id=site.site_id, target_at=CIBLE, predicted_value=12.0)
await creer_lecture(session, site_id=site.site_id, timestamp=CIBLE, consumption_kwh=10.0)
paires = await DriftRepository(session).paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
await session.rollback()
assert [(p.predicted_value, p.actual_value) for p in paires] == [(12.0, 10.0)]
@pytest.mark.integration
async def test_repository_keeps_the_latest_run_when_several_predictions_share_a_target(
session: AsyncSession,
) -> None:
site = await creer_site(session)
await creer_prediction(session, site_id=site.site_id, target_at=CIBLE, predicted_value=12.0)
await creer_prediction(session, site_id=site.site_id, target_at=CIBLE, predicted_value=99.0)
await creer_lecture(session, site_id=site.site_id, timestamp=CIBLE, consumption_kwh=10.0)
paires = await DriftRepository(session).paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
await session.rollback()
assert [p.predicted_value for p in paires] == [99.0]
@pytest.mark.integration
async def test_repository_keeps_one_reading_per_instant_when_two_sources_wrote_the_same_hour(
session: AsyncSession,
) -> None:
site = await creer_site(session)
await creer_prediction(session, site_id=site.site_id, target_at=CIBLE, predicted_value=12.0)
await creer_lecture(
session, site_id=site.site_id, timestamp=CIBLE, source="api_current", consumption_kwh=10.0
)
await creer_lecture(
session, site_id=site.site_id, timestamp=CIBLE, source="api_history", consumption_kwh=20.0
)
paires = await DriftRepository(session).paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
await session.rollback()
assert [p.actual_value for p in paires] == [20.0]
@pytest.mark.integration
async def test_repository_excludes_an_insufficient_data_prediction_from_the_pairs(
session: AsyncSession,
) -> None:
site = await creer_site(session)
await creer_prediction(
session,
site_id=site.site_id,
target_at=CIBLE,
predicted_value=None,
status="insufficient_data",
failure_reason="historique trop court",
)
await creer_lecture(session, site_id=site.site_id, timestamp=CIBLE, consumption_kwh=10.0)
depot = DriftRepository(session)
paires = await depot.paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
comptages = await depot.comptages(debut=DEBUT, fin=FIN, site_id=site.site_id)
await session.rollback()
assert paires == []
assert [(c.status, c.nombre) for c in comptages] == [("insufficient_data", 1)]
@pytest.mark.integration
async def test_repository_excludes_a_target_outside_the_window(session: AsyncSession) -> None:
site = await creer_site(session)
hors_fenetre = FIN + timedelta(hours=1)
await creer_prediction(
session, site_id=site.site_id, target_at=hors_fenetre, predicted_value=12.0
)
await creer_lecture(session, site_id=site.site_id, timestamp=hors_fenetre, consumption_kwh=10.0)
paires = await DriftRepository(session).paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
await session.rollback()
assert paires == []
@pytest.mark.integration
async def test_repository_reads_back_the_global_report_it_wrote(session: AsyncSession) -> None:
depot = DriftRepository(session)
fenetre = datetime(2035, 3, 1, tzinfo=UTC)
ecrites = await depot.enregistre([rapport(window_end=fenetre)])
derniers = await depot.derniers()
globaux = [r for r in derniers if r.site_id is None and r.window_end == fenetre]
await session.rollback()
assert ecrites == 1
assert len(globaux) == 1
@pytest.mark.integration
async def test_repository_ignores_a_second_report_for_the_same_window_and_site(
session: AsyncSession,
) -> None:
depot = DriftRepository(session)
fenetre = datetime(2035, 4, 1, tzinfo=UTC)
premiere = await depot.enregistre([rapport(window_end=fenetre)])
seconde = await depot.enregistre([rapport(window_end=fenetre, status="derive", reason="x")])
await session.rollback()
assert premiere == 1
assert seconde == 0
@@ -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", {}),
) )
+271
View File
@@ -0,0 +1,271 @@
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
import pytest
from app.repositories.drift import ComptageStatut, PaireDerive
from app.services.drift import (
STATUT_DERIVE,
STATUT_INDETERMINE,
STATUT_STABLE,
DriftService,
Seuils,
mesure,
)
INSTANT = datetime(2026, 9, 22, 12, 0, tzinfo=UTC)
def paire(
*, site_id: str = "SITE001", prevu: float, reel: float, reference: str = "lightgbm-aaa"
) -> PaireDerive:
return PaireDerive(
site_id=site_id,
target_at=INSTANT,
predicted_value=prevu,
actual_value=reel,
model_reference=reference,
)
def paires(
*, site_id: str = "SITE001", nombre: int, prevu: float, reel: float
) -> list[PaireDerive]:
return [paire(site_id=site_id, prevu=prevu, reel=reel) for _ in range(nombre)]
class FauxDepot:
def __init__(
self,
*,
recentes: Sequence[PaireDerive] = (),
anciennes: Sequence[PaireDerive] = (),
comptages: Sequence[ComptageStatut] = (),
) -> None:
self.recentes = list(recentes)
self.anciennes = list(anciennes)
self._comptages = list(comptages)
self.fenetres: list[tuple[datetime, datetime]] = []
async def paires(
self, *, debut: datetime, fin: datetime, site_id: str | None = None
) -> Sequence[PaireDerive]:
self.fenetres.append((debut, fin))
return self.recentes if len(self.fenetres) == 1 else self.anciennes
async def comptages(
self, *, debut: datetime, fin: datetime, site_id: str | None = None
) -> Sequence[ComptageStatut]:
return self._comptages
def service(depot: FauxDepot, **surcharges: object) -> DriftService:
return DriftService(depot, seuils=Seuils(**surcharges)) # type: ignore[arg-type]
def test_drift_averages_the_absolute_gap_between_forecast_and_actual() -> None:
metriques = mesure([paire(prevu=12.0, reel=10.0), paire(prevu=8.0, reel=10.0)])
assert metriques.mae == 2.0
assert metriques.n_observations == 2
def test_drift_computes_a_signed_bias_when_the_model_overforecasts() -> None:
metriques = mesure([paire(prevu=12.0, reel=10.0), paire(prevu=14.0, reel=10.0)])
assert metriques.bias == 3.0
def test_drift_computes_a_negative_bias_when_the_model_underforecasts() -> None:
metriques = mesure([paire(prevu=8.0, reel=10.0), paire(prevu=6.0, reel=10.0)])
assert metriques.bias == -3.0
def test_drift_excludes_a_zero_actual_from_the_mape_only() -> None:
metriques = mesure([paire(prevu=11.0, reel=10.0), paire(prevu=5.0, reel=0.0)])
assert metriques.mape == 10.0
assert metriques.n_observations == 2
assert metriques.mae == 3.0
def test_drift_reports_no_mape_when_every_actual_is_zero() -> None:
metriques = mesure([paire(prevu=1.0, reel=0.0)])
assert metriques.mape is None
def test_drift_lists_every_model_reference_seen_in_the_window() -> None:
metriques = mesure(
[paire(prevu=10.0, reel=10.0, reference="lightgbm-bbb"), paire(prevu=10.0, reel=10.0)]
)
assert metriques.model_references == ["lightgbm-aaa", "lightgbm-bbb"]
async def test_drift_reports_indetermine_when_the_window_holds_too_few_observations() -> None:
depot = FauxDepot(recentes=paires(nombre=3, prevu=10.0, reel=10.0))
rapports = await service(depot, min_observations=24).evaluate(now=INSTANT)
assert {rapport.status for rapport in rapports} == {STATUT_INDETERMINE}
assert all(rapport.reason for rapport in rapports)
async def test_drift_reports_derive_when_the_recent_mae_exceeds_the_reference_ratio() -> None:
depot = FauxDepot(
recentes=paires(nombre=30, prevu=14.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).evaluate(now=INSTANT)
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
assert global_.status == STATUT_DERIVE
assert global_.mae == 4.0
assert global_.reference_mae == 1.0
async def test_drift_reports_stable_when_the_recent_mae_stays_close_to_the_reference() -> None:
depot = FauxDepot(
recentes=paires(nombre=30, prevu=11.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).evaluate(now=INSTANT)
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
assert global_.status == STATUT_STABLE
assert global_.reason is None
async def test_drift_reports_derive_when_the_coverage_ratio_falls_under_the_threshold() -> None:
depot = FauxDepot(
recentes=paires(nombre=30, prevu=10.0, reel=10.0),
anciennes=paires(nombre=30, prevu=10.0, reel=10.0),
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=100)],
)
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_DERIVE
assert global_.coverage_ratio == 0.3
async def test_drift_reports_one_line_per_site_and_one_global_line() -> None:
depot = FauxDepot(
recentes=[
*paires(site_id="SITE001", nombre=12, prevu=10.0, reel=10.0),
*paires(site_id="SITE002", nombre=12, prevu=10.0, reel=10.0),
],
comptages=[
ComptageStatut(site_id="SITE001", status="available", nombre=12),
ComptageStatut(site_id="SITE002", status="available", nombre=12),
],
)
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
assert [rapport.site_id for rapport in rapports] == ["SITE001", "SITE002", None]
assert next(r for r in rapports if r.site_id is None).n_observations == 24
async def test_drift_measures_the_share_of_sites_left_without_enough_history() -> None:
depot = FauxDepot(
recentes=paires(nombre=30, prevu=10.0, reel=10.0),
comptages=[
ComptageStatut(site_id="SITE001", status="available", nombre=30),
ComptageStatut(site_id="SITE001", status="insufficient_data", nombre=10),
],
)
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
assert next(r for r in rapports if r.site_id is None).insufficient_data_ratio == 0.25
async def test_drift_closes_the_window_before_the_grace_delay() -> None:
depot = FauxDepot()
await service(depot, grace=timedelta(hours=2), fenetre=timedelta(hours=168)).evaluate(
now=INSTANT
)
recente, reference = depot.fenetres
assert recente[1] == INSTANT - timedelta(hours=2)
assert recente[0] == INSTANT - timedelta(hours=170)
assert reference[1] == recente[0]
@pytest.mark.parametrize(
("prevu", "attendu"),
[(10.0, STATUT_STABLE), (30.0, STATUT_DERIVE)],
ids=["mae_stable", "mae_triplee"],
)
async def test_drift_compares_the_recent_window_to_the_reference_one(
prevu: float, attendu: str
) -> None:
depot = FauxDepot(
recentes=paires(nombre=30, prevu=prevu, reel=10.0),
anciennes=paires(nombre=30, prevu=10.0, reel=10.0),
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
)
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
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 "")
+223
View File
@@ -0,0 +1,223 @@
"""Piege : ce fichier porte le marqueur `chaine`, pas `integration` - test_the_ml_binaries...()
Il lance les vrais binaires `enervision_ml.train` et `enervision_ml.score` dans l'environnement
uv de `ml/`, que le job `integration` de `backend.yml` n'installe pas. Un marqueur distinct evite
que ce job, et `make test`, ne le selectionnent et n'echouent faute de `ml/.venv`.
"""
import math
import os
import subprocess
from collections.abc import AsyncIterator, Iterator
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from functools import partial
from pathlib import Path
from typing import Any
from uuid import uuid4
import anyio
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from sqlalchemy import delete, insert, make_url
from app.api.deps import get_current_principal
from app.core.config import get_settings
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.db.session import get_session_factory
from app.models.energy import Prediction, Reading, Site
pytestmark = pytest.mark.chaine
RACINE = Path(__file__).resolve().parents[3]
ML = RACINE / "ml"
PYTHON_ML = Path(os.environ.get("ML_PYTHON", ML / ".venv" / "bin" / "python"))
HEURES_COMPLETES = 400
HEURES_INSUFFISANTES = 100
def lecteur() -> Principal:
return Principal(
id=uuid4(),
email="lecteur@enervision.fr",
role=Role.LECTEUR,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
def url_ml() -> str:
"""Derive la chaine du pipeline de celle du backend plutot que de la recopier : les deux
cotes visent ainsi la meme base, dans leur dialecte respectif."""
return (
make_url(get_settings().database_url)
.set(drivername="postgresql+psycopg")
.render_as_string(hide_password=False)
)
def lance_ml(module: str, *arguments: str, journal: Path) -> subprocess.CompletedProcess[str]:
if not PYTHON_ML.exists():
pytest.fail(
f"Environnement ml/ absent ({PYTHON_ML}). Lancer `cd ml && uv sync --all-groups`."
)
return subprocess.run( # noqa: S603 -- argv en liste, sans shell, binaire resolu dans le depot
[str(PYTHON_ML), "-m", module, *arguments],
cwd=ML,
text=True,
capture_output=True,
timeout=600,
check=False,
env={
**os.environ,
"ML_DATABASE_URL": url_ml(),
"MLFLOW_TRACKING_URI": f"sqlite:///{journal}/mlflow.db",
},
)
async def executer(module: str, *arguments: str, journal: Path) -> subprocess.CompletedProcess[str]:
resultat = await anyio.to_thread.run_sync(
partial(lance_ml, module, *arguments, journal=journal)
)
assert resultat.returncode == 0, resultat.stderr
return resultat
@dataclass
class Parc:
sites: list[str] = field(default_factory=list)
def lignes_horaires(site_id: str, *, heures: int, fin: datetime) -> list[dict[str, Any]]:
return [
{
"site_id": site_id,
"timestamp": fin - timedelta(hours=decalage),
"source": "api_history",
"consumption_kwh": 50.0 + math.sin(decalage / 12.0) * 10.0,
"temperature_celsius": 15.0,
"humidity_percent": 50.0,
"solar_irradiance_wm2": 0.0,
"is_working_hours": True,
"raw_data": {},
}
for decalage in reversed(range(heures))
]
@pytest.fixture
async def parc() -> AsyncIterator[Parc]:
"""Deux sites dotes d'un historique complet, un troisieme qui n'atteint pas le lag de 168 h.
Les ecritures sont validees : les binaires ML ouvrent leur propre connexion et ne verraient
pas une transaction en cours.
"""
fin = datetime.now(UTC).replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
marque = uuid4().hex[:12]
complets = [f"TEST-{marque}-A", f"TEST-{marque}-B"]
partiel = f"TEST-{marque}-C"
parc = Parc(sites=[*complets, partiel])
async with get_session_factory()() as session:
await session.execute(
insert(Site),
[
{
"site_id": site_id,
"site_name": f"Site {site_id}",
"site_type": "office",
"capacity_kw": 100.0,
}
for site_id in parc.sites
],
)
for site_id in complets:
await session.execute(
insert(Reading), lignes_horaires(site_id, heures=HEURES_COMPLETES, fin=fin)
)
await session.execute(
insert(Reading), lignes_horaires(partiel, heures=HEURES_INSUFFISANTES, fin=fin)
)
await session.commit()
try:
yield parc
finally:
async with get_session_factory()() as session:
await session.execute(delete(Prediction).where(Prediction.site_id.in_(parc.sites)))
await session.execute(delete(Reading).where(Reading.site_id.in_(parc.sites)))
await session.execute(delete(Site).where(Site.site_id.in_(parc.sites)))
await session.commit()
@pytest.fixture
def principal_lecteur(app: FastAPI) -> Iterator[None]:
app.dependency_overrides[get_current_principal] = lecteur
yield
app.dependency_overrides.pop(get_current_principal, None)
async def resume_du_site(client: AsyncClient, site_id: str) -> dict[str, Any]:
reponse = await client.get("/api/v1/predictions")
assert reponse.status_code == 200
sites = reponse.json()["sites"]
return next(site for site in sites if site["site_id"] == site_id)
async def entraine_et_score(parc: Parc, tmp_path: Path, *arguments: str) -> Path:
modele = tmp_path / "lightgbm-consumption.txt"
await executer(
"enervision_ml.train",
"--model-output",
str(modele),
"--mlflow-tracking-uri",
f"sqlite:///{tmp_path}/mlflow.db",
journal=tmp_path,
)
await executer("enervision_ml.score", "--model", str(modele), *arguments, journal=tmp_path)
return modele
async def test_the_ml_binaries_produce_a_prediction_that_the_api_serves(
parc: Parc, tmp_path: Path, client: AsyncClient, principal_lecteur: None
) -> None:
await entraine_et_score(parc, tmp_path)
servi = await resume_du_site(client, parc.sites[0])
assert servi["prediction"]["status"] == "available"
assert servi["prediction"]["predicted_value"] is not None
assert servi["prediction"]["target_metric"] == "consumption_kwh"
async def test_the_api_exposes_the_failure_reason_of_a_site_without_enough_history(
parc: Parc, tmp_path: Path, client: AsyncClient, principal_lecteur: None
) -> None:
await entraine_et_score(parc, tmp_path)
servi = await resume_du_site(client, parc.sites[-1])
assert servi["prediction"]["status"] == "insufficient_data"
assert servi["prediction"]["predicted_value"] is None
assert servi["prediction"]["failure_reason"] is not None
async def test_the_api_serves_the_latest_run_when_the_score_cli_runs_twice(
parc: Parc, tmp_path: Path, client: AsyncClient, principal_lecteur: None
) -> None:
modele = await entraine_et_score(parc, tmp_path)
premier = await resume_du_site(client, parc.sites[0])
await executer("enervision_ml.score", "--model", str(modele), journal=tmp_path)
second = await resume_du_site(client, parc.sites[0])
assert second["prediction"]["created_at"] >= premier["prediction"]["created_at"]
assert second["prediction"]["model_reference"] == premier["prediction"]["model_reference"]
+116
View File
@@ -0,0 +1,116 @@
from datetime import UTC, datetime, timedelta
import pytest
from app.monitoring import drift as cli
from app.repositories.drift import NouveauRapportDerive
from app.services.drift import STATUT_DERIVE, STATUT_STABLE, Seuils
INSTANT = datetime(2026, 9, 22, 12, tzinfo=UTC)
def rapport(*, site_id: str | None, status: str, reason: str | None = None) -> NouveauRapportDerive:
return NouveauRapportDerive(
site_id=site_id,
window_start=INSTANT - timedelta(hours=168),
window_end=INSTANT,
reference_start=None,
reference_end=None,
n_observations=48,
mae=1.5,
mape=12.0,
bias=0.3,
reference_mae=1.2,
coverage_ratio=1.0,
insufficient_data_ratio=0.0,
model_references=["lightgbm-aaa"],
status=status,
reason=reason,
)
def installe(monkeypatch: pytest.MonkeyPatch, rapports: list[NouveauRapportDerive]) -> None:
async def fausse_execution(
*, now: datetime | None, site_id: str | None, seuils: Seuils | None
) -> list[NouveauRapportDerive]:
return rapports
monkeypatch.setattr(cli, "run_drift", fausse_execution)
def test_parse_args_defaults_to_the_standard_window() -> None:
arguments = cli.parse_args([])
assert arguments.window_hours == 168
assert arguments.grace_hours == 2
assert arguments.fail_on_drift is False
def test_parse_args_reads_the_site_id() -> None:
assert cli.parse_args(["--site-id", "SITE001"]).site_id == "SITE001"
def test_parse_args_parses_the_instant_option() -> None:
arguments = cli.parse_args(["--now", "2026-09-22T12:00:00+00:00"])
assert arguments.now == INSTANT
def test_parse_instant_treats_a_naive_datetime_as_utc() -> None:
assert cli._parse_instant("2026-09-22T12:00:00") == INSTANT
def test_seuils_depuis_translates_the_hour_options_into_durations() -> None:
seuils = cli.seuils_depuis(cli.parse_args(["--window-hours", "24", "--grace-hours", "1"]))
assert seuils.fenetre == timedelta(hours=24)
assert seuils.grace == timedelta(hours=1)
def test_main_prints_the_verdict_of_every_line(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
installe(
monkeypatch,
[
rapport(site_id="SITE001", status=STATUT_STABLE),
rapport(site_id=None, status=STATUT_STABLE),
],
)
code = cli.main([])
sortie = capsys.readouterr().out
assert code == 0
assert "SITE001" in sortie
assert "TOUS SITES" in sortie
def test_main_exits_non_zero_when_drift_is_detected_and_the_flag_is_set(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
installe(monkeypatch, [rapport(site_id=None, status=STATUT_DERIVE, reason="MAE doublée")])
code = cli.main(["--fail-on-drift"])
assert code == 1
assert "MAE doublée" in capsys.readouterr().out
def test_main_exits_zero_when_drift_is_detected_without_the_flag(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
installe(monkeypatch, [rapport(site_id=None, status=STATUT_DERIVE, reason="MAE doublée")])
code = cli.main([])
assert code == 0
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
+12 -4
View File
@@ -26,20 +26,28 @@ RUN npm run build
FROM nginx:1.31-alpine AS runner FROM nginx:1.31-alpine AS runner
# Copie de la configuration de nginx # Copie de la configuration de nginx
COPY --chown=root:root --chmod=755 nginx.conf /etc/nginx/nginx.conf COPY --chown=root:root nginx.conf /etc/nginx/nginx.conf
RUN chmod 755 /etc/nginx/nginx.conf
# Copy the static build output from the build stage to Nginx's default HTML serving directory # Copy the static build output from the build stage to Nginx's default HTML serving directory
COPY --chown=root:root --chmod=755 --from=builder /app/dist/*/browser /usr/share/nginx/html COPY --chown=root:root --from=builder /app/dist/frontend/browser /usr/share/nginx/html
RUN chmod 755 /usr/share/nginx/html
# Create necessary directories with proper permissions for nginx # Create necessary directories with proper permissions for nginx
RUN mkdir -p /var/log/nginx /var/cache/nginx && \ RUN mkdir -p /var/log/nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx /var/cache/nginx /usr/share/nginx/html chown -R nginx:nginx /var/log/nginx /var/cache/nginx /usr/share/nginx/html &&\
chmod 755 /var/log/nginx /var/log
# Permissions appropriées
RUN find /usr/share/nginx/html -type f -exec chmod 644 {} \; && \
find /usr/share/nginx/html -type d -exec chmod 755 {} \; && \
chown -R nginx:nginx /var/log/nginx /var/cache/nginx
# Use a non-root user for security best practices # Use a non-root user for security best practices
USER nginx USER nginx
# Frontend : port 3000 # Frontend : port 3000
# Backend : port 8000
EXPOSE 3000 EXPOSE 3000
# Start Nginx directly with custom config # Start Nginx directly with custom config
+2 -3
View File
@@ -1,5 +1,5 @@
worker_processes auto; worker_processes auto;
error_log /var/log/nginx/error.log warn; error_log /dev/null warn; # ← Envoyer les logs vers /dev/null
pid /tmp/nginx.pid; pid /tmp/nginx.pid;
events { events {
@@ -9,15 +9,14 @@ events {
http { http {
include /etc/nginx/mime.types; include /etc/nginx/mime.types;
default_type application/octet-stream; default_type application/octet-stream;
access_log /dev/null; # ← Idem pour access logs
sendfile on; sendfile on;
keepalive_timeout 65; keepalive_timeout 65;
server { server {
listen 3000; listen 3000;
server_name _; server_name _;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
+1 -1
View File
@@ -4,7 +4,7 @@
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "node node_modules/@angular/cli/bin/ng.js build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test", "test": "ng test",
"test:ci": "ng test --watch=false" "test:ci": "ng test --watch=false"
+14 -4
View File
@@ -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
@@ -167,6 +169,14 @@ 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}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
+27 -7
View File
@@ -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
@@ -90,14 +90,25 @@ consommation prévue de **l'heure suivant sa dernière lecture connue**, et écr
### Ce que le run écrit, et ce qu'il n'écrase pas ### Ce que le run écrit, et ce qu'il n'écrase pas
La table `prediction` **n'a pas de contrainte d'unicité sur `(site_id, target_at)`** : chaque run La table `prediction` **n'a pas de contrainte d'unicité sur `(site_id, target_at)`** : chaque run
insère une ligne de plus au lieu d'écraser la précédente. C'est délibéré, et c'est ce qui rendra 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é, donc la surveillance de dérive (#44, #45), qui possible la comparaison prévision contre réalisé. La surveillance de dérive s'en sert : elle
n'existe pas encore. 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 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` ;
`insufficient_data` et `error` exigent l'inverse ; `target_metric` est bornée à `insufficient_data` et `error` exigent l'inverse ; `target_metric` est bornée à
`consumption_kwh` ou `consumption_kw`, et la forme énergie impose une `period_minutes`. `consumption_kwh` ou `consumption_kw`, et la forme énergie impose une `period_minutes`. Elles
sont vérifiées depuis le code qui écrit par `ml/tests/test_score_integration.py`, sur une vraie
base : un double ne prouverait rien d'une contrainte SQL.
**`--now` borne la fenêtre des deux côtés.** `load_recent_from_database` exige un `until` autant
qu'un `since`, et le scoring lui passe l'instant de référence. Sans cette borne haute,
`build_scoring_frame` repartait de la dernière lecture de toute la table quelle que soit la valeur
demandée : `target_at` valait toujours « fin du jeu + 1 h », et l'âge de la dernière lecture
devenait négatif sans franchir le seuil de péremption. Rejouer le scoring sur des instants passés
produit désormais des prévisions dont le réalisé existe déjà, ce dont la surveillance de dérive a
besoin pour se démontrer sur un jeu figé.
### `model_reference` est un hachage, pas un nom de fichier ### `model_reference` est un hachage, pas un nom de fichier
@@ -142,6 +153,10 @@ flowchart LR
train -- "models/*.txt + run MLflow" --> score train -- "models/*.txt + run MLflow" --> score
score -- "INSERT" --> prediction score -- "INSERT" --> prediction
prediction -- "lecture seule" --> route prediction -- "lecture seule" --> route
prediction -- "prévu" --> derive["app.monitoring.drift<br/>écart prévu / réalisé"]
reading -- "réalisé" --> derive
derive -- "INSERT" --> rapport[("drift_report")]
rapport -- "lecture seule" --> monitoring["GET /api/v1/monitoring/drift"]
``` ```
**La règle, en une phrase : FastAPI ne fait jamais tourner LightGBM.** **La règle, en une phrase : FastAPI ne fait jamais tourner LightGBM.**
@@ -163,8 +178,12 @@ flowchart LR
Le corollaire est qu'il n'y a **aucune prévision à la demande** : la fraîcheur d'une prévision est Le corollaire est qu'il n'y a **aucune prévision à la demande** : la fraîcheur d'une prévision est
celle du dernier run de scoring. Ce run est ordonnancé par Airflow, DAG `ml_score` en `@hourly` celle du dernier run de scoring. Ce run est ordonnancé par Airflow, DAG `ml_score` en `@hourly`
(issue #115) ; seuls le mode `--csv` et un lancement local restent manuels, tout comme (issue #115) ; seuls le mode `--csv` et un lancement local restent manuels, tout comme
l'entraînement, dont le DAG `ml_train` n'a pas de planification. La dette qui subsiste est la l'entraînement, dont le DAG `ml_train` n'a pas de planification.
surveillance de dérive, portée par les issues #44 et #45.
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
pas MLflow. Son calcul, son seuil et son refus de comparer à la métrique d'entraînement sont dans
l'[ADR 0013](adr/0013-surveillance-de-derive-dans-le-backend.md).
--- ---
@@ -175,3 +194,4 @@ surveillance de dérive, portée par les issues #44 et #45.
- [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 0013](adr/0013-surveillance-de-derive-dans-le-backend.md) : la surveillance de dérive
+4
View File
@@ -16,3 +16,7 @@
| [0007](adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose | | [0007](adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
| [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 |
| [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 |
@@ -76,6 +76,7 @@ secret.
à chaque push : plusieurs minutes par déploiement, acceptable pour la cadence du projet. à chaque push : plusieurs minutes par déploiement, acceptable pour la cadence du projet.
- `environments/prod` de Terraform reste vide. Le provisionnement de la machine est porté par - `environments/prod` de Terraform reste vide. Le provisionnement de la machine est porté par
`scripts/provision-host.sh`, que Terraform pourra appeler par `remote-exec` le jour où une `scripts/provision-host.sh`, que Terraform pourra appeler par `remote-exec` le jour où une
racine visant la VM existera. racine visant la VM existera. Cette racine existe depuis l'[ADR 0010](0010-terraform-provisionne-github-actions-deploie.md),
sous le nom `environments/vm-eni`, et `environments/prod` a disparu avec elle.
- L'image frontend quitte `dhi.io/nginx`, registre authentifié dont personne n'a l'accès, pour - L'image frontend quitte `dhi.io/nginx`, registre authentifié dont personne n'a l'accès, pour
`nginx:1.28-alpine`, la même image que le proxy. Elle n'avait jamais été construite. `nginx:1.28-alpine`, la même image que le proxy. Elle n'avait jamais été construite.
@@ -0,0 +1,77 @@
# 0010 - Terraform provisionne la machine, GitHub Actions déploie l'application
- Statut : accepté
- Date : 2026-09-22
## Contexte
L'[ADR 0009](0009-deux-environnements-compose-sur-la-vm-eni.md) a posé la livraison : deux
projets Compose sur la VM ENI, alignés sur `dev` et sur `main` par un runner auto-hébergé. Elle
ne dit pas qui prépare la machine. C'est `scripts/provision-host.sh`, lancé à la main en SSH.
La grille d'évaluation attend en C22 que l'infrastructure soit « provisionnée via du code
(Terraform, Ansible…) ». Le seul Terraform du dépôt installe un cluster k3s que rien ne
consomme, qui n'a jamais été appliqué, et dont la racine ne passait même pas `terraform init`
depuis que Terraform refuse les provisioners `destroy` dont la connexion lit autre chose que
`self`. Sa racine s'appelait `environments/dev`, nom qui laissait croire à un environnement
applicatif alors que les deux environnements réels sont `rec` et `prod`, sur la même machine.
La branche `feat/deploy` (PR #141) proposait la réponse inverse : Terraform construit les
images, lance les conteneurs et copie les sources par SSH. La revue a relevé deux racines sur
trois qui ne passent pas `terraform validate`, le mot de passe SSH écrit en clair dans le state,
un backend lancé sans base ni variables d'environnement, et trois architectures différentes pour
trois environnements.
## Décision
**Terraform provisionne la machine, GitHub Actions déploie l'application.** La frontière est
nette et vérifiable : `infra/terraform/environments/vm-eni` installe Docker et le plugin
Compose, exécute `scripts/provision-host.sh`, enregistre le runner. Il ne construit aucune
image, ne lance aucun conteneur, et un `apply` n'interrompt pas la stack qui tourne.
**Le déploiement continu ne change pas.** `deploy.yml` reste le seul chemin de livraison : push
sur `dev` ou `main`, alignement du clone, `make stack-up`, sonde `/api/v1/health/ready`.
**Le Bash reste la mécanique, Terraform devient le point d'entrée.** `provision-host.sh` connaît
les deux environnements, leurs ports décalés, leurs secrets et leurs certificats. Le réécrire en
HCL créerait une seconde source de vérité qui divergerait au premier changement de port.
**Aucun secret dans le state.** Authentification SSH par clé seulement, pas de variable de mot
de passe. Le jeton d'enregistrement du runner est une variable `sensitive` fournie à l'`apply`,
jamais un `trigger` : les `triggers` sont la seule partie d'un `null_resource` que Terraform
persiste.
**Les racines portent le nom de ce qu'elles provisionnent**, pas d'un environnement applicatif :
`vm-eni` pour la machine, `k3s-cible` pour le cluster resté en cible. `environments/prod`,
dossier vide, disparaît.
## Alternatives écartées
- **Ansible à la place du Bash** : plus idiomatique pour de la configuration de machine, et le
jury le reconnaîtrait immédiatement comme de l'IaC. Mais c'est un outil de plus à installer et
à faire tourner, pour réécrire un script qui fonctionne, à trois jours du gel technique.
- **Provisioners applicatifs de `feat/deploy`** : voir la revue sur #141. Terraform y devenait un
orchestrateur concurrent de Compose, sans base de données ni migrations.
- **Terraform appelle aussi `make stack-up`** : le premier démarrage serait plus court d'une
commande, mais Terraform se mettrait à porter la livraison, que le runner rejoue à chaque
push. Deux chemins pour le même acte, c'est précisément ce que #141 montre qu'il ne faut pas.
- **k3s tout de suite** : le cluster serait vide, sans manifeste, sans registre et sans stockage
persistant. Le module reste, documenté comme cible.
- **State Terraform distant** : un seul opérateur, pas d'exécution concurrente. Le backend local
suffit, comme pour `k3s-cible`.
## Conséquences
- Le premier `apply` exige un jeton d'enregistrement du runner, valable une heure et pour une
seule inscription, que seul un administrateur du dépôt peut créer. L'`apply` n'est donc pas
rejouable sans intervention humaine, ce qui est acceptable : il ne se joue qu'à l'installation.
- L'utilisateur propriétaire de `/srv/enervision` doit exister sur la machine avant l'`apply`.
Terraform vérifie et échoue tôt plutôt que de le créer : décider d'un compte système est une
décision d'administration, pas un effet de bord de déploiement.
- Terraform ne sait rien de l'état de la stack. `terraform plan` ne dira jamais que la recette
est tombée ; c'est la sonde de `deploy.yml` qui le dit.
- Pas de provisioner `destroy` sur le runner : il imposerait de mettre le chemin de la clé SSH
dans le state, et `svc.sh uninstall` ne désinscrit pas le runner côté GitHub. Le retrait reste
manuel, depuis les paramètres du dépôt.
- C22 cesse de reposer sur `docker-compose.prod.yml` seul. C23 reste porté par les scripts, que
Terraform appelle désormais au lieu de les remplacer.
@@ -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.
@@ -0,0 +1,119 @@
# 0013 - La surveillance de dérive vit dans le backend et écrit sa propre table
- Statut : accepté
- Date : 2026-09-22
## Contexte
L'issue #45 demande des tests d'intégration API ↔ DB ↔ ML. Trois documents du dépôt annoncent
par ailleurs, depuis le jalon J3, une surveillance de dérive qui n'existe nulle part :
`docs/architecture/00-vue-ensemble.md` (« Surveillance de dérive (EC06, #44/#45) pas encore
construite »), `docs/ML-START.md` (« la dette qui subsiste est la surveillance de dérive »), et
le docstring de `write_predictions()` dans `ml/enervision_ml/score.py`, qui justifie l'absence
d'unicité sur `(site_id, target_at)` par la comparaison future entre prévu et réalisé.
La matière première est en base : `prediction` porte ce que le modèle a annoncé, `reading` ce
qui est réellement arrivé. Restaient trois questions : où vit le calcul, à quoi on compare, et
où atterrit le résultat.
## Décision
**Le calcul vit dans `apps/backend`** : `repositories/drift.py` pour le SQL, `services/drift.py`
pour la logique, `monitoring/drift.py` pour la CLI, `api/v1/endpoints/monitoring.py` pour la
lecture. Le dossier `ml/` ne gagne pas une ligne.
**Le résultat est persisté** dans une table `drift_report`, une ligne par site plus une ligne
globale que `site_id` à NULL désigne.
**La comparaison oppose deux fenêtres vives de 168 h**, la récente et celle qui la précède, et
le verdict a trois valeurs : `stable`, `derive`, `indetermine`.
### Pourquoi le backend, alors que le sujet est le modèle
- **`prediction` n'est pas dans le périmètre de `ML_DATABASE_URL`.** `enervision_ml/config.py`,
`docs/ML-START.md` et l'[ADR 0003](0003-autorisation-rbac-a-trois-roles.md) désignent pour
cette variable un rôle PostgreSQL restreint **en lecture sur `reading` et `site`**. Mettre la
dérive dans `ml/` obligerait à élargir ce rôle à `prediction`, et à l'écriture : ce serait
contredire par le code la dette de moindre privilège que ces trois documents ont posée par
écrit.
- **L'alignement prévu contre réalisé existe déjà ici, une fois.** `AlertService._detect_anomaly`
croise `reading` et `prediction` sur le même instant, et `PredictionRepository.list_since`
porte déjà le piège des runs empilés. Le réécrire en SQL brut dans `ml/` créerait une seconde
source de vérité sur « quelle prédiction correspond à quelle lecture », ce que
l'[ADR 0006](0006-moteur-de-regles-dans-le-backend.md) a déjà refusé pour les règles.
- **La frontière de `docs/ML-START.md` tient.** FastAPI ne fait toujours pas tourner LightGBM :
la dérive lit deux tables et compare des nombres, elle n'évalue aucun modèle.
**Conséquence assumée** : `enervision_ml.metrics.regression_metrics` n'est pas réutilisable, le
backend n'important pas `enervision_ml`. MAE, MAPE et biais sont donc réécrits, une quinzaine de
lignes. Cette duplication n'est pas celle que `build_features` interdit : une divergence de
features est silencieuse et ruine les prévisions sans erreur, une divergence sur une moyenne
d'écarts absolus est attrapée par le premier test à valeurs connues.
### Ce qu'on mesure, et les deux dédoublonnages obligatoires
La paire est `prediction ⋈ reading` sur `(site_id, target_at = timestamp)`, restreinte aux
prédictions `available`. Elle exige un `DISTINCT ON` **des deux côtés** :
- `prediction` n'a pas d'unicité sur `(site_id, target_at)`, chaque run de scoring empile une
ligne. On retient la plus récente, celle que sert `GET /api/v1/predictions`, départagée par
`prediction_id` : `created_at` vaut l'heure de début de transaction et ne distingue pas deux
lignes du même run.
- `uq_reading_source` autorise deux lectures au même instant quand la `source` diffère. Sans
dédoublonnage, la jointure compterait cette heure deux fois et pondérerait doublement le site.
La fenêtre est **fermée à droite par un délai de grâce de 2 h** : le réalisé de la dernière
heure n'est pas encore ingéré, et l'inclure ferait chuter le taux de couverture à chaque
exécution, pour une raison qui n'a rien à voir avec le modèle.
Métriques retenues : `mae` (la métrique même qu'optimise LightGBM), **`bias` signé** (une MAE qui
monte dit « moins bon », un biais qui s'éloigne de zéro dit « le modèle se trompe toujours du
même côté », signature d'un décalage de distribution), `mape`, `n_observations`,
`coverage_ratio` et `insufficient_data_ratio` (qui mesurent le pipeline, pas le modèle), et la
liste des `model_references` vus dans la fenêtre : une MAE qui saute à l'instant exact où le
modèle change n'est pas une dérive, c'est une régression de réentraînement.
## Alternatives écartées
| Écartée | Raison |
|---|---|
| Comparer à la métrique MLflow de l'entraînement | Ce ne sont pas les mêmes grandeurs : `train.py` mesure un backtest où la météo de l'heure cible est connue, le scoring prévoit une heure future dont la météo est `NaN` et dont `is_working_hours` est recopié. Le verdict serait « dérive » dès le premier jour. Et le backend devrait importer `mlflow`, ce que la frontière de ML-START interdit. |
| É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. |
| 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 identiques par jour. Surtout, un échec de dérive ferait rougir `alertes` et laisserait croire que la détection a échoué. |
## Conséquences
- 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
deux `site_id` à NULL, et NULL n'est égal à rien, pas même à lui-même. Même forme que
`uq_reading_source`.
- `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
`tests/api/acces.py`, donc couverte gratuitement par la matrice de rôles rejouée avec de vrais
jetons.
- Un DAG `derive` quotidien l'ordonnance, sans reprise : rejouer une dérive la redéclarerait à
l'identique.
- 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.
- **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
La dérive n'a de matière que si des paires prévu/réalisé existent. Or `enervision_ml.score --now`
ne rejouait pas l'historique : `load_recent_from_database` n'avait pas de borne haute et
`build_scoring_frame` repartait de la dernière lecture connue, si bien que `target_at` valait
toujours « fin du jeu + 1 h » et que l'âge de la dernière lecture devenait négatif sans franchir
le seuil de péremption. Sur le jeu historique, figé au 31/12/2024, aucune boucle de rattrapage
n'aurait donc rien produit de vérifiable.
`until` est devenu obligatoire sur ce chargeur, et le scoring lui passe son instant de référence.
Le comportement en exploitation ne change pas, aucune lecture n'étant postérieure à l'heure
courante ; seul le rattrapage sur données passées devient possible.
+15 -12
View File
@@ -70,11 +70,11 @@ Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API,
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 : quatre DAGs tournent, deux pour Le lien `airflow --> db` est maintenant en trait plein : cinq 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), `historical_import` pour le dataset historique
historique (issue #119). L'orchestration de l'import API Mock et la réconciliation globale des (issue #119) et `mock_api_import` pour l'ingestion horaire de l'API Mock (issue #15).
deux sources restent à compléter dans l'issue #15. La réconciliation globale des données provenant des deux sources reste à compléter dans l'issue #15.
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
collecteur ne vient le lire. collecteur ne vient le lire.
@@ -86,18 +86,20 @@ 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 (EC06, #44/#45) pas encore construite | | 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)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée | | Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Provisionnement de la VM par Terraform, qui installe Docker, prépare les deux environnements et enregistre le runner, jamais appliqué ([ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Quatre DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes` et `historical_import`. Le DAG historique orchestre `app.etl.historical_import` et charge `dataset`, `site` et `reading`. L'orchestration API Mock reste à compléter dans #15 | | ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver et scheduler avec LocalExecutor via Docker Compose, sur une base PostgreSQL dédiée. Six DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes`, `historical_import`, `mock_api_import` et `derive` (quotidien, surveillance de dérive). L'import historique reste manuel et l'import API Mock s'exécute chaque heure. La réconciliation globale des deux sources reste à compléter dans l'issue #15. |
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 6 workflows, 18 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. Déploiement continu vers la VM ENI écrit par `deploy.yml`, `dev` en recette et `main` en production après approbation ([ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)), mais jamais exécuté : la machine n'est pas provisionnée et le runner n'y est pas enregistré. Détail dans [50-cicd.md](50-cicd.md) | | CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 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) |
## 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 trois 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 globale des données provenant des deux sources reste à compléter dans l'issue #15.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
@@ -189,3 +191,4 @@ Elles vivent dans `../adr/`, pas ici.
| [0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose | | [0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
| [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 |
+80 -8
View File
@@ -1,14 +1,16 @@
# Infrastructure # Infrastructure
Trois topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut dans Plusieurs topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut
quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre elles. dans quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre elles.
| Topologie | Sert à | Statut | | Topologie | Sert à | Statut |
|---|---|---| |---|---|---|
| Docker Compose | Développer et recetter sur le poste | `Fait` | | Docker Compose | Développer et recetter sur le poste | `Fait` |
| Docker Compose plus reverse proxy | Déployer sur la machine on-premise | `Fait` | | Docker Compose plus reverse proxy | Déployer sur la machine on-premise | `Fait` |
| 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` |
| 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
@@ -52,7 +54,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`) :
@@ -85,12 +87,25 @@ 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 l'heure précédant son déclenchement depuis l'API Mock |
| `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 |
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. Il transmet cet intervalle au script backend et charge les mesures 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
@@ -147,6 +162,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
@@ -209,10 +252,38 @@ Nginx renvoie vers `https://$host` sans port, c'est-à-dire vers la production.
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`.
### Provisionnement de la machine
Statut : `En cours`. Décision et frontière dans
l'[ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md) : **Terraform
provisionne la machine, GitHub Actions déploie l'application**. La racine
`infra/terraform/environments/vm-eni/` fait trois choses, et rien d'autre.
```mermaid
sequenceDiagram
participant TF as terraform apply
participant VM as VM eadl-2025-nantes-g3
participant GH as GitHub
TF->>VM: SSH, get.docker.com puis docker compose version
TF->>VM: copie et exécute scripts/provision-host.sh
VM->>VM: deux clones, deux .env, deux certificats
TF->>VM: installe actions-runner, config.sh, svc.sh
VM->>GH: le runner s'enregistre avec le label eni-g3
```
Aucune image n'y est construite, aucun conteneur lancé : un `apply` n'interrompt pas la stack qui
tourne. Le premier démarrage reste manuel, `make stack-up` dans chaque dossier ; les suivants
sont joués par le runner à chaque push. Terraform ne sait rien de l'état de la stack, c'est la
sonde de `deploy.yml` qui le dit.
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.
## Cible à terme, k3s ## Cible à terme, k3s
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster, depuis la
été appliqué. racine `infra/terraform/environments/k3s-cible/`. Il n'a jamais été appliqué.
```mermaid ```mermaid
flowchart LR flowchart LR
@@ -260,11 +331,13 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
| `k3s_version` obligatoire, valeur vide refusée | Sans épinglage, `get.k3s.io` installe la dernière version à chaque exécution : le déploiement cesse d'être reproductible | `validation` dans `modules/k3s/variables.tf` | | `k3s_version` obligatoire, valeur vide refusée | Sans épinglage, `get.k3s.io` installe la dernière version à chaque exécution : le déploiement cesse d'être reproductible | `validation` dans `modules/k3s/variables.tf` |
| Traefik désactivé | Le choix d'ingress reste ouvert, on ne veut pas en subir un par défaut | `k3s_disable_components`, défaut `["traefik"]` | | Traefik désactivé | Le choix d'ingress reste ouvert, on ne veut pas en subir un par défaut | `k3s_disable_components`, défaut `["traefik"]` |
| Kubeconfig laissé en `600/root`, lu par `sudo` | `--write-kubeconfig-mode 644` exposerait `cluster-admin` à tout utilisateur local de la machine | Commentaire et `fetch_kubeconfig` dans `modules/k3s/main.tf` | | Kubeconfig laissé en `600/root`, lu par `sudo` | `--write-kubeconfig-mode 644` exposerait `cluster-admin` à tout utilisateur local de la machine | Commentaire et `fetch_kubeconfig` dans `modules/k3s/main.tf` |
| State Terraform en backend `local` | Un seul opérateur, pas d'exécution concurrente, pas de dépendance à un stockage distant | `environments/dev/versions.tf` | | State Terraform en backend `local` | Un seul opérateur, pas d'exécution concurrente, pas de dépendance à un stockage distant | `versions.tf` de chaque racine |
| `.terraform.lock.hcl` versionné | Fige les versions de provider entre contributeurs et future CI | Commentaire dans `.gitignore` | | `.terraform.lock.hcl` versionné | Fige les versions de provider entre contributeurs et future CI | Commentaire dans `.gitignore` |
| `*.tfvars` ignoré, `*.tfvars.example` versionné | Les tfvars portent l'adresse du serveur et le chemin de la clé | `.gitignore` | | `*.tfvars` ignoré, `*.tfvars.example` versionné | Les tfvars portent l'adresse du serveur et le chemin de la clé | `.gitignore` |
| Désinstallation gérée au `destroy` | `k3s-uninstall.sh` en `on_failure = continue` : un serveur injoignable ne bloque pas le `destroy` | `modules/k3s/main.tf` | | Désinstallation gérée au `destroy` | `k3s-uninstall.sh` en `on_failure = continue` : un serveur injoignable ne bloque pas le `destroy` | `modules/k3s/main.tf` |
| Deux racines, `dev` et `prod` | Séparation des états et des variables par environnement | `environments/` | | Une racine Terraform par machine provisionnée, nommée d'après elle | `environments/dev` laissait croire à un environnement applicatif, alors que `rec` et `prod` vivent sur la même machine et ne sont pas provisionnés par Terraform | `environments/vm-eni`, `environments/k3s-cible` |
| Terraform provisionne, GitHub Actions déploie | Deux chemins pour le même acte de livraison, c'est ce que la revue de #141 relève sur la VM | [ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md) |
| Connexion SSH par clé, jamais par mot de passe | Une variable de mot de passe finit en clair dans le state, ou dans les `triggers` qui y sont persistés | `environments/vm-eni/variables.tf`, `modules/k3s/main.tf` |
| Terminaison TLS par un reverse proxy Nginx en Compose | L'ingress k3s supposait un registre et des manifestes qui n'existent pas, à quatre jours du rendu | `docker-compose.prod.yml`, [ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | | Terminaison TLS par un reverse proxy Nginx en Compose | L'ingress k3s supposait un registre et des manifestes qui n'existent pas, à quatre jours du rendu | `docker-compose.prod.yml`, [ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) |
| Certificat auto-signé par défaut, chemin ACME câblé | Aucun domaine public ne résout vers la machine : le défi HTTP-01 ne peut pas aboutir | `scripts/tls-selfsigned.sh`, `infra/proxy/acme-deploy-hook.sh` | | Certificat auto-signé par défaut, chemin ACME câblé | Aucun domaine public ne résout vers la machine : le défi HTTP-01 ne peut pas aboutir | `scripts/tls-selfsigned.sh`, `infra/proxy/acme-deploy-hook.sh` |
| Un projet Compose par environnement, sur la même machine | Une seule VM, et l'isolation par nom de projet ne demande ni cluster ni registre | `.env` de chaque dossier, [ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md) | | Un projet Compose par environnement, sur la même machine | Une seule VM, et l'isolation par nom de projet ne demande ni cluster ni registre | `.env` de chaque dossier, [ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md) |
@@ -304,4 +377,3 @@ question à trancher, avant toute ressource Kubernetes.
- **Quel stockage persistant** côté Kubernetes pour PostgreSQL, et si la base tourne dans le - **Quel stockage persistant** côté Kubernetes pour PostgreSQL, et si la base tourne dans le
cluster ou à côté. cluster ou à côté.
- **Quelle stratégie de sauvegarde et de restauration** des données de mesure. - **Quelle stratégie de sauvegarde et de restauration** des données de mesure.
- **Que devient `environments/prod/`**, aujourd'hui réduit à un `.gitkeep`.
+30 -3
View File
@@ -12,7 +12,7 @@ Les quatre couches existent désormais, portées par l'authentification.
```mermaid ```mermaid
flowchart TB flowchart TB
ep["endpoints<br/>health, auth, users, sites, alerts,<br/>recommendations, stats, readings, sensors, predictions"] 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, PredictionService"] 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, prediction"] rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, alert, recommendation, reading, prediction"]
@@ -151,6 +151,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
| GET | `/api/v1/readings` | Historique des lectures, filtrable par `site_id`, fenêtre `start`/`end` (24h par défaut, 90 jours maximum) et paginé par `limit`/`offset`. `lecteur` | 400, 401, 403, 422, 500 | | GET | `/api/v1/readings` | Historique des lectures, filtrable par `site_id`, fenêtre `start`/`end` (24h par défaut, 90 jours maximum) et paginé par `limit`/`offset`. `lecteur` | 400, 401, 403, 422, 500 |
| GET | `/api/v1/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 500 | | GET | `/api/v1/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 500 |
| GET | `/api/v1/predictions` | Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`). `lecteur` | 401, 403, 500 | | GET | `/api/v1/predictions` | Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`). `lecteur` | 401, 403, 500 |
| GET | `/api/v1/monitoring/drift` | Dernier rapport de dérive par site, plus la ligne globale. `operateur` | 401, 403, 422, 500 |
| GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | | | GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | |
| GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | | | GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | |
@@ -223,6 +224,30 @@ par exemple `limit` hors bornes). Un datetime sans fuseau dans `start`/`end` est
l'UTC plutôt que rejeté : le comparer tel quel à `reading.timestamp` (`timestamptz`) échouerait l'UTC plutôt que rejeté : le comparer tel quel à `reading.timestamp` (`timestamptz`) échouerait
côté pilote, en `500` plutôt qu'un refus propre. côté pilote, en `500` plutôt qu'un refus propre.
### Surveillance de dérive
`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
site plus une ligne globale, que `DriftRepository.enregistre()` écrit dans `drift_report` avec
`ON CONFLICT DO NOTHING` sur `uq_drift_report_window` : rejouer la commande sur la même fenêtre
n'ajoute rien.
| Métrique | Ce qu'elle dit |
|---|---|
| `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é. 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 |
| `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 |
| `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
d'observations, le service dit qu'il ne sait pas plutôt que de rendre un chiffre trompeur. La
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`
l'ordonnance, et `GET /api/v1/monitoring/drift` sert le dernier rapport de chaque site. Les
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
`AlertService` n'est plus lecture seule : `AlertService.detect()` compare les `reading` (et, pour `AlertService` n'est plus lecture seule : `AlertService.detect()` compare les `reading` (et, pour
@@ -331,8 +356,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.
+16 -3
View File
@@ -48,7 +48,8 @@ Statut : `Fait`.
et refuse de s'appliquer si l'extension TimescaleDB manque. et refuse de s'appliquer si l'extension TimescaleDB manque.
- Les révisions suivantes créent les tables liées à l'authentification : - Les révisions suivantes créent les tables liées à l'authentification :
`app_user`, `login_attempt`, `audit_log` et `refresh_token`. `app_user`, `login_attempt`, `audit_log` et `refresh_token`.
- La révision `e6d2026091501` crée les six tables Data et déclare l'hypertable `reading`. - La révision `e6d2026091501` crée six des sept tables Data et déclare l'hypertable `reading`.
- La révision `d3f1a2b7c904` ajoute `drift_report`, la septième.
- La révision `c0adab96238c` ajoute les tables `password_reset_attempt` - La révision `c0adab96238c` ajoute les tables `password_reset_attempt`
et `password_reset_token`. et `password_reset_token`.
@@ -261,8 +262,8 @@ Cette modélisation prend en compte :
- leurs métadonnées JSON ; - leurs métadonnées JSON ;
- les données de l'API Mock. - les données de l'API Mock.
Elle comprend six tables Data, depuis le stockage des mesures jusqu'aux recommandations proposées Elle comprend sept tables Data, depuis le stockage des mesures jusqu'aux recommandations
à l'utilisateur. proposées à l'utilisateur, et jusqu'au suivi de la dérive du modèle.
### Schéma de données ### Schéma de données
@@ -286,11 +287,22 @@ Chaque table remplit un rôle précis dans le traitement et l'exploitation des d
| `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d'EnerVision | | `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d'EnerVision |
| `alert` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision | | `alert` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision |
| `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 |
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.
Les lignes de `drift_report` sont écrites par `app.monitoring.drift`, ordonnancé par le DAG
`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,
qui ne dédoublonnerait jamais deux lignes globales. Le calcul, ses seuils et ce qu'il refuse de
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`,
par `make recommendations`, ou par la seconde tâche du DAG `alertes`, à partir des alertes déjà en par `make recommendations`, ou par la seconde tâche du DAG `alertes`, à partir des alertes déjà en
@@ -304,6 +316,7 @@ n'ajoute aucune ligne.
- Les mesures API ne sont pas rattachées à un dataset historique. - Les mesures API ne sont pas rattachées à un dataset historique.
- Une alerte peut être associée à une prévision du même site. - Une alerte peut être associée à une prévision du même site.
- Une alerte peut donner lieu à plusieurs recommandations. - Une alerte peut donner lieu à plusieurs recommandations.
- Un site possède plusieurs rapports de dérive ; un rapport global n'est rattaché à aucun site.
## Ingestion des données historiques ## Ingestion des données historiques
+169 -10
View File
@@ -16,6 +16,12 @@ basculer sur `Fait` au premier déploiement vert. Sa limite, nommée ici plutôt
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.
Ce que ce workflow ne fait pas, et ne fera pas : préparer la machine. Installation de Docker,
clones, `.env`, certificats et enregistrement du runner sont provisionnés par
`infra/terraform/environments/vm-eni`
([ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md)). Terraform provisionne,
GitHub Actions déploie ; aucun des deux ne fait le travail de l'autre.
## Vue d'ensemble ## Vue d'ensemble
```mermaid ```mermaid
@@ -45,6 +51,10 @@ flowchart TB
ab["image<br/>construction de l'image"] ab["image<br/>construction de l'image"]
end end
subgraph infw["Infra · infra.yml"]
it["terraform<br/>fmt -check, init et validate par racine"]
end
subgraph sq["SonarQube · sonarqube.yml"] subgraph sq["SonarQube · sonarqube.yml"]
sb1["build-front / test-front"] sb1["build-front / test-front"]
sb2["build-back / test-back"] sb2["build-back / test-back"]
@@ -57,22 +67,36 @@ flowchart TB
push --> fd push --> fd
push --> mv & ms push --> mv & ms
push --> av & ab push --> av & ab
push --> sb1 & sb2 --> sscan push --> it
push --> sb1 & sb2 & sb3 --> sscan
subgraph cd["Déploiement · deploy.yml"] subgraph cd["Déploiement · deploy.yml"]
dep["deploy<br/>runner eni-g3, environnement rec ou prod"] dep["deploy<br/>runner eni-g3, environnement rec ou prod"]
end end
push -->|"push sur dev ou main"| dep push -->|"push sur dev ou main"| dep
planifie["chaque lundi 3h UTC,<br/>ou à la main"]
subgraph dastw["DAST · dast.yml"]
zscan["zap<br/>seed + scan actif OWASP ZAP"]
end
planifie --> zscan
push -->|"PR sur dast.yml<br/>ou dast-token.sh"| zscan
``` ```
## Déclenchement ## Déclenchement
Les cinq workflows se déclenchent sur `push` **et** sur `pull_request`, filtrés par **chemin** : Les six workflows hébergés par GitHub qui vérifient le code se déclenchent sur `push` **et** sur
`backend.yml` sur `apps/backend/**`, `frontend.yml` sur `apps/frontend/**`, `ml.yml` sur `ml/**`, `pull_request`, filtrés par **chemin** : `backend.yml` sur `apps/backend/**`, `frontend.yml` sur
`airflow.yml` sur `etl/airflow/**` **plus des chemins de `ml/` et de `apps/backend/`**, chacun `apps/frontend/**`, `ml.yml` sur `ml/**`, `infra.yml` sur `infra/terraform/**`, `airflow.yml` sur
incluant son propre fichier de workflow dans le filtre pour qu'une modification du pipeline `etl/airflow/**` **plus des chemins de `ml/` et de `apps/backend/`**, chacun incluant son propre
déclenche le pipeline. fichier de workflow dans le filtre pour qu'une modification du pipeline déclenche le pipeline.
`dast.yml` s'en écarte volontairement (détail dans sa propre section plus bas) : aucun
déclenchement sur `push`, seulement `workflow_dispatch`, une planification hebdomadaire, et
`pull_request` restreint à ses deux seuls fichiers. Un scan actif est trop long pour tourner à
chaque commit.
Le filtre d'`airflow.yml` mérite un mot : il inclut `ml/pyproject.toml`, `ml/uv.lock`, Le filtre d'`airflow.yml` mérite un mot : il inclut `ml/pyproject.toml`, `ml/uv.lock`,
`ml/enervision_ml/**`, `apps/backend/pyproject.toml`, `apps/backend/uv.lock` et `ml/enervision_ml/**`, `apps/backend/pyproject.toml`, `apps/backend/uv.lock` et
@@ -97,7 +121,8 @@ environnement.
## Déploiement ## Déploiement
`deploy.yml` est le sixième workflow, et le seul qui ne tourne pas chez GitHub : il s'exécute sur `deploy.yml` est le huitième workflow (`backend`, `frontend`, `ml`, `infra`, `airflow`,
`sonarqube`, `dast`, plus lui-même), et le seul qui ne tourne pas chez GitHub : il s'exécute sur
un runner auto-hébergé installé sur la VM ENI, label `eni-g3`, parce que les runners hébergés ne un runner auto-hébergé installé sur la VM ENI, label `eni-g3`, parce que les runners hébergés ne
joignent pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port joignent pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port
entrant n'est ouvert. entrant n'est ouvert.
@@ -144,6 +169,8 @@ dans [10-infra.md](10-infra.md).
| Typage `mypy` | backend (`app`), ml (strict) | zéro erreur | Bloque | | Typage `mypy` | backend (`app`), ml (strict) | zéro erreur | Bloque |
| Tests unitaires `pytest` | backend, ml | **`--cov-fail-under=85`** côté backend | Bloque | | Tests unitaires `pytest` | backend, ml | **`--cov-fail-under=85`** côté backend | Bloque |
| Tests d'intégration | backend | marqueur `integration`, base réelle | Bloque | | Tests d'intégration | backend | marqueur `integration`, base réelle | Bloque |
| Tests d'intégration ML ↔ DB | ml | marqueur `integration`, base réelle migrée par Alembic | Bloque |
| Chaîne ML → DB → API | ml | marqueur `chaine`, vrais binaires en sous-processus | Bloque |
| Audit de dépendances `pip-audit` | backend | sur le **verrou figé** | Bloque | | Audit de dépendances `pip-audit` | backend | sur le **verrou figé** | Bloque |
| Audit de dépendances `npm audit` | frontend | `--audit-level=high` | Bloque | | Audit de dépendances `npm audit` | frontend | `--audit-level=high` | Bloque |
| **SAST `bandit`** | backend (`app`), ml (`enervision_ml`) | **MEDIUM et au-dessus** | Bloque | | **SAST `bandit`** | backend (`app`), ml (`enervision_ml`) | **MEDIUM et au-dessus** | Bloque |
@@ -151,6 +178,7 @@ dans [10-infra.md](10-infra.md).
| Build `npm run build` | frontend | compilation | Bloque | | Build `npm run build` | frontend | compilation | Bloque |
| 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 |
Deux seuils portent une décision qu'il faut savoir défendre : Deux seuils portent une décision qu'il faut savoir défendre :
@@ -181,6 +209,28 @@ 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 : les `paths` de `ml.yml` incluent `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 workflow `sonarqube.yml` exécute cinq jobs de préparation (`build-front`, `test-front`,
@@ -242,12 +292,112 @@ 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.
## Scan DAST (OWASP ZAP)
Statut : `En cours`. Le workflow `dast.yml` attaque l'API **en fonctionnement**, ce que ni Bandit,
ni `pip-audit`, ni Sonar ne font. Il se lance à la main (`workflow_dispatch`), chaque lundi à 3h
UTC, et sur une PR qui modifie le scan lui-même. Pas à chaque PR : un scan actif dure plusieurs
minutes.
Le job démarre sur le runner la base (même image TimescaleDB que `docker-compose.yml`, base
jetable), applique les migrations, y sème un site et deux relevés (`db/seeds/` est vide, pas
encore d'outillage de jeu de données pour la CI ; sans données, `GET /sites` rend `[]`, chaque
`/{site_id}` rend 404, et le scan actif ne frappe que des gestionnaires d'erreur), démarre le
backend, puis `scripts/dast-token.sh` crée un compte **`lecteur`** et rend son jeton.
ZAP charge le contrat `/openapi.json` depuis un fichier (`zap-api-scan.py -f openapi -t
/zap/wrk/openapi.json`) et en importe les 26 opérations **quel que soit le jeton** : c'est le
contrat qui décide de ce qui est exploré, pas l'authentification. Le jeton ne change que les
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.
## 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 | | Tests end to end | #46 | Les parcours utilisateur ne sont pas vérifiés en CI |
| Tests de charge | #47 | Aucun garde-fou de performance | | Tests de charge | #47 | Aucun garde-fou de performance |
| Scan d'image de conteneur | aucune | Les `Dockerfile` sont construits en local, pas analysés | | Scan d'image de conteneur | aucune | Les `Dockerfile` sont construits en local, pas analysés |
@@ -255,8 +405,17 @@ régénère, ce qui invalide les sessions et les connexions chiffrées par Airfl
## Reproduire la CI en local ## Reproduire la CI en local
`make check` enchaîne formatage, analyse statique, typage et tests du backend, c'est à dire le job `make check` enchaîne formatage, analyse statique, typage et tests du backend, c'est à dire le job
`verification`. `make ml-check` fait la même chose pour le module ML. Les tests d'intégration `verification`. `make ml-check` fait la même chose pour le module ML.
demandent une base : `make db-up` puis `uv run pytest -m integration`.
Les tests d'intégration demandent une base **migrée**, et `db/init` ne crée `enervision_test` que
vide :
```bash
make db-up migrate-test # la base de test reçoit les six révisions Alembic
make test-integration # backend, marqueur `integration`
make ml-test-integration # pipeline ML, marqueur `integration`
make test-chaine # vrais binaires ML puis relecture par l'API, marqueur `chaine`
```
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
+1
View File
@@ -38,6 +38,7 @@ 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 |
| 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`, et `no-store` sur les routes d'authentification | `app/api/middleware.py` | A05 | | En-têtes `nosniff`, `DENY`, `no-referrer`, 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 |
+15 -9
View File
@@ -663,16 +663,22 @@ 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`. Il appelle
`app.etl.historical_import` avec les fichiers montés en lecture seule depuis `data/raw` vers `app.etl.mock_api_import` avec un intervalle explicite d'une heure et une limite de 1 000 lectures
`/opt/data/raw`. L'orchestration de l'import API Mock et la réconciliation globale des deux par site. Les deux pipelines normalisent leurs données vers les tables communes `site` et
sources restent couvertes par l'issue #15. `reading`, tout en conservant leur source (`csv` ou `api_history`). La réconciliation globale
des deux sources reste à compléter dans l'issue #15.
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` et `alertes.py` et `historical_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 DAG `mock_api_import` exécute `app.etl.mock_api_import` toutes les heures. Chaque exécution
traite l'intervalle Airflow précédent. Les deux pipelines normalisent leurs données vers les
tables communes `site` et `reading`, tout en conservant leur source (`csv` ou `api_history`).
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.
+46
View File
@@ -0,0 +1,46 @@
"""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
vingt-quatre lignes presque identiques par jour et se heurterait à l'index d'idempotence
`uq_drift_report_window`. Planifié après les scorings de la nuit, et décalé de `ml_score` (à
l'heure pile) comme de `alertes` (à la quinzième minute).
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 à
son pas horaire.
"""
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"
# Piège : aucune reprise. Une dérive n'est pas un échec transitoire, la rejouer la redéclarerait
# à l'identique ; et la cadence quotidienne pardonne une connexion perdue.
TENTATIVES = 0
PLAFOND = timedelta(minutes=10)
with DAG(
dag_id="derive",
description=(
"Compare les prévisions déjà écrites aux lectures réellement arrivées "
"(app.monitoring.drift)."
),
schedule="30 5 * * *",
start_date=datetime(2026, 1, 1),
catchup=False,
max_active_runs=1,
tags=["ml", "monitoring"],
) as dag:
BashOperator(
task_id="derive",
bash_command=f"{COMMANDE_BACKEND} app.monitoring.drift",
retries=TENTATIVES,
execution_timeout=PLAFOND,
)
+61
View File
@@ -0,0 +1,61 @@
"""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 traite l'heure précédant 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"
# Le pipeline backend et l'API acceptent au maximum 1 000 lectures par site.
# Cette marge évite de perdre silencieusement une lecture si une heure en contient plus de 60.
LIMITE_LECTURES = 1000
# 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.
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 \"{{ data_interval_start.strftime('%Y-%m-%dT%H:%M:%S') }}\" "
"--end-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:%M:%S') }}\" "
f"--limit {LIMITE_LECTURES}"
),
retries=NOMBRE_REPRISES,
retry_delay=DELAI_ENTRE_REPRISES,
execution_timeout=PLAFOND_PAR_TENTATIVE,
)
+70 -2
View File
@@ -1,22 +1,32 @@
"""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"] DAG_IDS = [
"ml_train",
"ml_score",
"alertes",
"historical_import",
"mock_api_import",
"derive",
]
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"),
] ]
@@ -52,6 +62,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
@@ -84,6 +107,20 @@ 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_uses_the_airflow_data_interval(dagbag: DagBag) -> None:
commande = dagbag.dags["mock_api_import"].get_task("import_mock_api").bash_command
assert "--start-time \"{{ data_interval_start.strftime('%Y-%m-%dT%H:%M:%S') }}\"" in commande
assert "--end-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:%M:%S') }}\"" in commande
assert "--limit 1000" 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).
@@ -95,6 +132,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.
@@ -136,6 +179,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.
@@ -159,6 +210,23 @@ 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:
assert dagbag.dags["derive"].timetable.expression == "30 5 * * *"
def test_derive_calls_the_backend_drift_module(dagbag: DagBag) -> None:
assert "app.monitoring.drift" in dagbag.dags["derive"].get_task("derive").bash_command
def test_derive_never_retries_a_detected_drift(dagbag: DagBag) -> None:
# Une derive n'est pas une panne passagere : la rejouer la redeclarerait a l'identique.
assert dagbag.dags["derive"].get_task("derive").retries == 0
@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
+36 -6
View File
@@ -1,18 +1,48 @@
# Infrastructure # Infrastructure
Provisionnement Terraform de la machine on-premise (serveur physique, accessible en SSH). Provisionnement Terraform des machines on-premise. Terraform prepare la machine, GitHub Actions
deploie l'application : voir l'[ADR 0010](../docs/adr/0010-terraform-provisionne-github-actions-deploie.md).
Rien ici ne construit d'image ni ne lance de conteneur.
- `terraform/modules` : modules reutilisables. - `terraform/modules` : modules reutilisables.
- `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/<env>` : racines Terraform, une par environnement. - `terraform/environments/<racine>` : une racine par machine provisionnee.
- `dev` : instancie le module `k3s` sur le serveur de l'ecole. - `vm-eni` : la VM `eadl-2025-nantes-g3`, qui porte les environnements `rec` et `prod`
- `prod` : non initialise, voir le ticket dedie. ([ADR 0009](../docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md)). Installe Docker,
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
applique.
## Usage (environments/dev) ## Usage (environments/vm-eni)
```bash ```bash
cd infra/terraform/environments/dev cd infra/terraform/environments/vm-eni
cp terraform.tfvars.example terraform.tfvars
terraform init
terraform apply
```
`terraform.tfvars` est ignore par git. Trois valeurs sont a renseigner avant l'apply :
- `proprietaire` : l'utilisateur qui possede `/srv/enervision` et fait tourner le runner. Il doit
deja exister sur la machine.
- `runner_version` : a epingler depuis <https://github.com/actions/runner/releases>.
- `runner_token` : jeton d'enregistrement, valable une heure et pour une seule inscription.
Parametres du depot, Actions, Runners, New self-hosted runner. Seul un administrateur du depot
peut le creer.
Apres l'apply, la machine porte `/srv/enervision/rec` et `/srv/enervision/prod`, chacun avec son
`.env` et son certificat. Le premier demarrage reste manuel, `make stack-up` dans chaque dossier ;
les suivants sont joues par le runner a chaque push sur `dev` et sur `main`.
Retirer le runner se fait a la main, depuis les parametres du depot : `terraform destroy` ne le
desinscrit pas.
## Usage (environments/k3s-cible)
```bash
cd infra/terraform/environments/k3s-cible
cp terraform.tfvars.example terraform.tfvars # renseigner ssh_host / ssh_private_key_path cp terraform.tfvars.example terraform.tfvars # renseigner ssh_host / ssh_private_key_path
terraform init terraform init
terraform apply terraform apply
@@ -0,0 +1,63 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/archive" {
version = "2.8.1"
constraints = "~> 2.4"
hashes = [
"h1:eehhIUcuegkswQDKArYBAhVV9wQmRVMhyYGaD7kHIj0=",
"zh:03de290604114a89fcd45c2e5bc7787d5a1ebfc5f964fb5989306bea7a4c79ec",
"zh:0a7d69dc9fbbc48960bc2f04588c8fb1bd92c78a8f306566b7fb17fc4a4f2058",
"zh:4df1f3981379c35f1757da957470f7f7724496b57219da3485177cf1647bdf59",
"zh:50f0e72ba53bfe6e11b03b7fc899e1f72536354381d302a743a274ae2a3f45f4",
"zh:5c4e15a04c98e2a8cafb1cd9632b48ad318051e8462d105b1153006277985c35",
"zh:66069e604bcf5c4af0278e15997d9e6bd755c54fb3801d78885838b889729c5f",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:979765db3f42601870ab377104ba70befb029547b278337ec4ada980e3582de6",
"zh:b165254da774f49945a73fbccc3ef1b63d70ea00a98fa9e14716665ba80ecaad",
"zh:c0bb2697b525da9fec4511f569ed2bd2b42f25d5c1f9fa00f8f3645b50cfc2e9",
"zh:c48f6695d12df0d0fa5231f7c1b8d518a4feae91a733fdd35a5804af3a303831",
"zh:d589954c93f075180c9f4e2ce91780d5edcb56dfd0d3cda8ba08e13c10b52249",
"zh:f5792ed06da65d0daf7ca3711f5399ff78c7cb4400afe53fbce9a926fbde4477",
]
}
provider "registry.terraform.io/hashicorp/null" {
version = "3.3.2"
hashes = [
"h1:7rn0+p+fbrHfJxNVgTnlKp6C3yu0jENMXffz9Xb9XjQ=",
"zh:10ec43b8b7b18d5639238c7fb9e111f6a4b038523dd66c7a426bf27b25fa4c08",
"zh:60beb9cc2ad5b871c710860cee75b42850cc6acd43db0d77cb5e00fda7288b55",
"zh:62538582d0a4a2f10ad8a8d9a6c3cd3f05af6c6d91c6641ffc78d4f0e8e69b27",
"zh:64a8f9ce7852d9efc5b464c12306c946366d59f5e2757def97969c9fd64bd1d6",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:92a374fb736a52f465283326d0a5bf4f495132eb99be209dfb4c75ec803fe8db",
"zh:98da9c42785d27a50f0604758bcb61a30f6278b9f2acd92bb3b2046e0e71916c",
"zh:b0f7896fae554729cdf4a24ac06359a050cff5817e6cd8597cba8a4ae01a7409",
"zh:bc8179ee35d67c72fb03012e7023b9f9816f033a7ec4109c001dd6d29752e812",
"zh:d23a598f713bfb6098bc003571d7de90b5a33b78f9be240488252fe5f3c2a60d",
"zh:d2855b922ea345dbd89ea287e4c6c4757e38bc0aaffeb2b79aa0b8004f9c53ff",
"zh:d3a60422bc6a2f9244d076c5222c07060c826ef91bdbaf4634cb752b86057473",
"zh:faa01928c25d2a6ecd9c7eb8b88134cb08de55a6b11ca6c703ac0092845344ba",
]
}
provider "registry.terraform.io/kreuzwerker/docker" {
version = "4.5.0"
constraints = "~> 4.5.0"
hashes = [
"h1:eXPMxNcZjz5gC7Nc1oEPJldpI6B5btbvldjLOgJQNbI=",
"zh:0ee4e9121632158a9ae1049bdd53c71fd9e06357de915dd193df2c1c55ae8814",
"zh:12c6991012b5a548a406e1960c323484f95a9538c2522af15ec82f47fdb832d1",
"zh:19c86ae59ed1e062e8402a49ea842f957fcda2f9689e06ecbd9480cc9bd1e41a",
"zh:3ac8bc9805acd20c40c0ed474c9f4605aa73b61cf52809e1131e335fcbfe8e49",
"zh:5a0e70d143759712fdda109f024a41d59d4758b5615378aa21ee6b959f65a416",
"zh:5d3599285f71cc53c88a5802fa6e2f8ba87b9deea03abe5fe8bb2639e9d95654",
"zh:a42f573a2526cddc9b8a93637a2c5478bc6e903ff29556ab8b2f1467681d5bde",
"zh:c8de50d902cc8457e565a5d959f1e3e49064d31c2f14f7adf5f3ec632cfe3695",
"zh:d40543d51eb4e902125d2c5a8a0f425a60edcec6c1dc07c4a89d92335ec05541",
"zh:dd3105018ffddc8114083dcf84798c32fdecca7ef708eceaee49eb4b306fc061",
"zh:e51e0199700698b962085683af4551d1f982dce37f3b6950da062c65e0145505",
"zh:ffe415d3d3deffffdbd3a7173e3a9f90b74601cb6d8acafec1ab701a5b91dbff",
]
}
@@ -0,0 +1,78 @@
locals {
backend_dir = "../../../../apps/backend"
backend_image_name = "enervision-back"
backend_image_tag = "latest"
backend_container_name = "enervision-backend"
}
resource "docker_image" "backend" {
name = "${local.backend_image_name}:${local.backend_image_tag}"
build {
context = local.backend_dir
dockerfile = "Dockerfile"
}
triggers = {
build_hash = sha256(join("", [
for file in fileset(local.backend_dir, "**") :
filesha256("${local.backend_dir}/${file}")
]))
}
}
resource "docker_container" "backend" {
image = docker_image.backend.image_id
name = local.backend_container_name
ports {
internal = 8000
external = var.backend_port
}
volumes {
host_path = "/var/log/enervision"
container_path = "/var/log/enervision"
}
depends_on = [docker_image.backend]
}
resource "null_resource" "deploy_backend_to_server" {
depends_on = [docker_image.backend]
triggers = {
image_id = docker_image.backend.image_id
}
connection {
type = "ssh"
host = var.ssh_host
port = var.ssh_port
user = var.ssh_user
password = var.ssh_password
private_key = try(file(pathexpand(var.ssh_private_key_path)), null)
timeout = "5m"
}
provisioner "file" {
source = "${local.backend_dir}/Dockerfile"
destination = "/tmp/Dockerfile"
}
provisioner "file" {
source = "${local.backend_dir}/"
destination = "/tmp/backend/"
}
provisioner "remote-exec" {
inline = [
"cd /tmp/backend",
"sudo docker stop ${local.backend_container_name} 2>/dev/null || true",
"sudo docker rm ${local.backend_container_name} 2>/dev/null || true",
"sudo docker build -t ${local.backend_image_name}:${local.backend_image_tag} .",
"sudo docker run -d --name ${local.backend_container_name} -p ${var.backend_port}:8000 --restart always -v /var/log/enervision:/var/log/enervision ${local.backend_image_name}:${local.backend_image_tag}"
]
}
}
@@ -0,0 +1,145 @@
locals {
frontend_environments = {
dev = {
source_dir = "${path.root}/../../../../apps/frontend/dist/frontend/browser"
domain = "dev.enervision"
}
}
frontend_dir = "../../../../apps/frontend"
image_name = "enervision-front"
image_tag = "latest"
container_name = "enervision-frontend"
selected_frontend = local.frontend_environments[var.deployment_environment]
}
provider "docker" {
host = var.docker_host
}
# Build le front
resource "null_resource" "build_frontend" {
triggers = {
frontend_files = sha256(join("", [
for file in fileset(local.frontend_dir, "src/**,package.json") :
filesha256("${local.frontend_dir}/${file}")
]))
}
provisioner "local-exec" {
command = "cd ${local.frontend_dir} && npm ci && chmod -R +x node_modules/@angular/cli/bin && chmod -R +x node_modules/.bin && npm exec ng build"
}
}
# Build l'image docker du front
resource "docker_image" "frontend" {
name = "${local.image_name}:${local.image_tag}"
build {
context = local.frontend_dir
dockerfile = "Dockerfile"
}
triggers = {
build_hash = sha256(join("", [
for file in fileset(local.frontend_dir, "**") :
filesha256("${local.frontend_dir}/${file}")
]))
}
depends_on = [null_resource.build_frontend]
}
# Création du container Docker
resource "docker_container" "frontend" {
image = docker_image.frontend.image_id
name = local.container_name
ports {
internal = 3000
external = var.frontend_port
}
# Volume pour les logs
volumes {
host_path = "/var/log/enervision"
container_path = "/var/log/nginx"
}
depends_on = [docker_image.frontend]
}
resource "null_resource" "create_network" {
provisioner "remote-exec" {
connection {
type = "ssh"
host = var.ssh_host
port = var.ssh_port
user = var.ssh_user
password = var.ssh_password
private_key = try(file(pathexpand(var.ssh_private_key_path)), null)
timeout = "5m"
}
inline = [
"sudo docker network create enervision_default 2>/dev/null || true"
]
}
}
# Déploiement du container sur le serveur via SSH
resource "null_resource" "deploy_to_server" {
depends_on = [docker_image.frontend]
triggers = {
image_id = docker_image.frontend.image_id
}
connection {
type = "ssh"
host = var.ssh_host
port = var.ssh_port
user = var.ssh_user
password = var.ssh_password
private_key = try(file(pathexpand(var.ssh_private_key_path)), null)
timeout = "5m"
}
# Copie le Dockerfile et nginx.conf
provisioner "file" {
source = "${local.frontend_dir}/Dockerfile"
destination = "/tmp/Dockerfile"
}
provisioner "file" {
source = "${local.frontend_dir}/nginx.conf"
destination = "/tmp/nginx.conf"
}
# Copie les sources pour le build
provisioner "file" {
source = "${local.frontend_dir}/"
destination = "/tmp/frontend/"
}
# Build et lance le container sur le serveur
provisioner "remote-exec" {
inline = [
"cd /tmp/frontend",
"sudo docker stop ${local.container_name} 2>/dev/null || true",
"sudo docker rm ${local.container_name} 2>/dev/null || true",
"sudo docker build -t ${local.image_name}:${local.image_tag} .",
"sudo docker run -d \\",
" --name ${local.container_name} \\",
" --network enervision_default \\",
" --network-alias frontend \\",
" -p ${var.frontend_port}:3000 \\",
" --restart always \\",
" -v /var/log/enervision:/var/log/nginx \\",
" ${local.image_name}:${local.image_tag}"
]
}
}
@@ -0,0 +1,28 @@
module "k3s" {
source = "../../modules/k3s"
ssh_host = var.ssh_host
ssh_port = var.ssh_port
ssh_user = var.ssh_user
ssh_private_key_path = var.ssh_private_key_path
ssh_password = var.ssh_password
k3s_version = var.k3s_version
k3s_disable_components = var.k3s_disable_components
kubeconfig_output_path = var.kubeconfig_output_path
}
provider "vault" {
# Adresse du serveur Vault (dev)
address = "https://10.101.200.37:8200"
# Auth par userpass
auth_login {
path = "auth/userpass/login/${var.vault_username}"
parameters = {
login = var.vault_username
password = var.vault_password
}
}
# Skip verify pour dev uniquement (pas en prod!)
# skip_client_verification = true
}
@@ -0,0 +1,90 @@
variable "ssh_host" {
type = string
description = "Adresse IP ou nom d'hote du serveur on-premise de l'ecole."
}
variable "ssh_port" {
type = number
description = "Port SSH du serveur."
default = 22
}
variable "ssh_user" {
type = string
description = "Utilisateur SSH utilise pour l'installation."
default = "root"
}
variable "ssh_private_key_path" {
type = string
description = "Chemin local vers la cle privee SSH."
sensitive = true
}
variable "ssh_password" {
type = string
sensitive = true
}
variable "remote_path" {
type = string
description = "Chemin distant sur le serveur pour le deploiement."
default = "/var/www/enervision"
}
variable "docker_host" {
type = string
default = "npipe:////.//pipe//docker_engine"
description = "Host Docker (local ou distant)"
}
variable "frontend_port" {
type = number
default = 3000
description = "Port externe du frontend"
}
variable "backend_port" {
type = number
default = 8000
description = "Port externe du backend"
}
variable "k3s_version" {
type = string
description = "Version k3s a epingler pour un deploiement reproductible (ex: v1.31.5+k3s1). Voir https://github.com/k3s-io/k3s/releases."
}
variable "k3s_disable_components" {
type = list(string)
description = "Composants embarques k3s a desactiver."
default = ["traefik"]
}
variable "kubeconfig_output_path" {
type = string
description = "Chemin local ou ecrire le kubeconfig recupere apres installation."
default = "./kubeconfig"
}
variable "deployment_environment" {
type = string
default = "dev"
validation {
condition = contains(["dev", "rec", "prod"], var.deployment_environment)
error_message = "L'environnement doit être dev, rec ou prod."
}
}
variable "vault_username" {
description = "Username Vault"
type = string
sensitive = false
}
variable "vault_password" {
description = "Password Vault"
type = string
sensitive = true
}
@@ -0,0 +1,125 @@
locals {
frontend_environments = {
prod = {
source_dir = "${path.root}/../../../apps/frontend/dist/frontend/browser"
domain = "enervision"
}
}
frontend_dir = "../../../../apps/frontend"
image_name = "enervision-front"
image_tag = "latest"
container_name = "enervision-frontend"
selected_frontend = local.frontend_environments[var.deployment_environment]
}
provider "docker" {
host = var.docker_host
}
# Build le front
resource "null_resource" "build_frontend" {
triggers = {
frontend_files = sha256(join("", [
for file in fileset(local.frontend_dir, "src/**,package.json") :
filesha256("${local.frontend_dir}/${file}")
]))
}
provisioner "local-exec" {
command = "cd ${local.frontend_dir} && npm ci && chmod -R +x node_modules/@angular/cli/bin && chmod -R +x node_modules/.bin && npm exec ng build"
}
}
# Build l'image docker du front
resource "docker_image" "frontend" {
name = "${local.image_name}:${local.image_tag}"
build {
context = local.frontend_dir
dockerfile = "Dockerfile"
}
triggers = {
build_hash = sha256(join("", [
for file in fileset(local.frontend_dir, "**") :
filesha256("${local.frontend_dir}/${file}")
]))
}
depends_on = [null_resource.build_frontend]
}
# Création du container Docker
resource "docker_container" "frontend" {
image = docker_image.frontend.image_id
name = local.container_name
ports {
internal = 3000
external = var.frontend_port
}
# Volume pour les logs
volumes {
host_path = "/var/log/enervision"
container_path = "/var/log/nginx"
}
depends_on = [docker_image.frontend]
}
# Déploiement du container sur le serveur via SSH
resource "null_resource" "deploy_to_server" {
depends_on = [docker_image.frontend]
triggers = {
image_id = docker_image.frontend.image_id
}
connection {
type = "ssh"
host = var.ssh_host
port = var.ssh_port
user = var.ssh_user
password = var.ssh_password
private_key = try(file(pathexpand(var.ssh_private_key_path)), null)
timeout = "5m"
}
# Copie le Dockerfile et nginx.conf
provisioner "file" {
source = "${local.frontend_dir}/Dockerfile"
destination = "/tmp/Dockerfile"
}
provisioner "file" {
source = "${local.frontend_dir}/nginx.conf"
destination = "/tmp/nginx.conf"
}
# Copie les sources pour le build
provisioner "file" {
source = "${local.frontend_dir}/"
destination = "/tmp/frontend/"
}
# Build et lance le container sur le serveur
provisioner "remote-exec" {
inline = [
"cd /tmp/frontend",
"sudo docker stop ${local.container_name} 2>/dev/null || true",
"sudo docker rm ${local.container_name} 2>/dev/null || true",
"sudo docker build -t ${local.image_name}:${local.image_tag} .",
"sudo docker run -d \\",
" --name ${local.container_name} \\",
" --network enervision_default \\",
" --network-alias frontend \\",
" -p ${var.frontend_port}:3000 \\",
" --restart always \\",
" -v /var/log/enervision:/var/log/nginx \\",
" ${local.image_name}:${local.image_tag}"
]
}
}
@@ -0,0 +1,9 @@
output "kubeconfig_path" {
description = "Chemin local du kubeconfig recupere apres installation."
value = module.k3s.kubeconfig_path
}
output "node_host" {
description = "Adresse du serveur sur lequel k3s est installe."
value = module.k3s.node_host
}
@@ -0,0 +1,14 @@
terraform {
required_version = ">= 1.7"
required_providers {
null = {
source = "hashicorp/null"
version = "~> 3.2"
}
}
backend "local" {
path = "terraform.tfstate"
}
}
@@ -0,0 +1,57 @@
locals {
frontend_environments = {
rec = {
source_dir = "${path.root}/../../../apps/frontend/dist/frontend-rec/browser"
domain = "rec.enervision"
}
}
selected_frontend = local.frontend_environments[var.deployment_environment]
}
resource "null_resource" "frontend" {
depends_on = [module.k3s]
triggers = {
environment = var.deployment_environment
build_hash = sha256(join("", [
for file in fileset("${path.root}/../../../apps/frontend/dist/frontend/browser", "**") :
filesha256("${path.root}/../../../apps/frontend/dist/frontend/browser/${file}")
]))
}
// Variables pour la connexion SSH
connection {
type = "ssh"
host = var.ssh_host
port = var.ssh_port
user = var.ssh_user
private_key = file(pathexpand(var.ssh_private_key_path))
}
// Lancement de script en SSH avec remote-exec
// Installation de Nginx et initialisation du répertoire du frontend
provisioner "remote-exec" {
inline = [
"sudo apt-get update",
"sudo apt-get install -y nginx",
"sudo mkdir -p /var/www/enervision",
"sudo rm -rf /var/www/enervision/*"
]
}
// Copie des fichiers vers le serveur
provisioner "file" {
source = "${path.root}/../../../apps/frontend/dist/frontend/browser/"
destination = "/tmp/enervision-frontend"
}
// Déplacement des fichiers
provisioner "remote-exec" {
inline = [
"sudo cp -r /tmp/enervision-frontend/* /var/www/enervision/",
"sudo chown -R www-data:www-data /var/www/enervision"
]
}
}
+11
View File
@@ -0,0 +1,11 @@
module "k3s" {
source = "../../modules/k3s"
ssh_host = var.ssh_host
ssh_port = var.ssh_port
ssh_user = var.ssh_user
ssh_private_key_path = var.ssh_private_key_path
k3s_version = var.k3s_version
k3s_disable_components = var.k3s_disable_components
kubeconfig_output_path = var.kubeconfig_output_path
}
@@ -0,0 +1,9 @@
output "kubeconfig_path" {
description = "Chemin local du kubeconfig recupere apres installation."
value = module.k3s.kubeconfig_path
}
output "node_host" {
description = "Adresse du serveur sur lequel k3s est installe."
value = module.k3s.node_host
}
@@ -0,0 +1,39 @@
variable "ssh_host" {
type = string
description = "Adresse IP ou nom d'hote du serveur on-premise de l'ecole."
}
variable "ssh_port" {
type = number
description = "Port SSH du serveur."
default = 22
}
variable "ssh_user" {
type = string
description = "Utilisateur SSH utilise pour l'installation."
default = "root"
}
variable "ssh_private_key_path" {
type = string
description = "Chemin local vers la cle privee SSH."
sensitive = true
}
variable "k3s_version" {
type = string
description = "Version k3s a epingler pour un deploiement reproductible (ex: v1.31.5+k3s1). Voir https://github.com/k3s-io/k3s/releases."
}
variable "k3s_disable_components" {
type = list(string)
description = "Composants embarques k3s a desactiver."
default = ["traefik"]
}
variable "kubeconfig_output_path" {
type = string
description = "Chemin local ou ecrire le kubeconfig recupere apres installation."
default = "./kubeconfig"
}
@@ -0,0 +1,14 @@
terraform {
required_version = ">= 1.7"
required_providers {
null = {
source = "hashicorp/null"
version = "~> 3.2"
}
}
backend "local" {
path = "terraform.tfstate"
}
}
+23
View File
@@ -0,0 +1,23 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/null" {
version = "3.3.2"
constraints = "~> 3.2"
hashes = [
"h1:IQ1qrkht1sC1nibUR+AJ3ulryyhVDHfCHZhoJi0sg2Y=",
"zh:10ec43b8b7b18d5639238c7fb9e111f6a4b038523dd66c7a426bf27b25fa4c08",
"zh:60beb9cc2ad5b871c710860cee75b42850cc6acd43db0d77cb5e00fda7288b55",
"zh:62538582d0a4a2f10ad8a8d9a6c3cd3f05af6c6d91c6641ffc78d4f0e8e69b27",
"zh:64a8f9ce7852d9efc5b464c12306c946366d59f5e2757def97969c9fd64bd1d6",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:92a374fb736a52f465283326d0a5bf4f495132eb99be209dfb4c75ec803fe8db",
"zh:98da9c42785d27a50f0604758bcb61a30f6278b9f2acd92bb3b2046e0e71916c",
"zh:b0f7896fae554729cdf4a24ac06359a050cff5817e6cd8597cba8a4ae01a7409",
"zh:bc8179ee35d67c72fb03012e7023b9f9816f033a7ec4109c001dd6d29752e812",
"zh:d23a598f713bfb6098bc003571d7de90b5a33b78f9be240488252fe5f3c2a60d",
"zh:d2855b922ea345dbd89ea287e4c6c4757e38bc0aaffeb2b79aa0b8004f9c53ff",
"zh:d3a60422bc6a2f9244d076c5222c07060c826ef91bdbaf4634cb752b86057473",
"zh:faa01928c25d2a6ecd9c7eb8b88134cb08de55a6b11ca6c703ac0092845344ba",
]
}
+137
View File
@@ -0,0 +1,137 @@
# Pourquoi : Terraform provisionne la machine, GitHub Actions la deploie (ADR 0010). Rien ici ne
# construit d'image ni ne lance de conteneur : la livraison reste portee par `deploy.yml` et
# `make stack-up`, et un `apply` n'interrompt pas la stack qui tourne.
# Piege : seul le bloc `triggers` d'un `null_resource` atterrit dans le state. Ni le jeton du
# runner ni la cle SSH n'y figurent, et ne doivent jamais y etre ajoutes pour forcer un rejeu.
# 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
# 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.
locals {
sudo = var.ssh_user == "root" ? "" : "sudo "
en_tant_que = "${var.ssh_user == "root" ? "" : "sudo "}runuser -u ${var.proprietaire} --"
provisionneur = "${path.root}/../../../../scripts/provision-host.sh"
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
# le depot, le nom d'hote l'est deja et le reste si cette racine sert a une autre machine.
runner_nom = var.runner_nom != "" ? var.runner_nom : "$(hostname -s)"
}
resource "null_resource" "docker_engine" {
triggers = {
hote = var.ssh_host
user = var.proprietaire
}
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 "remote-exec" {
inline = [
<<-EOT
set -eu
id ${var.proprietaire} >/dev/null 2>&1 || {
echo "l'utilisateur ${var.proprietaire} n'existe pas sur la machine" >&2
exit 1
}
command -v docker >/dev/null || ${local.sudo}sh -c 'curl -fsSL https://get.docker.com | sh'
${local.sudo}systemctl enable --now docker
${local.sudo}usermod -aG docker ${var.proprietaire}
${local.sudo}docker compose version
EOT
]
}
}
# `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
# jamais reecrit, un certificat present jamais regenere.
resource "null_resource" "environnements" {
depends_on = [null_resource.docker_engine]
triggers = {
script = filesha256(local.provisionneur)
racine = var.racine
depot = var.depot_url
}
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.provisionneur
destination = "/tmp/provision-host.sh"
}
provisioner "remote-exec" {
inline = [
<<-EOT
set -eu
${local.sudo}env RACINE='${var.racine}' \
REPO_URL='${var.depot_url}' \
PROPRIETAIRE='${var.proprietaire}' \
PUBLIC_IP='${var.adresse_publique}' \
bash /tmp/provision-host.sh
rm -f /tmp/provision-host.sh
EOT
]
}
}
# Piege : le jeton d'enregistrement expire en une heure. Un `apply` rejoue cette ressource des
# que `runner_version`, `runner_labels` ou `runner_nom` change, et redemande donc un jeton frais.
resource "null_resource" "runner_github" {
depends_on = [null_resource.environnements]
triggers = {
version = var.runner_version
labels = var.runner_labels
nom = local.runner_nom
dossier = var.runner_dossier
}
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 "remote-exec" {
inline = [
<<-EOT
set -eu
${local.sudo}install -d -o ${var.proprietaire} -g ${var.proprietaire} ${var.runner_dossier}
if [ ! -x ${var.runner_dossier}/config.sh ]; then
curl -fsSL -o /tmp/${local.runner_archive} \
https://github.com/actions/runner/releases/download/v${var.runner_version}/${local.runner_archive}
${local.sudo}tar -xzf /tmp/${local.runner_archive} -C ${var.runner_dossier}
${local.sudo}chown -R ${var.proprietaire}:${var.proprietaire} ${var.runner_dossier}
rm -f /tmp/${local.runner_archive}
fi
if [ ! -f ${var.runner_dossier}/.runner ]; then
${local.en_tant_que} sh -c 'cd ${var.runner_dossier} && ./config.sh --unattended --replace \
--url ${var.runner_url} --token ${var.runner_token} \
--labels ${var.runner_labels} --name ${local.runner_nom} --work _work'
${local.sudo}${var.runner_dossier}/svc.sh install ${var.proprietaire}
fi
${local.sudo}${var.runner_dossier}/svc.sh start
EOT
]
}
}
@@ -0,0 +1,9 @@
output "machine" {
description = "Machine provisionnee et racine qui porte un clone par environnement."
value = "${var.ssh_user}@${var.ssh_host}:${var.racine}"
}
output "runner" {
description = "Dossier d'installation du runner et libelles supplementaires annonces a GitHub."
value = "${var.runner_dossier} (${var.runner_labels})"
}
@@ -0,0 +1,22 @@
ssh_host = "10.101.200.37"
ssh_port = 22
ssh_user = "root"
ssh_private_key_path = "~/.ssh/id_ed25519"
# Utilisateur qui possede la racine et fait tourner le runner. Il doit deja exister sur la
# machine : git refuse les depots appartenant a un autre utilisateur, et un .env en 600 lui
# echapperait.
proprietaire = "enervision"
# Epingler une version reelle : https://github.com/actions/runner/releases
runner_version = "2.330.0"
# Jeton d'enregistrement du runner, valable une heure et pour une seule inscription :
# Parametres du depot > Actions > Runners > New self-hosted runner. Seul un administrateur du
# depot peut le creer. terraform.tfvars est ignore par git, mais le jeton ne doit pas y rester
# apres l'apply.
runner_token = "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.
# runner_nom = "eni-g3-bis"
@@ -0,0 +1,90 @@
variable "ssh_host" {
type = string
description = "Adresse de la VM ENI qui porte les deux environnements (ADR 0009)."
}
variable "ssh_port" {
type = number
description = "Port SSH de la VM."
default = 22
}
variable "ssh_user" {
type = string
description = "Utilisateur SSH du provisionnement. Different de root, les commandes privilegiees sont prefixees par sudo."
default = "root"
}
variable "ssh_private_key_path" {
type = string
description = "Chemin local vers la cle privee SSH. L'authentification par mot de passe n'est volontairement pas prise en charge : une variable de mot de passe finit en clair dans le state ou dans les triggers."
sensitive = true
}
variable "proprietaire" {
type = string
description = "Utilisateur qui possede la racine et fait tourner le runner. Il doit exister sur la machine : git refuse les depots appartenant a un autre utilisateur, et un .env en 600 lui echapperait."
validation {
condition = can(regex("^[a-z_][a-z0-9_-]*$", var.proprietaire))
error_message = "proprietaire doit etre un nom d'utilisateur Unix valide."
}
}
variable "racine" {
type = string
description = "Dossier qui porte un clone du depot par environnement."
default = "/srv/enervision"
}
variable "depot_url" {
type = string
description = "URL de clonage du depot, passee a provision-host.sh."
default = "https://github.com/ineszang/ProjetPiscine_EnerVision.git"
}
variable "adresse_publique" {
type = string
description = "Adresse annoncee dans les certificats auto-signes. Vide : la premiere adresse de la VM."
default = ""
}
variable "runner_url" {
type = string
description = "Depot GitHub auquel le runner s'enregistre."
default = "https://github.com/ineszang/ProjetPiscine_EnerVision"
}
variable "runner_version" {
type = string
description = "Version d'actions-runner a installer, sans le v initial (ex: 2.330.0). Voir https://github.com/actions/runner/releases."
validation {
condition = can(regex("^[0-9]+\\.[0-9]+\\.[0-9]+$", var.runner_version))
error_message = "runner_version doit etre epinglee explicitement (ex: 2.330.0), sinon l'installation cesse d'etre reproductible."
}
}
variable "runner_token" {
type = string
description = "Jeton d'enregistrement du runner. Expire au bout d'une heure et ne vaut que pour une inscription : Parametres du depot > Actions > Runners > New self-hosted runner. Seul un administrateur du depot peut le creer."
sensitive = true
}
variable "runner_labels" {
type = string
description = "Libelles supplementaires du runner. deploy.yml cible [self-hosted, linux, eni-g3], les deux premiers etant poses par GitHub."
default = "eni-g3"
}
variable "runner_nom" {
type = string
description = "Nom du runner cote GitHub, unique dans le depot. Vide : le nom d'hote de la machine, qui reste unique si cette racine est reprise pour une seconde VM. A renseigner pour faire tourner deux runners sur la meme machine."
default = ""
}
variable "runner_dossier" {
type = string
description = "Dossier d'installation du runner sur la machine."
default = "/opt/actions-runner"
}
@@ -0,0 +1,16 @@
terraform {
required_version = ">= 1.7"
required_providers {
archive = {
source = "hashicorp/archive"
version = "~> 2.4"
}
docker = {
source = "kreuzwerker/docker"
version = "~> 4.5.0"
}
}
}
+30 -13
View File
@@ -5,19 +5,33 @@ locals {
kubeconfig_cmd = "${local.sudo_prefix}cat /etc/rancher/k3s/k3s.yaml" kubeconfig_cmd = "${local.sudo_prefix}cat /etc/rancher/k3s/k3s.yaml"
} }
# Piege : un provisioner `destroy` impose que tout le bloc `connection` ne lise que `self`, sinon
# `terraform init` refuse le module. D'ou la connexion batie sur `triggers`, ou ne figurent que
# l'adresse, le port, l'utilisateur et le chemin de la cle : jamais la cle ni un mot de passe.
resource "null_resource" "k3s_install" { resource "null_resource" "k3s_install" {
triggers = { triggers = {
ssh_host = var.ssh_host ssh_host = var.ssh_host
k3s_version = var.k3s_version ssh_port = tostring(var.ssh_port)
disable_components = join(",", var.k3s_disable_components) ssh_user = var.ssh_user
ssh_password = var.ssh_password
k3s_version = var.k3s_version
disable_components = join(",", var.k3s_disable_components)
sudo_prefix = local.sudo_prefix
ssh_host = var.ssh_host
ssh_port = tostring(var.ssh_port)
ssh_user = var.ssh_user
ssh_key_path = var.ssh_private_key_path
sudo_prefix = local.sudo_prefix
k3s_version = var.k3s_version
disable_components = join(",", var.k3s_disable_components)
} }
connection { connection {
type = "ssh" type = "ssh"
host = var.ssh_host host = self.triggers.ssh_host
port = var.ssh_port port = self.triggers.ssh_port
user = var.ssh_user user = self.triggers.ssh_user
private_key = file(var.ssh_private_key_path) password = self.triggers.ssh_password
} }
provisioner "remote-exec" { provisioner "remote-exec" {
@@ -33,7 +47,7 @@ resource "null_resource" "k3s_install" {
when = destroy when = destroy
on_failure = continue on_failure = continue
inline = [ inline = [
"${local.sudo_prefix}sh -c 'test -x /usr/local/bin/k3s-uninstall.sh && /usr/local/bin/k3s-uninstall.sh || true'", "${self.triggers.sudo_prefix}sh -c 'test -x /usr/local/bin/k3s-uninstall.sh && /usr/local/bin/k3s-uninstall.sh || true'",
] ]
} }
} }
@@ -46,10 +60,13 @@ resource "null_resource" "fetch_kubeconfig" {
} }
provisioner "local-exec" { provisioner "local-exec" {
interpreter = ["bash", "-c"] interpreter = ["PowerShell", "-NoProfile", "-Command"]
command = <<-EOT
ssh -i "${var.ssh_private_key_path}" -p ${var.ssh_port} -o StrictHostKeyChecking=accept-new ${var.ssh_user}@${var.ssh_host} '${local.kubeconfig_cmd}' \ command = <<-EOT
| sed 's/127.0.0.1/${var.ssh_host}/' > "${var.kubeconfig_output_path}" $content = ssh -i "${pathexpand(var.ssh_private_key_path)}" -p ${var.ssh_port} -o StrictHostKeyChecking=accept-new ${var.ssh_user}@${var.ssh_host} "cat /etc/rancher/k3s/k3s.yaml"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$content -replace "127.0.0.1", "${var.ssh_host}" |
Set-Content -Path "${var.kubeconfig_output_path}" -Encoding UTF8
EOT EOT
} }
} }
+5
View File
@@ -21,6 +21,11 @@ variable "ssh_private_key_path" {
sensitive = true sensitive = true
} }
variable "ssh_password" {
type = string
sensitive = true
}
variable "k3s_version" { variable "k3s_version" {
type = string type = string
description = "Version k3s a epingler pour un deploiement reproductible (ex: v1.31.5+k3s1). Voir https://github.com/k3s-io/k3s/releases." description = "Version k3s a epingler pour un deploiement reproductible (ex: v1.31.5+k3s1). Voir https://github.com/k3s-io/k3s/releases."
+3 -3
View File
@@ -2,9 +2,9 @@ terraform {
required_version = ">= 1.7" required_version = ">= 1.7"
required_providers { required_providers {
null = { provider = {
source = "hashicorp/null" source = "hashicorp/archive"
version = "~> 3.2" version = "~> 2.4"
} }
} }
} }
+6
View File
@@ -0,0 +1,6 @@
.venv
data
mlruns
mlflow.db*
models
.env
+1
View File
@@ -0,0 +1 @@
MLFLOW_DB_PASSWORD=change-me
+7
View File
@@ -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
+68 -6
View File
@@ -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.
@@ -111,12 +162,23 @@ Depuis la racine du monorepo, via le `Makefile` : `make install-ml`, `make ml-li
## Ou ecrire les tests ## Ou ecrire les tests
Aucun test ne touche PostgreSQL ni un serveur MLflow distant : `enervision_ml.data.load_from_csv` Deux regimes, separes par le marqueur `integration` que `pytest` ecarte par defaut.
et le chargement CSV de test suffisent a exercer `build_features` sur des donnees reelles ou
synthetiques, et `enervision_ml.train.train()` accepte un `tracking_uri` SQLite isole (`tmp_path` **Sans base** : `enervision_ml.data.load_from_csv` et le chargement CSV de test suffisent a
pytest) pour un test de bout en bout sans effet de bord. `enervision_ml.data.load_from_database` exercer `build_features` sur des donnees reelles ou synthetiques, et `enervision_ml.train.train()`
n'est pas encore couvert : il n'existe aucune base PostgreSQL a interroger en CI ni dans cet accepte un `tracking_uri` SQLite isole (`tmp_path` pytest) pour un test de bout en bout sans effet
environnement de developpement pour le moment. de bord.
**Avec base**, sous `integration` : `test_data_integration.py` confronte les neuf colonnes du
contrat au schema Alembic reel, et `test_score_integration.py` verifie les contraintes de
`prediction` depuis le code qui ecrit. Les fixtures sont dans `tests/conftest.py`, qui refuse de
demarrer si `ML_DATABASE_URL` ne vise pas `enervision_test`.
make db-up migrate-test ml-test-integration
Regle a tenir : **toute requete SQL nouvelle porte un test `integration`**. Le schema vit dans
`apps/backend/alembic`, pas ici : sans ce garde-fou, une migration qui renomme une colonne casse
le pipeline en production sans qu'aucun test ne rougisse.
## Piege a connaitre ## Piege a connaitre
+32
View File
@@ -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:
+30 -8
View File
@@ -43,6 +43,10 @@ NUMERIC_COLUMNS = [
"capacity_kw", "capacity_kw",
] ]
# Piege : `is_working_hours` est nullable et entre dans les features. Toujours `float64`, jamais
# `bool` : `astype(bool)` ferait un `True` d'une absence, et les deux chargeurs divergeraient.
FLAG_COLUMNS = ["is_working_hours"]
_READING_QUERY = text( _READING_QUERY = text(
""" """
SELECT SELECT
@@ -76,7 +80,7 @@ _RECENT_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
WHERE r.timestamp >= :since WHERE r.timestamp >= :since AND r.timestamp <= :until
ORDER BY r.site_id, r.timestamp ORDER BY r.site_id, r.timestamp
""" """
) )
@@ -90,22 +94,34 @@ def load_from_database(connection: Connectable) -> pd.DataFrame:
return _typer(frame[OUTPUT_COLUMNS]) return _typer(frame[OUTPUT_COLUMNS])
def load_recent_from_database(connection: Connectable, *, since: datetime) -> pd.DataFrame: def load_recent_from_database(
"""Lit `reading` + `site` depuis `since` seulement, pour le scoring. connection: Connectable, *, since: datetime, until: datetime
) -> pd.DataFrame:
"""Lit `reading` + `site` sur la fenetre `[since, until]`, pour le scoring.
Piege evite : un `SELECT` sans borne sur l'hypertable complete juste pour scorer le prochain Piege evite cote bas : un `SELECT` sans borne sur l'hypertable complete juste pour scorer le
pas horaire serait la meme erreur que celle corrigee sur `GET /readings` (fenetre non prochain pas horaire serait la meme erreur que celle corrigee sur `GET /readings` (fenetre non
plafonnee sur une table pouvant porter des annees d'historique). plafonnee sur une table pouvant porter des annees d'historique).
Piege evite cote haut : `until` est obligatoire, et c'est ce qui donne son sens a `--now`.
Sans lui, `build_scoring_frame` repartait de la derniere lecture de toute la table quel que
soit l'instant demande, donc `target_at` valait toujours "fin du jeu + 1h" et l'age de la
derniere lecture devenait negatif sans que rien ne le signale.
""" """
frame = pd.read_sql(_RECENT_READING_QUERY, connection, params={"since": since}) frame = pd.read_sql(_RECENT_READING_QUERY, connection, params={"since": since, "until": until})
return _typer(frame[OUTPUT_COLUMNS]) return _typer(frame[OUTPUT_COLUMNS])
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])
@@ -120,6 +136,10 @@ 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
@@ -128,4 +148,6 @@ def _typer(frame: pd.DataFrame) -> pd.DataFrame:
typee = frame.copy() typee = frame.copy()
for colonne in NUMERIC_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
+3 -2
View File
@@ -204,7 +204,8 @@ def _load_recent_from_csv(csv_path: Path, *, now: datetime | None) -> tuple[pd.D
instant = now or ( instant = now or (
brute["timestamp"].max().to_pydatetime() if not brute.empty else datetime.now(UTC) brute["timestamp"].max().to_pydatetime() if not brute.empty else datetime.now(UTC)
) )
return brute[brute["timestamp"] >= instant - LOOKBACK], instant fenetre = (brute["timestamp"] >= instant - LOOKBACK) & (brute["timestamp"] <= instant)
return brute[fenetre], instant
def _score_frame( def _score_frame(
@@ -240,7 +241,7 @@ def run_scoring(
engine = create_engine(config.database_url()) engine = create_engine(config.database_url())
try: try:
instant = now or datetime.now(UTC) instant = now or datetime.now(UTC)
recent = load_recent_from_database(engine, since=instant - LOOKBACK) recent = load_recent_from_database(engine, since=instant - LOOKBACK, until=instant)
resultats = _score_frame(recent, model_path=model_path, site_id=site_id, instant=instant) resultats = _score_frame(recent, model_path=model_path, site_id=site_id, instant=instant)
reference = model_reference(model_path) reference = model_reference(model_path)
+5 -1
View File
@@ -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))
View File
+332
View File
@@ -0,0 +1,332 @@
"""Piege : deux fixtures d'acces a la base, jamais interchangeables - `connexion_ml` et `parc`.
`connexion_ml` ouvre une transaction annulee a la fin du test : rien ne subsiste, et rien n'est
visible hors de cette connexion. Elle sert aux fonctions qui recoivent leur connexion en
argument (`load_from_database`, `load_recent_from_database`, `write_predictions`).
`run_scoring` fabrique en revanche son propre engine depuis `ML_DATABASE_URL` : il ne verrait
pas des lignes semees dans une transaction non validee, et ses propres ecritures survivraient a
l'annulation. Les tests qui l'appellent passent donc par `parc`, qui valide ce qu'il ecrit et
nettoie lui-meme, dans l'ordre impose par les cles etrangeres `RESTRICT`.
"""
import math
import os
from collections.abc import Iterator
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from uuid import uuid4
import lightgbm as lgb
import pandas as pd
import pytest
from sqlalchemy import Connection, Engine, Row, bindparam, create_engine, text
from sqlalchemy.engine import URL, make_url
from enervision_ml.features import TARGET_COLUMN, build_features, feature_columns
BASE_ATTENDUE = "enervision_test"
# Piege : `load_from_database` lit toute la table, et `enervision_test` est partagee entre un run
# local et la CI. Les tests ancrent donc leurs lectures au-dela de tout jeu de donnees reel
# (l'historique s'arrete au 31/12/2024) pour que leur borne `since` ne ramene qu'eux.
ANCRAGE = datetime(2035, 1, 1, tzinfo=UTC)
SITE_TYPE = "office"
CAPACITY_KW = 100.0
_INSERT_SITE = text(
"""
INSERT INTO site (site_id, site_name, site_type, capacity_kw)
VALUES (:site_id, :site_name, :site_type, :capacity_kw)
"""
)
# `source = 'api_history'` impose `dataset_id IS NULL` (ck_reading_dataset_source), ce qui evite
# de creer une ligne `dataset`. `raw_data` est NOT NULL, d'ou le litteral jsonb.
_INSERT_READING = text(
"""
INSERT INTO reading (
site_id, timestamp, source, consumption_kwh, temperature_celsius,
humidity_percent, solar_irradiance_wm2, is_working_hours, raw_data
) VALUES (
:site_id, :timestamp, :source, :consumption_kwh, :temperature_celsius,
:humidity_percent, :solar_irradiance_wm2, :is_working_hours, '{}'::jsonb
)
"""
)
_SELECT_PREDICTIONS = text(
"""
SELECT target_at, predicted_value, status, failure_reason, model_reference
FROM prediction
WHERE site_id = :site_id
ORDER BY prediction_id
"""
)
_INSERT_PREDICTION = text(
"""
INSERT INTO prediction (
site_id, target_at, target_metric, period_minutes,
predicted_value, model_reference, status, failure_reason
) VALUES (
:site_id, :target_at, 'consumption_kwh', 60,
:predicted_value, :model_reference, :status, :failure_reason
)
"""
)
# Ordre impose par les cles etrangeres `RESTRICT` : une lecture avant son site, une prediction
# avant sa lecture.
_SUPPRESSIONS = tuple(
text(requete).bindparams(bindparam("sites", expanding=True))
for requete in (
"DELETE FROM prediction WHERE site_id IN :sites",
"DELETE FROM reading WHERE site_id IN :sites",
"DELETE FROM site WHERE site_id IN :sites",
)
)
def insere_site(
connexion: Connection,
*,
site_type: str = SITE_TYPE,
capacity_kw: float | None = CAPACITY_KW,
) -> str:
site_id = f"TEST-{uuid4().hex[:12]}"
connexion.execute(
_INSERT_SITE,
{
"site_id": site_id,
"site_name": "Site de test",
"site_type": site_type,
"capacity_kw": capacity_kw,
},
)
return site_id
def insere_lectures(
connexion: Connection,
site_id: str,
*,
heures: int,
fin: datetime,
valeur: float = 50.0,
source: str = "api_history",
is_working_hours: bool | None = True,
) -> list[datetime]:
"""Grille horaire contigue finissant a `fin`, incluse.
Contigue parce que les lags de `build_features` sont des `shift()` positionnels : un trou
dans la grille decalerait le lag de 168 h sans qu'aucune erreur ne se declenche.
"""
instants = [fin - timedelta(hours=decalage) for decalage in reversed(range(heures))]
connexion.execute(
_INSERT_READING,
[
{
"site_id": site_id,
"timestamp": instant,
"source": source,
"consumption_kwh": valeur + math.sin(rang / 12.0) * 10.0,
"temperature_celsius": 15.0,
"humidity_percent": 50.0,
"solar_irradiance_wm2": 0.0,
"is_working_hours": is_working_hours,
}
for rang, instant in enumerate(instants)
],
)
return instants
def insere_lecture(
connexion: Connection,
site_id: str,
*,
instant: datetime,
consumption_kwh: float | None = 50.0,
source: str = "api_history",
is_working_hours: bool | None = True,
) -> None:
"""Une lecture isolee, quand le test pilote sa valeur plutot que sa forme."""
connexion.execute(
_INSERT_READING,
{
"site_id": site_id,
"timestamp": instant,
"source": source,
"consumption_kwh": consumption_kwh,
"temperature_celsius": 15.0,
"humidity_percent": 50.0,
"solar_irradiance_wm2": 0.0,
"is_working_hours": is_working_hours,
},
)
def insere_prediction(
connexion: Connection,
site_id: str,
*,
target_at: datetime,
predicted_value: float | None = 42.0,
model_reference: str = "lightgbm-test000000",
status: str = "available",
failure_reason: str | None = None,
) -> None:
connexion.execute(
_INSERT_PREDICTION,
{
"site_id": site_id,
"target_at": target_at,
"predicted_value": predicted_value,
"model_reference": model_reference,
"status": status,
"failure_reason": failure_reason,
},
)
@pytest.fixture(scope="session")
def url_ml() -> URL:
valeur = os.environ.get("ML_DATABASE_URL")
if not valeur:
pytest.fail("ML_DATABASE_URL absente. Voir `make ml-test-integration`.")
url = make_url(valeur)
if url.database != BASE_ATTENDUE:
pytest.fail(
f"Ces tests ecrivent et suppriment : ML_DATABASE_URL doit viser {BASE_ATTENDUE}, "
f"pas {url.database}."
)
return url
@pytest.fixture(scope="session")
def moteur_ml(url_ml: URL) -> Iterator[Engine]:
moteur = create_engine(url_ml)
try:
yield moteur
finally:
moteur.dispose()
@pytest.fixture
def connexion_ml(moteur_ml: Engine) -> Iterator[Connection]:
with moteur_ml.connect() as connexion:
transaction = connexion.begin()
try:
yield connexion
finally:
transaction.rollback()
@dataclass
class Parc:
"""Semis valide en base, et son nettoyage, pour les tests qui appellent `run_scoring`.
Chaque `site_id` porte une marque unique : la base de test est partagee entre un run local
et la CI.
"""
moteur: Engine
sites: list[str] = field(default_factory=list)
def site(self, *, site_type: str = SITE_TYPE, capacity_kw: float | None = CAPACITY_KW) -> str:
with self.moteur.begin() as connexion:
site_id = insere_site(connexion, site_type=site_type, capacity_kw=capacity_kw)
self.sites.append(site_id)
return site_id
def lectures(self, site_id: str, **arguments: Any) -> list[datetime]:
with self.moteur.begin() as connexion:
return insere_lectures(connexion, site_id, **arguments)
def lecture(self, site_id: str, **arguments: Any) -> None:
with self.moteur.begin() as connexion:
insere_lecture(connexion, site_id, **arguments)
def prediction(self, site_id: str, **arguments: Any) -> None:
with self.moteur.begin() as connexion:
insere_prediction(connexion, site_id, **arguments)
def predictions_ecrites(self, site_id: str) -> list[Row[Any]]:
with self.moteur.connect() as connexion:
return list(connexion.execute(_SELECT_PREDICTIONS, {"site_id": site_id}))
def nettoie(self) -> None:
if not self.sites:
return
with self.moteur.begin() as connexion:
for suppression in _SUPPRESSIONS:
connexion.execute(suppression, {"sites": self.sites})
@pytest.fixture
def parc(moteur_ml: Engine) -> Iterator[Parc]:
semis = Parc(moteur=moteur_ml)
try:
yield semis
finally:
semis.nettoie()
def trame_synthetique(*, sites: int = 2, heures: int = 400) -> pd.DataFrame:
"""Lectures horaires deterministes, assez longues pour que le lag de 168 h existe."""
depart = datetime(2024, 1, 1, tzinfo=UTC)
morceaux = [
pd.DataFrame(
{
"site_id": f"SITE{numero:03d}",
"timestamp": [depart + timedelta(hours=rang) for rang in range(heures)],
TARGET_COLUMN: [
50.0 + 10.0 * math.sin(rang / 12.0) + numero * 5.0 for rang in range(heures)
],
"temperature_celsius": 15.0,
"humidity_percent": 50.0,
"solar_irradiance_wm2": 0.0,
"is_working_hours": True,
"site_type": SITE_TYPE,
"capacity_kw": CAPACITY_KW,
}
)
for numero in range(sites)
]
return pd.concat(morceaux, ignore_index=True)
@pytest.fixture(scope="session")
def modele_jetable(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Booster reel entraine sur une trame synthetique, ecrit dans un repertoire temporaire.
Ni `ml/models/` (ignore par git, et le polluer serait un effet de bord), ni
`enervision_ml.train.train()` (qui journalise dans MLflow sans garde). Le typage `category`
de `site_type` reproduit celui de l'entrainement : c'est le `pandas_categorical` enregistre
dans le modele que `score()` devra retrouver.
"""
features = build_features(trame_synthetique()).dropna(subset=feature_columns())
typee = features.copy()
typee["site_type"] = typee["site_type"].astype("category")
donnees = lgb.Dataset(
typee[feature_columns()],
label=typee[TARGET_COLUMN],
categorical_feature=["site_type"],
)
booster = lgb.train(
{"objective": "regression", "num_leaves": 7, "min_data_in_leaf": 5, "verbosity": -1},
donnees,
num_boost_round=5,
)
chemin = tmp_path_factory.mktemp("modele") / "lightgbm-consumption.txt"
booster.save_model(str(chemin))
return chemin
+42
View File
@@ -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]
+198
View File
@@ -0,0 +1,198 @@
from datetime import timedelta
from pathlib import Path
import pandas as pd
import pytest
from sqlalchemy import Connection
from enervision_ml.data import (
OUTPUT_COLUMNS,
load_from_csv,
load_from_database,
load_recent_from_database,
)
from tests.conftest import ANCRAGE, insere_lecture, insere_lectures, insere_site
pytestmark = pytest.mark.integration
def test_load_from_database_returns_the_nine_contract_columns(connexion_ml: Connection) -> None:
site_id = insere_site(connexion_ml)
insere_lectures(connexion_ml, site_id, heures=3, fin=ANCRAGE)
frame = load_from_database(connexion_ml)
assert list(frame.columns) == OUTPUT_COLUMNS
def test_load_from_database_joins_the_site_attributes_to_every_reading(
connexion_ml: Connection,
) -> None:
site_id = insere_site(connexion_ml, site_type="factory", capacity_kw=250.0)
insere_lectures(connexion_ml, site_id, heures=3, fin=ANCRAGE)
frame = load_from_database(connexion_ml)
mien = frame[frame["site_id"] == site_id]
assert len(mien) == 3
assert set(mien["site_type"]) == {"factory"}
assert set(mien["capacity_kw"]) == {250.0}
def test_load_recent_from_database_excludes_readings_before_the_since_bound(
connexion_ml: Connection,
) -> None:
site_id = insere_site(connexion_ml)
insere_lectures(connexion_ml, site_id, heures=5, fin=ANCRAGE)
frame = load_recent_from_database(
connexion_ml, since=ANCRAGE - timedelta(hours=2), until=ANCRAGE
)
assert list(frame["timestamp"]) == [
ANCRAGE - timedelta(hours=2),
ANCRAGE - timedelta(hours=1),
ANCRAGE,
]
def test_load_recent_from_database_includes_a_reading_exactly_at_the_since_bound(
connexion_ml: Connection,
) -> None:
site_id = insere_site(connexion_ml)
insere_lecture(connexion_ml, site_id, instant=ANCRAGE)
frame = load_recent_from_database(
connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3)
)
assert len(frame) == 1
def test_load_recent_from_database_keeps_timestamps_timezone_aware(
connexion_ml: Connection,
) -> None:
site_id = insere_site(connexion_ml)
insere_lecture(connexion_ml, site_id, instant=ANCRAGE)
frame = load_recent_from_database(
connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3)
)
assert frame["timestamp"].dt.tz is not None
def test_load_recent_from_database_orders_readings_by_site_then_timestamp(
connexion_ml: Connection,
) -> None:
site_id = insere_site(connexion_ml)
for decalage in (2, 0, 1):
insere_lecture(connexion_ml, site_id, instant=ANCRAGE + timedelta(hours=decalage))
frame = load_recent_from_database(
connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3)
)
assert list(frame["timestamp"]) == [
ANCRAGE,
ANCRAGE + timedelta(hours=1),
ANCRAGE + timedelta(hours=2),
]
def test_load_recent_from_database_returns_the_contract_columns_even_without_any_row(
connexion_ml: Connection,
) -> None:
frame = load_recent_from_database(
connexion_ml, since=ANCRAGE + timedelta(days=365), until=ANCRAGE + timedelta(days=400)
)
assert frame.empty
assert list(frame.columns) == OUTPUT_COLUMNS
def test_load_recent_from_database_types_a_fully_null_capacity_kw_as_float64(
connexion_ml: Connection,
) -> None:
site_id = insere_site(connexion_ml, capacity_kw=None)
insere_lectures(connexion_ml, site_id, heures=3, fin=ANCRAGE)
frame = load_recent_from_database(
connexion_ml, since=ANCRAGE - timedelta(hours=2), until=ANCRAGE
)
assert frame["capacity_kw"].dtype == "float64"
assert frame["capacity_kw"].isna().all()
def test_load_recent_from_database_types_a_null_is_working_hours_as_float64(
connexion_ml: Connection,
) -> None:
site_id = insere_site(connexion_ml)
insere_lecture(connexion_ml, site_id, instant=ANCRAGE, is_working_hours=None)
insere_lecture(
connexion_ml, site_id, instant=ANCRAGE + timedelta(hours=1), is_working_hours=True
)
frame = load_recent_from_database(
connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3)
)
assert frame["is_working_hours"].dtype == "float64"
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(
connexion_ml: Connection, tmp_path: Path
) -> None:
site_id = insere_site(connexion_ml)
insere_lectures(connexion_ml, site_id, heures=2, fin=ANCRAGE)
csv_path = tmp_path / "lectures.csv"
pd.DataFrame(
{
"site_id": [site_id],
"timestamp": [ANCRAGE],
"consumption_kwh": [50.0],
"temperature_celsius": [15.0],
"humidity_percent": [50.0],
"solar_irradiance_wm2": [0.0],
"is_working_hours": [True],
"site_type": ["office"],
}
).to_csv(csv_path, index=False)
depuis_la_base = load_recent_from_database(
connexion_ml, since=ANCRAGE - timedelta(hours=1), until=ANCRAGE
)
depuis_le_csv = load_from_csv(csv_path)
assert list(depuis_la_base.columns) == list(depuis_le_csv.columns)
assert depuis_la_base.dtypes.to_dict() == depuis_le_csv.dtypes.to_dict()
def test_load_recent_from_database_excludes_readings_after_the_until_bound(
connexion_ml: Connection,
) -> None:
site_id = insere_site(connexion_ml)
insere_lectures(connexion_ml, site_id, heures=5, fin=ANCRAGE + timedelta(hours=4))
frame = load_recent_from_database(
connexion_ml, since=ANCRAGE - timedelta(days=1), until=ANCRAGE
)
assert list(frame["timestamp"]) == [ANCRAGE]
+21
View File
@@ -275,3 +275,24 @@ def test_run_scoring_in_csv_mode_scores_without_touching_a_database(tmp_path: Pa
assert {r.site_id for r in resultats} == {"site-a", "site-b"} assert {r.site_id for r in resultats} == {"site-a", "site-b"}
assert all(r.status == "available" for r in resultats) assert all(r.status == "available" for r in resultats)
assert all(r.predicted_value == 7.0 for r in resultats) assert all(r.predicted_value == 7.0 for r in resultats)
def test_run_scoring_in_csv_mode_targets_the_hour_after_the_reference_instant(
tmp_path: Path,
) -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
frame = make_recent("site-a", heures=400, depart=depart)
csv_path = tmp_path / "recent.csv"
frame.to_csv(csv_path, index=False)
model_path = tmp_path / "model.txt"
model_path.write_bytes(b"peu importe le contenu pour ce test")
rattrapage = depart + timedelta(hours=300)
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(
"enervision_ml.score.lgb.Booster", lambda model_file: FakeBooster(valeur=7.0)
)
resultats = run_scoring(model_path=model_path, csv_path=csv_path, now=rattrapage)
assert [r.target_at for r in resultats] == [rattrapage + timedelta(hours=1)]
+238
View File
@@ -0,0 +1,238 @@
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
import pytest
from sqlalchemy import Connection, Row, text
from sqlalchemy.exc import IntegrityError
from enervision_ml.score import (
INSUFFICIENT_DATA_REASON,
LOOKBACK,
MAX_STALENESS,
ScoredSite,
model_reference,
run_scoring,
write_predictions,
)
from tests.conftest import ANCRAGE, Parc, insere_site
pytestmark = pytest.mark.integration
REFERENCE = "lightgbm-000000000000"
_SELECT = text(
"""
SELECT target_at, target_metric, period_minutes, predicted_value,
model_reference, status, failure_reason
FROM prediction
WHERE site_id = :site_id
ORDER BY prediction_id
"""
)
def lignes(connexion: Connection, site_id: str) -> list[Row[Any]]:
return list(connexion.execute(_SELECT, {"site_id": site_id}))
def disponible(
site_id: str,
*,
target_at: datetime = ANCRAGE,
predicted_value: float | None = 12.5,
) -> ScoredSite:
return ScoredSite(
site_id=site_id,
target_at=target_at,
status="available",
predicted_value=predicted_value,
failure_reason=None,
)
def test_write_predictions_inserts_one_row_per_scored_site(connexion_ml: Connection) -> None:
premier = insere_site(connexion_ml)
second = insere_site(connexion_ml)
write_predictions(connexion_ml, [disponible(premier), disponible(second)], reference=REFERENCE)
assert len(lignes(connexion_ml, premier)) == 1
assert len(lignes(connexion_ml, second)) == 1
def test_write_predictions_stores_the_model_reference_and_the_hourly_period(
connexion_ml: Connection,
) -> None:
site_id = insere_site(connexion_ml)
write_predictions(connexion_ml, [disponible(site_id)], reference=REFERENCE)
ligne = lignes(connexion_ml, site_id)[0]
assert ligne.model_reference == REFERENCE
assert ligne.target_metric == "consumption_kwh"
assert ligne.period_minutes == 60
def test_write_predictions_stacks_a_second_run_instead_of_overwriting_the_first(
connexion_ml: Connection,
) -> None:
site_id = insere_site(connexion_ml)
write_predictions(
connexion_ml, [disponible(site_id, predicted_value=10.0)], reference=REFERENCE
)
write_predictions(
connexion_ml, [disponible(site_id, predicted_value=20.0)], reference=REFERENCE
)
assert [ligne.predicted_value for ligne in lignes(connexion_ml, site_id)] == [10.0, 20.0]
def test_write_predictions_writes_nothing_when_no_site_was_scored(
connexion_ml: Connection,
) -> None:
site_id = insere_site(connexion_ml)
write_predictions(connexion_ml, [], reference=REFERENCE)
assert lignes(connexion_ml, site_id) == []
def test_write_predictions_rejects_an_available_row_without_a_predicted_value(
connexion_ml: Connection,
) -> None:
site_id = insere_site(connexion_ml)
with pytest.raises(IntegrityError, match="ck_prediction_status"):
write_predictions(
connexion_ml, [disponible(site_id, predicted_value=None)], reference=REFERENCE
)
def test_write_predictions_rejects_an_insufficient_data_row_carrying_a_value(
connexion_ml: Connection,
) -> None:
site_id = insere_site(connexion_ml)
incoherent = ScoredSite(
site_id=site_id,
target_at=ANCRAGE,
status="insufficient_data",
predicted_value=12.5,
failure_reason=INSUFFICIENT_DATA_REASON,
)
with pytest.raises(IntegrityError, match="ck_prediction_status"):
write_predictions(connexion_ml, [incoherent], reference=REFERENCE)
def test_write_predictions_rejects_a_prediction_for_an_unknown_site(
connexion_ml: Connection,
) -> None:
with pytest.raises(IntegrityError, match="fk_prediction_site"):
write_predictions(connexion_ml, [disponible("SITE-INCONNU")], reference=REFERENCE)
def test_run_scoring_writes_an_available_prediction_for_a_site_with_a_full_week(
parc: Parc, modele_jetable: Path
) -> None:
site_id = parc.site()
parc.lectures(site_id, heures=200, fin=ANCRAGE)
run_scoring(model_path=modele_jetable, now=ANCRAGE)
ligne = parc.predictions_ecrites(site_id)[0]
assert ligne.status == "available"
assert ligne.predicted_value is not None
assert ligne.target_at == ANCRAGE + timedelta(hours=1)
def test_run_scoring_writes_insufficient_data_when_the_weekly_lag_is_missing(
parc: Parc, modele_jetable: Path
) -> None:
site_id = parc.site()
parc.lectures(site_id, heures=100, fin=ANCRAGE)
run_scoring(model_path=modele_jetable, now=ANCRAGE)
ligne = parc.predictions_ecrites(site_id)[0]
assert ligne.status == "insufficient_data"
assert ligne.predicted_value is None
assert ligne.failure_reason == INSUFFICIENT_DATA_REASON
def test_run_scoring_writes_a_staleness_reason_when_the_last_reading_is_too_old(
parc: Parc, modele_jetable: Path
) -> None:
site_id = parc.site()
parc.lectures(site_id, heures=200, fin=ANCRAGE)
run_scoring(model_path=modele_jetable, now=ANCRAGE + MAX_STALENESS + timedelta(hours=1))
ligne = parc.predictions_ecrites(site_id)[0]
assert ligne.status == "insufficient_data"
assert ligne.failure_reason != INSUFFICIENT_DATA_REASON
def test_run_scoring_writes_nothing_when_every_reading_is_older_than_the_window(
parc: Parc, modele_jetable: Path
) -> None:
site_id = parc.site()
parc.lectures(site_id, heures=200, fin=ANCRAGE)
run_scoring(model_path=modele_jetable, now=ANCRAGE + LOOKBACK + timedelta(days=1))
assert parc.predictions_ecrites(site_id) == []
def test_run_scoring_only_writes_the_site_that_was_requested(
parc: Parc, modele_jetable: Path
) -> None:
demande = parc.site()
ignore = parc.site()
parc.lectures(demande, heures=200, fin=ANCRAGE)
parc.lectures(ignore, heures=200, fin=ANCRAGE)
run_scoring(model_path=modele_jetable, site_id=demande, now=ANCRAGE)
assert len(parc.predictions_ecrites(demande)) == 1
assert parc.predictions_ecrites(ignore) == []
def test_run_scoring_uses_the_model_file_hash_as_model_reference(
parc: Parc, modele_jetable: Path
) -> None:
site_id = parc.site()
parc.lectures(site_id, heures=200, fin=ANCRAGE)
run_scoring(model_path=modele_jetable, now=ANCRAGE)
ligne = parc.predictions_ecrites(site_id)[0]
assert ligne.model_reference == model_reference(modele_jetable)
def test_run_scoring_appends_a_second_row_when_it_runs_twice(
parc: Parc, modele_jetable: Path
) -> None:
site_id = parc.site()
parc.lectures(site_id, heures=200, fin=ANCRAGE)
run_scoring(model_path=modele_jetable, now=ANCRAGE)
run_scoring(model_path=modele_jetable, now=ANCRAGE)
ecrites = parc.predictions_ecrites(site_id)
assert len(ecrites) == 2
assert ecrites[0].target_at == ecrites[1].target_at
def test_run_scoring_targets_the_hour_after_the_reference_instant(
parc: Parc, modele_jetable: Path
) -> None:
site_id = parc.site()
parc.lectures(site_id, heures=200, fin=ANCRAGE + timedelta(hours=48))
rattrapage = ANCRAGE
run_scoring(model_path=modele_jetable, now=rattrapage)
ligne = parc.predictions_ecrites(site_id)[0]
assert ligne.target_at == rattrapage + timedelta(hours=1)

Some files were not shown because too many files have changed in this diff Show More