Compare commits

..
Author SHA1 Message Date
Johan LEROYandGitHub 4dc59df1de Merge pull request #171 from ineszang/dev
CI / Périmètre modifié (push) Successful in 1s
CI / Backend (push) Failing after 10m13s
CI / Frontend (push) Failing after 5m13s
CI / ML (push) Failing after 6m31s
CI / Airflow (push) Successful in 9m58s
CI / Infra (push) Skipped
CI / E2E (push) Failing after 17s
CI / SonarQube (push) Failing after 46s
CI / CI ok (push) Successful in 1s
CI / Déploiement (push) Canceled after 0s
Backend / Lint, typage et tests (push) Failing after 5m27s
Backend / Tests exigeant une base (push) Failing after 4m57s
Backend / Audit des dépendances (push) Successful in 9m39s
Backend / Analyse statique de sécurité (push) Successful in 7s
Frontend / Construction et tests (push) Failing after 5m13s
Frontend / Audit des dépendances (push) Successful in 5s
ML / Lint, typage et tests (push) Failing after 6m30s
ML / ML - DB et chaîne ML - DB - API (push) Failing after 0s
ML / Analyse statique de sécurité (push) Successful in 6s
Airflow / Lint et intégrité des DAGs (push) Successful in 9m58s
Airflow / Construction de l'image (push) Successful in 15s
Infra / Formatage et validation Terraform (push) Skipped
Infra / Validation des fichiers Compose et de la supervision (push) Skipped
Infra / Analyse des workflows (push) Skipped
E2E / Parcours Playwright et tirs k6 (push) Failing after 16s
Déploiement / Déploie sur la VM (push) Canceled after 0s
Remontée dev vers main : livrables au thème EnerVision
2026-09-24 16:56:51 +02:00
Johan LEROYandGitHub 79cafb8e1a Merge pull request #170 from ineszang/docs/livrables-design
Livrables au thème EnerVision : rapports EC02 et EC04 régénérés, chaîne md2pdf versionnée
2026-09-24 16:56:44 +02:00
Johan LEROY 5e06b08546 docs(livrables): charte EnerVision sur les rapports EC02 et EC04, chaîne md2pdf versionnée
Les deux PDF sont régénérés avec la chaîne Markdown vers HTML vers CSS de pagination
vers PDF, désormais versionnée dans docs/livrables/outils (script, CSS, logo). Le CSS
reprend les tokens du frontend (vert primaire, neutres, Lato en substitut de Segoe UI,
tableaux sans filet vertical, badges d'état pour les marqueurs Prouvé, Constaté, Absent).

Le rapport collectif EC02 perd sa section « Écarts entre conception (EC01) et
réalisation » : les dossiers EC01 sont individuels et différents d'un membre à l'autre,
il n'existe pas une conception collective à comparer. Les sections suivantes sont
renumérotées ; le rapport passe de 12 à 10 pages.
2026-09-24 16:50:46 +02:00
Johan LEROYandGitHub 931ec9e527 Merge pull request #169 from ineszang/dev
Dev
2026-09-24 16:25:07 +02:00
Johan LEROYandGitHub c8eddbc02b Merge pull request #168 from ineszang/docs/livrables-gel
Rapport collectif EC02 présenté à l'échelle de l'équipe
2026-09-24 16:18:26 +02:00
Johan LEROY 68fc1052fb docs(livrables): présente le rapport collectif EC02 à l'échelle de l'équipe
RACI reconstitué avec tous les membres qui ont écrit, mergé ou relu, par ordre
alphabétique ; revues, intégration et ADR donnés au niveau de l'équipe ; plus
de colonne d'auteur dans le tableau des livraisons. Les contributions
individuelles relèvent des oraux et du rapport de sécurisation.
2026-09-24 16:15:58 +02:00
Johan LEROYandGitHub 0a7dd2e692 Merge pull request #167 from ineszang/dev
Remontée dev vers main : livrables du gel
2026-09-24 16:08:45 +02:00
Johan LEROYandGitHub 45cc67266d Merge pull request #166 from ineszang/docs/livrables-gel
Livrables du gel : rapports EC02 et EC04, documentation réalignée
2026-09-24 16:03:15 +02:00
Johan LEROY 182a2f4a6c docs(livrables): verse le rapport collectif EC02 et le rapport de sécurisation EC04
Sources Markdown et versions figées PDF, relevés du 24/09 sur le commit gelé
9f343e9, et les quatorze preuves anonymisées du rapport de sécurisation,
chacune avec la commande qui la rejoue. Porte la déclaration d'usage de l'IA
et la section anonymisation et RGPD demandées par #154.
2026-09-24 15:55:17 +02:00
Johan LEROY 7ecc0b2e64 docs(architecture): ajoute le runbook de pilotage des traitements automatisés
Carte des sept DAGs et de leurs planifications en UTC, lecture du verdict de
dérive, réentraînement sans perdre le modèle en service, rétention et reprise
après incident. Renvoie aux vues et aux ADR qui portent le détail.
2026-09-24 15:55:17 +02:00
Johan LEROY 9ee0de9d55 docs: réaligne la documentation sur l'état livré au gel
Trois environnements et un frontal SNI au lieu de deux, certificats Let's
Encrypt par DNS-01, sept DAGs, index des ADR complété jusqu'à 0020. Les
exemples de l'ETL passent en bash et n'utilisent plus l'option --limit,
retirée. L'adresse de la machine est masquée dans l'arbre, les ADR 0009 et
0014 portent une note datée sur l'approbation de la production.
2026-09-24 15:55:08 +02:00
Johan LEROYandGitHub 9f343e9f42 Merge pull request #165 from ineszang/dev
Last prod
2026-09-24 11:08:33 +02:00
Johan LEROYandGitHub 5a7e2a94a7 Merge pull request #164 from ineszang/feat/deployer-docker-garage
feat(infra): Garage par environnement, rétention exportée de reading et chiffrement au repos
2026-09-24 10:59:28 +02:00
Johan LEROY f011fce84e docs(adr): constate que la machine ENI est un conteneur LXC, LUKS y est impossible
La garde de scripts/coffre-luks.sh refuse un conteneur LXC ou l'absence de device-mapper.
L'ADR 0020, 10-infra.md et la vue d'ensemble disent ce qui est en place (SSE-C des archives)
et ce qui relève de l'hôte Proxmox (chiffrement du disque du conteneur).
2026-09-24 10:54:06 +02:00
Johan LEROY 7730f184e7 fix(apps): lève les remarques SonarCloud sur les tests de rétention, le script du coffre et la fumée CI 2026-09-24 10:42:50 +02:00
Johan LEROY fa815f49b6 feat(apps): archive vers Garage puis supprime les chunks anciens de reading
Nouveau module app.etl.reading_retention : pour chaque chunk de `reading` entièrement plus
vieux que APP_READING_RETENTION_DAYS (1095 jours), export CSV gzip reproductible vers Garage
(SSE-C, sha256 en métadonnées), relecture et comparaison, puis drop_chunks ciblé sur ce seul
chunk dans une transaction dédiée. --dry-run. Réglages APP_S3_* optionnels, jamais exigés par
l'API. DAG Airflow `retention` quotidien à 03h20. 44 tests unitaires sans réseau ni base,
tests d'intégrité du DAG, vérification --help dans l'image Airflow en CI. Docs 40-data et
20-backend.

Closes #36
2026-09-24 10:36:09 +02:00
Johan LEROY 0462dd01ba feat(scripts): chiffre au repos les volumes Docker de la VM par un coffre LUKS
scripts/coffre-luks.sh pose un coffre LUKS2 dans un fichier image, bind-monte
/var/lib/docker/volumes depuis ce coffre et exige son montage pour que Docker démarre
(drop-in RequiresMountsFor). Migration à froid rejouable, jouée par l'opérateur ; null_resource
Terraform optionnel (coffre_taille). Les archives déposées sur Garage sont chiffrées par SSE-C
(clé GARAGE_SSE_KEY). ADR 0020, runbook et tableaux Garage dans 10-infra.md.

Closes #42
2026-09-24 10:35:31 +02:00
Johan LEROY e53c7e441c feat(infra): déploie Garage par environnement, secrets par le .env, fumée S3 en CI
Reprend l'amorce de la PR 164 et l'intègre à la stack : service `garage` (dxflrs/garage v2.4.1,
`--single-node --default-bucket`) dans docker-compose.yml, garage.toml versionné sans secret,
ports sur 127.0.0.1 décalés par environnement dans provision-host.sh, garde des six clés
GARAGE_* dans le Makefile, cible Prometheus avec jeton, tests de fumée déplacés dans
tests/garage et joués par le job compose d'infra.yml contre le vrai conteneur, SSE-C compris.
Variables APP_S3_* et APP_READING_RETENTION_DAYS posées sur airflow-scheduler pour le DAG
`retention`. ADR 0019.

Closes #24
2026-09-24 10:30:04 +02:00
Johan LEROY 0a7a802b38 Merge remote-tracking branch 'origin/dev' into feat/deployer-docker-garage 2026-09-24 10:22:25 +02:00
Valentin e0089537d0 feat(garage): configuration Docker Compose et tests de fumée S3 2026-09-23 16:27:03 +02:00
PhyriosandGitHub 6b41ec900c Merge pull request #162 from ineszang/feat/reconciliation-dag
fix(backend,airflow,ml): cloture la reconciliation entre les deux sou…
2026-09-23 15:59:44 +02:00
Dorian 49175d8ff7 fix: conflict 2026-09-23 15:55:12 +02:00
Dorian da7fc52299 Merge remote-tracking branch 'origin/dev' into feat/reconciliation-dag 2026-09-23 15:54:59 +02:00
Johan LEROYandGitHub 5afe2fc88e Merge pull request #163 from ineszang/dev
Remontée dev vers main : import horaire de l'API Mock, une mesure à l'heure pile
2026-09-23 15:53:11 +02:00
Johan LEROY 6c09beeb3c fix(etl): demander la mesure de l'heure pile à l'API Mock
Correctif de cbbfaf4, dont le message affirmait à tort une mesure à :00. Avec
--limit 1, l'API Mock renvoie le point de début d'intervalle : le DAG, déclenché
à :45 sur [:45 - 1 h, :45], aurait écrit ses mesures à :45, au pas horaire mais
hors de la grille du dataset historique.

L'intervalle part désormais de l'heure pile du déclenchement : un run à 13:45
demande [13:00, 13:45] et importe la mesure de 13:00, avant ml_score à 14:00.
Vérifié contre l'API Mock en recette et par le rendu du gabarit Jinja.
2026-09-23 15:39:04 +02:00
Dorian 14eed08ff5 fix(mock_api): remove merge conflict markers 2026-09-23 15:36:46 +02:00
Dorian 1232646f68 Merge remote-tracking branch 'origin/dev' into feat/reconciliation-dag 2026-09-23 15:36:43 +02:00
Johan LEROY cbbfaf4910 fix(etl): importer une seule mesure par heure depuis l'API Mock
L'API Mock ne renvoie pas les mesures d'une période : elle génère `limit` points
répartis sur l'intervalle demandé (1 000 par heure avec --limit 1000, un toutes
les 3,6 s). Le DAG aurait écrit 7 000 lignes par heure et par environnement,
alors que le dataset historique a une mesure horaire et que les features ML
décalent par ligne : `shift(168)`, le retard d'une semaine, serait devenu un
retard de dix minutes, sans erreur visible au scoring ni au réentraînement.

Avec --limit 1, l'API renvoie la mesure de :00 de chaque heure, au pas du CSV.
Constaté sur la recette le 23/09 avant la réactivation des DAGs.
2026-09-23 15:31:51 +02:00
Dorian b78322bd61 fix(backend,airflow,ml): applique les corrections de revue sur la PR #162 2026-09-23 15:19:39 +02:00
Dorian 101ebd404f Merge remote-tracking branch 'origin/dev' into feat/reconciliation-dag 2026-09-23 14:55:19 +02:00
Johan LEROYandGitHub 32f1bef643 Merge pull request #161 from ineszang/dev
Remontée dev vers main : première connexion sans ressaisie du mot de passe provisoire
2026-09-23 14:51:25 +02:00
Dorian b00c39277b fix(backend,airflow,ml): cloture la reconciliation entre les deux sources de lectures (#15) 2026-09-23 14:45:38 +02:00
Johan LEROY c2f360c591 fix(auth): ne plus redemander le mot de passe provisoire à la première connexion
L'écran de changement imposé redemandait le mot de passe provisoire qui venait
d'être vérifié, sans champ identifiant. Un gestionnaire de mots de passe y
collait un ancien mot de passe du site : /auth/password répondait 401
« Identifiants invalides », et le message unique accusait aussi la politique
de mot de passe. Constaté en rec et en dev sur les comptes nominatifs.

- AuthService garde en mémoire le mot de passe d'une connexion qui impose le
  changement, rendu une seule fois par takeProvisionalPassword() et effacé
  avec la session.
- Le champ « Mot de passe actuel » ne s'affiche que si ce mot de passe manque
  (page rechargée) ou vient d'être refusé.
- Champ identifiant masqué pour les gestionnaires de mots de passe.
- Messages distincts pour 401, 422 et le reste, liste des critères en direct.
2026-09-23 14:42:37 +02:00
Johan LEROYandGitHub 7f4364df77 Merge pull request #160 from ineszang/dev
Remontée dev vers main : noms publics, certificats Let's Encrypt et frontal sans port
2026-09-23 12:45:42 +02:00
Johan LEROYandGitHub 933f0a3360 Merge pull request #159 from ineszang/fix/prod-sous-domaine
fix(deploy): la prod passe sur prod.enervision-g3.dynv6.net
2026-09-23 12:40:44 +02:00
Johan LEROY dbcd5c4240 fix(deploy): passe la prod sur prod.enervision-g3.dynv6.net
dynv6 sert mal un TXT _acme-challenge à la racine de la zone : l'API ne
le liste ni ne le supprime, et un seul de ses trois serveurs le renvoie.
Le défi DNS-01 de la prod échouait donc à chaque essai, alors que rec. et
dev. passaient. La prod rejoint ses voisines en sous-domaine, ce qui aligne
aussi les trois noms sur les environnements.

- provision-host.sh : hôte prod.$DOMAINE, enregistrement A prod publié.
- Makefile : --dnssleep 90, le temps que les trois serveurs de dynv6
  servent le TXT avant la validation multi-réseaux de Let's Encrypt.
- deploy.yml, ADR 0018, 10-infra.md, infra/README.md, Terraform.
2026-09-23 12:35:20 +02:00
Johan LEROY 46d10209f1 Merge branch 'main' into dev 2026-09-23 12:01:54 +02:00
Johan LEROYandGitHub db6ee6e56d Merge pull request #156 from ineszang/feat/domaine-duckdns-tls
feat(deploy): URL sans port et certificats Let's Encrypt sur la VM ENI
2026-09-23 12:01:41 +02:00
Johan LEROYandGitHub 84969d3375 Merge pull request #158 from ineszang/dependabot/npm_and_yarn/tests/e2e/e2e-dependencies-b7ceb5d816
chore(deps-dev): bump typescript from 6.0.3 to 7.0.2 in /tests/e2e in the e2e-dependencies group
2026-09-23 11:59:01 +02:00
Johan LEROYandGitHub 93d5cad5af Merge pull request #157 from ineszang/dependabot/github_actions/astral-sh/setup-uv-10.1.0
chore(deps): bump astral-sh/setup-uv from 7.6.0 to 10.1.0
2026-09-23 11:58:56 +02:00
Johan LEROY 4d88604a07 fix(deploy): rejoue la synchronisation dynv6 quand l'API ne répond pas
L'API dynv6 laisse par intermittence une écriture sans réponse, parfois
appliquée malgré tout. La synchronisation est rejouée jusqu'à trois fois
et relit l'état avant chaque écriture : une création aboutie malgré le
délai n'est jamais dupliquée. Délai par appel porté à 60 s.

Validé contre le vrai dynv6 depuis la VM : zone, rec et dev visent
10.101.200.37, et un certificat de test Let's Encrypt a été émis par
DNS-01 pour dev.enervision-g3.dynv6.net.
2026-09-23 11:56:33 +02:00
Johan LEROY 22a88e193c fix(deploy): passe à dynv6 et rend le défi DNS-01 indépendant du fournisseur
deSEC n'ouvre plus de nouveaux domaines dedyn.io, et duckdns.org est
filtré par l'école. dynv6 répond depuis les postes et depuis la VM.

- Zone enervision-g3.dynv6.net ; provision-host.sh pointe la zone, rec
  et dev vers la VM par l'API dynv6 (bloc Python, idempotent).
- make tls-dns01 remplace tls-desec : DNS01_API et DNS01_JETON_VAR
  nomment le greffon acme.sh, le jeton vit dans ../dns.token quel que
  soit le fournisseur. Un domaine acheté ne demandera que ces variables.
- deploy.yml ne demande un certificat qu'à un .env qui ne porte plus de
  nom en .local.
- ADR 0018 renommé noms-publics : deSEC et DuckDNS en alternatives.
2026-09-23 11:47:07 +02:00
Johan LEROY d687d7dc58 fix(deploy): passe de DuckDNS à deSEC, filtré par l'école
Le filtrage du réseau de l'école bloque duckdns.org, site et API, depuis
les postes comme depuis la VM : sans API, pas de défi DNS-01. deSEC
(dedyn.io) répond depuis les deux.

- Domaine enervision-g3.dedyn.io ; provision-host.sh publie par l'API
  deSEC l'enregistrement du domaine et son joker vers la VM.
- make tls-desec remplace tls-duckdns. acme.sh recopie le jeton dans
  acme/account.conf : le dossier est retiré aux autres comptes.
- deploy.yml ne demande un certificat qu'à un .env déjà réaligné sur
  le domaine deSEC, pour ne pas faire échouer un déploiement en cours
  de migration.
2026-09-23 11:38:44 +02:00
dependabot[bot]andGitHub 288970df77 chore(deps-dev): bump typescript
Bumps the e2e-dependencies group in /tests/e2e with 1 update: [typescript](https://github.com/microsoft/TypeScript).


Updates `typescript` from 6.0.3 to 7.0.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v6.0.3...v7.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: e2e-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-23 09:34:42 +00:00
dependabot[bot]andGitHub 985c188106 chore(deps): bump astral-sh/setup-uv from 7.6.0 to 10.1.0
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 7.6.0 to 10.1.0.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/37802adc94f370d6bfd71619e3f0bf239e1f3b78...bec219d24cd3e171d82865faccec33120bb574f4)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 10.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-23 09:34:37 +00:00
PhyriosandGitHub b786f27a4d Merge pull request #152 from ineszang/dev
Remontée dev vers main : mise en production sur la VM ENI
2026-09-23 11:33:44 +02:00
Johan LEROY 3e871a3e8b feat(deploy): URL sans port et certificats Let's Encrypt sur la VM ENI
Les trois environnements passent sur enervision-g3.duckdns.org, rec. et
dev. : noms publics qui visent l'IP privée de la VM, donc résolus sans
/etc/hosts sur le réseau de l'école et injoignables ailleurs (ADR 0018).

- infra/front : nginx sur le réseau de l'hôte, seul exposé en 80 et 443.
  Aiguille par SNI vers la stack visée sans déchiffrer le TLS, et lui
  transmet l'IP du client en PROXY protocol.
- Proxy de stack : écouteur 4443 en PROXY protocol, real_ip_header ;
  sans lui, limit_req et get_client_ip() compteraient tous les postes
  comme un seul. PROXY_FRONT_PORT le publie sur 127.0.0.1.
- make tls-duckdns : Let's Encrypt par défi DNS-01 via l'API DuckDNS
  (acme.sh 3.1.6), rejouable, rejoué à chaque déploiement et chaque nuit.
- provision-host.sh fait foi pour l'adressage et les secrets : un .env
  existant garde ses secrets, reçoit ceux qui manquent (supervision) et
  voit hôte et ports réalignés. Planifie le renouvellement des certificats.
- deploy.yml : nouvelles URL, sonde prod sur 10443, front-up en prod.
- Terraform : variable domaine. CI : validation du frontal.
2026-09-23 11:21:12 +02:00
Johan LEROYandGitHub fe0d4222a5 Merge pull request #148 from ineszang/feat/robustesse-ci-e2e-charge-supervision
Robustesse : CI/CD unifiée, e2e Playwright, charge k6, supervision
2026-09-23 10:57:32 +02:00
Johan LEROY 30bb3b838c Fusionne dev dans feat/robustesse-ci-e2e-charge-supervision
Rapatrie l'environnement dev à la demande (#151) et l'en-tête CORP (#149).

- deploy.yml : garde le routage de #151 (main vers prod, dev vers rec, toute autre branche vers
  dev) et l'appel par ci.yml après « CI ok ». Le groupe concurrency par environnement cède la
  place au flock sur le dossier, qui sérialise aussi deux branches lancées dans dev. La garde
  anti-recul ne joue que sur la même branche : dans dev, une autre branche que celle en place
  est toujours déployée.
- provision-host.sh : le dossier dev reçoit aussi les clés de supervision, profil inactif,
  ports 3003, 9092 et 9095.
- 10-infra.md, 50-cicd.md et ADR 0017 : trois environnements, supervision et verrou flock.
2026-09-23 10:51:42 +02:00
Johan LEROYandGitHub c3ec8b79c7 Merge pull request #151 from ineszang/feat/env-dev-a-la-demande
feat(deploy): environnement dev déployé à la demande sur la VM ENI
2026-09-23 10:46:24 +02:00
Johan LEROY 7644bf49ad ci: joue l'e2e quand le Makefile ou .env.example change
e2e.yml construit son .env depuis .env.example et appelle make db-ensure-supervision,
load-smoke et load-limits. Une PR qui cassait une de ces cibles ou une clé de .env.example
sautait l'e2e et obtenait « CI ok » ; l'échec n'apparaissait qu'au push sur dev, en bloquant le
déploiement.
2026-09-23 10:44:51 +02:00
Johan LEROY 3ca4839a02 fix(deploy): ne ramène jamais un environnement sur un commit plus ancien
Les CI de deux push rapprochés peuvent finir dans le désordre. deploy.yml faisait alors
reset --hard sur un GITHUB_SHA plus ancien que celui déjà déployé, et le groupe concurrency
deploy-<branche> ne garde qu'un job en attente : un troisième arrivé annulait le précédent, qui
n'était jamais déployé.

- un commit qui précède celui déjà déployé est ignoré, avec une annotation dans le run ;
- le groupe concurrency cède la place à un flock posé dans le clone de la VM, tenu du fetch
  jusqu'à la sonde de santé : les déploiements passent un par un, aucun n'est annulé ;
- les trois étapes n'en font plus qu'une, le verrou tombant avec le shell qui l'a posé ; les
  journaux restent découpés par ::group::.

ADR 0014 et 50-cicd.md décrivent les deux gardes.
2026-09-23 10:44:51 +02:00
Johan LEROY 10cc408b03 feat(deploy): ajoute un environnement dev déployé à la demande
Infra / Formatage et validation Terraform (push) Successful in 50s
Troisième projet Compose sur la VM ENI, /srv/enervision/dev, alimenté par
workflow_dispatch de n'importe quelle branche autre que dev et main
(https://dev.enervision.local:9443). La recette suit toujours dev, la
production main.

- deploy.yml : routage main -> prod, dev -> rec, autre -> dev ; groupe de
  concurrence par environnement et non plus par branche.
- provision-host.sh : prépare le dossier dev (ports 9443, 5435, 8027, 8084) ;
  passe safe.directory à git, faute de quoi un second passage en root, celui
  de terraform apply, échoue sur les clones déjà remis au runner.
- ADR 0017, 10-infra.md, 50-cicd.md, infra/README.md à jour.
2026-09-23 10:43:48 +02:00
PhyriosandGitHub c7744483b4 Merge pull request #149 from ineszang/feat/CORP-backend
Ajout de l'en-tete Cross-Origin-Resource-Policy sur toutes les reponses
2026-09-23 10:33:21 +02:00
Dorian ac05da7001 docs(backend): corrige la justification du CORP same-origin (no-cors, pas d'ingress)
Airflow / Construction de l'image (push) Successful in 1m17s
Backend / Tests exigeant une base (push) Failing after 4m50s
Backend / Analyse statique de sécurité (push) Successful in 7s
Airflow / Lint et intégrité des DAGs (push) Successful in 9m34s
Backend / Audit des dépendances (push) Successful in 9m36s
Backend / Lint, typage et tests (push) Successful in 10m7s
SonarQube / test-ml (push) Failing after 6m8s
SonarQube / build-front (push) Successful in 10m15s
SonarQube / build-back (push) Successful in 10m47s
SonarQube / test-front (push) Failing after 5m13s
SonarQube / test-back (push) Failing after 5m22s
SonarQube / SonarQube (push) Skipped
2026-09-23 10:30:06 +02:00
Johan LEROY 0284cc0cd8 docs: décrit la CI unifiée, l'e2e, la charge et la supervision
- ADR 0014 : un pipeline CI unique, « CI ok » seul check à exiger, déploiement du commit testé.
- ADR 0015 : e2e et charge contre la stack Compose déployée, hypothèses et seuils de k6.
- ADR 0016 : supervision en profil Compose, active en prod, rôle en lecture seule.
- Nouvelle vue 60-observabilite.md ; 00-vue-ensemble, 10-infra, 20-backend et 50-cicd mis à
  jour (monitoring passé à Fait, nouveau graphe de CI, gates, ports).
- README racine et guide de tests du frontend : où sont l'e2e, la charge et la supervision.
2026-09-23 09:46:12 +02:00
Johan LEROY dc952d13aa feat(monitoring): supervise l'API, la base et l'hôte avec Prometheus et Grafana
L'API exposait /metrics, mais aucun collecteur ne le lisait : monitoring/ ne contenait que des
.gitkeep.

Sous le profil Compose `monitoring` : prometheus, alertmanager, grafana, postgres-exporter,
node-exporter et cadvisor. Tous ont un mem_limit, pour environ 700 Mo au total sur la VM de 8 Go,
et leurs interfaces n'écoutent que sur 127.0.0.1. Le profil est actif en prod via
COMPOSE_PROFILES, donc à chaque déploiement, et se lance à la demande ailleurs
(make monitoring-up).

- Neuf règles d'alerte (API, base, hôte, cibles). Chacune a un cas dans les tests joués par
  `promtool test rules`, en CI comme par make monitoring-check.
- Alertmanager route les alertes par courriel vers Mailpit ; un critical masque le warning de la
  même cible.
- Grafana est provisionné : sources Prometheus et TimescaleDB, et trois tableaux de bord (API,
  données et dérive du modèle, infrastructure).
- Le rôle PostgreSQL `supervision` est en lecture seule sur les seules tables métier
  (db/roles/supervision.sql), posé par make db-ensure-supervision et par stack-up quand le
  profil est actif.
- Le jeton de /metrics passe à Prometheus en secret Compose (APP_METRICS_TOKEN) ;
  provision-host.sh génère ce secret et les deux autres.

Backend :
- un APP_METRICS_TOKEN vide vaut absent ;
- les sondes de santé ne comptent plus dans les métriques ;
- seaux de latence fins autour de 500 ms ;
- un registre Prometheus par application, sans quoi toute application créée après la première
  (dans les tests) ne mesurait rien.

Réf : #26
2026-09-23 09:41:12 +02:00
Dorian cfc194a3fb fix(backend): ajoute l'en-tete Cross-Origin-Resource-Policy sur toutes les reponses 2026-09-23 09:36:10 +02:00
Johan LEROY 2ed9e1cee4 test(charge): mesure l'API sous charge avec k6
Aucun garde-fou de performance n'existait, et le dépôt ne chiffrait aucun temps de réponse.

tests/load, quatre scénarios :
- smoke : une minute sur chaque route de lecture, joué à chaque PR ;
- charge : 50 utilisateurs, 40 sur le tableau de bord au rythme de son rafraîchissement,
  10 qui explorent les sites ;
- stress : débit croissant jusqu'à la rupture, arrêt au-delà de 10 % d'erreurs ;
- limitation-debit : par le proxy, vérifie que nginx répond 429 et jamais 5xx.

Seuils : p95 < 500 ms et p99 < 1 s sur les lectures, moins de 1 % d'échecs.

k6 tourne en service Compose (profil load) sur le réseau du projet : il vise backend:8000 et
mesure l'API plutôt que la limite de 20 req/s par adresse de nginx. Chaque tir écrit un rapport
HTML, une synthèse Markdown et le JSON brut dans tests/load/results.

make load-smoke, load-test, load-stress et load-limits ; le job E2E enchaîne le smoke et le
test de limitation après Playwright.

Closes #47
2026-09-23 09:29:20 +02:00
Johan LEROY a197af91ff test(e2e): joue les parcours utilisateur avec Playwright contre la stack déployée
Aucun parcours n'était vérifié de bout en bout : les tests unitaires du frontend simulent l'API,
ceux du backend n'ouvrent jamais de navigateur.

tests/e2e, paquet npm autonome, 18 parcours dans Chromium :
- authentification, premier login, réinitialisation du mot de passe par Mailpit ;
- rôles : lecteur, opérateur, administrateur ;
- sites, recommandations, fil d'alertes.

e2e.yml, appelé par ci.yml, démarre db, mailpit, backend, frontend et proxy avec
docker-compose.prod.yml sur https://localhost, sème demo.sql, crée les comptes et joue la suite.
Il construit au passage les images backend et frontend, que la CI ne construisait jamais.

Un seul worker et une session par fichier : la zone auth de nginx admet 30 connexions par
minute, et rejouer un cookie de refresh dans un second contexte révoque toute la session.

make e2e-install, e2e-prepare et e2e pour le poste ; make help affiche désormais les cibles
dont le nom contient un chiffre.

Closes #46
2026-09-23 09:25:37 +02:00
Johan LEROY a646635b4c test: partage un jeu de démonstration et des comptes de test
db/seeds/ était vide, et chaque outil de test semait ses données à la main : un site et deux
relevés dans dast.yml, rien pour le reste.

- db/seeds/demo.sql : trois sites, 72 heures de relevés relatives à now(), une prévision par
  site, quatorze alertes de tous types et sévérités, un rapport de dérive par site. Rejouable.
- scripts/comptes-test.sh : administrateur par la CLI, lecteur et opérateur activés, et un
  compte laissé sur son mot de passe temporaire ; identifiants écrits en JSON (mode 600).
  Fonctionne en natif ou contre la stack Compose (BASE_URL, APP_CLI).
- scripts/dast-token.sh s'appuie désormais dessus ; dast.yml sème demo.sql.
2026-09-23 09:19:29 +02:00
Johan LEROY d54cd963f1 ci: ne déploie que le commit testé, après une CI verte
deploy.yml partait à chaque push sur dev ou main, CI verte ou non, et déployait la pointe de
branche du moment plutôt que le commit poussé.

Il devient un workflow appelé par ci.yml, après « CI ok », sur les seuls push. Il aligne le
dossier de l'environnement sur GITHUB_SHA. Toujours aucun déclencheur pull_request (ADR 0009) ;
workflow_dispatch reste disponible pour redéployer à la main.
2026-09-23 09:16:15 +02:00
Johan LEROY 18307e9be3 ci: rassemble la CI dans un orchestrateur unique et retire le doublon Sonar
Chaque workflow se déclenchait sur push (toutes branches) et sur pull_request : chaque commit
de PR jouait tout deux fois. sonarqube.yml reconstruisait et retestait front, back et ML en
parallèle des workflows qui le faisaient déjà, et son test backend tournait sans uv sync.

ci.yml devient le seul point d'entrée (pull_request, push sur dev et main) :
- paths-filter choisit les composants à jouer sur une PR, tout est rejoué sur dev et main ;
- backend, frontend, ml, airflow et infra passent en workflow_call ;
- le job sonar reprend les couvertures versées par ces jobs au lieu de tout rejouer ;
- « CI ok » agrège le résultat, seul check à exiger dans les règles de branche.

Au passage :
- npm run test:ci au lieu de npm test --watch=false, option que npm gardait pour lui ;
- uv sync --locked au lieu de --frozen, pour qu'un verrou périmé casse la CI ;
- setup-uv et sonarqube-scan-action épinglés sur un SHA (règle S7637), timeout sur chaque job ;
- frontend : un seul npm ci pour la construction et les tests ;
- infra : validation des fichiers Compose et actionlint sur les workflows ;
- exclusions Sonar en globs, doublon apps/frontend/sonar-project.properties supprimé.
2026-09-23 09:16:08 +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
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
Johan LEROYandClaude Opus 5 c1f9dec5fe chore(git): retire le motif airflow-webserver.pid du .gitignore
Airflow 3 n'a plus de webserver : le fichier n'est plus produit. Dernier point
de la revue de la PR #143.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:42:25 +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
Johan LEROY be6be42947 fix(infra,ci): aligne le provisionnement et le déploiement sur Airflow 3
La migration Airflow 3 est arrivée sur `dev` après l'écriture du chemin de
déploiement, qui en a gardé quatre traces fausses, invisibles en CI puisque
aucun job ne joue ce chemin.

`provision-host.sh` substituait `AIRFLOW_WEBSERVER_SECRET_KEY`, clé disparue.
`AIRFLOW_API_SECRET_KEY` et `AIRFLOW_JWT_SECRET` restaient donc à `change_me`
dans le `.env` posé sur la machine, et le `:?` d'`airflow-init` ne voit pas
une valeur d'exemple : la stack aurait démarré avec un secret de session et un
secret JWT prévisibles. Le `.env` est maintenant écrit après contrôle, et le
script refuse de le poser s'il reste un `change_me` hors `APP_MOCK_API_*`.

`make services-up` démarrait `airflow-webserver`, service supprimé par la
migration ; seul `airflow-up` avait été aligné.

L'overlay posait `AIRFLOW__WEBSERVER__WORKERS`, sans effet en Airflow 3 où la
section est `[api]`. Le réglage disparaît plutôt que d'être renommé : le défaut
y vaut un worker, moins que les deux qu'on visait.

Le diagnostic d'échec de `deploy.yml` lisait les journaux sans l'overlay, donc
sans service `proxy` : il échouait avant d'imprimer quoi que ce soit.
2026-09-22 11:17:02 +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
Johan LEROY b5993f994e chore(deps): aligne le runner nginx du frontend sur 1.31-alpine
Même image que le proxy de docker-compose.prod.yml depuis #126 : un seul
nginx à suivre pour les deux conteneurs qui en embarquent un.
2026-09-22 09:13:03 +02:00
Johan LEROY 091ee344d1 refactor(etl): aligne la DAG historical_import sur le SDK Airflow 3
Même mouvement que pour ml_train, ml_score et alertes (#135) : DAG depuis
airflow.sdk, BashOperator depuis le provider standard, et la planification
lue sur dag.schedule puisque les timetables n'exposent plus summary. Les
anciens imports fonctionnaient encore, avec un avertissement de dépréciation.
2026-09-22 09:05:53 +02:00
Johan LEROY f81959c1a9 Merge branch 'main' into dev
Rapatrie #135 : migration vers Airflow 3.3.2 (api-server, dag-processor,
secret JWT, FabAuthManager, DAGs et tests sur le SDK). Conflit Makefile :
la cible airflow-up garde le prérequis db-ensure-airflow de dev et les
services renommés de main.
2026-09-22 09:05:50 +02:00
Johan LEROYandGitHub 5c361c6952 Merge pull request #135 from ineszang/dependabot/uv/etl/airflow/apache-airflow-3.3.0
chore(deps): bump apache-airflow from 2.10.4 to 3.3.0 in /etl/airflow
2026-09-22 09:04:19 +02:00
Johan LEROY a27e0c6535 Merge branch 'main' into dev
Rapatrie dans dev les mises à jour de dépendances mergées sur main :
#142 (SonarQube ignoré pour Dependabot), #129 checkout v7, #130 setup-uv v7,
#132 setup-node v7, #128 upload-artifact v7, #131 download-artifact v8,
#127 Node 26 (image et CI frontend), #126 nginx 1.31-alpine, #133 pandas 3.0.6
et ruff 0.16.8, #134 Angular 22.1.7, jsdom 30, prettier 3.9.8 (TypeScript 6 et
vitest 4 conservés, majeures ignorées côté Dependabot).

Conflit README.md : statuts « En place » de main, « Quatre DAGs » de dev (#138).
2026-09-22 09:03:17 +02:00
Johan LEROY 7a97a4d1ac docs(readme): le frontend construit sur Node 26
Suite à #127 : image node:26-alpine3.22 et CI frontend sur Node 26.
2026-09-22 09:03:12 +02:00
Johan LEROYandGitHub 20abcfee33 Merge pull request #134 from ineszang/dependabot/npm_and_yarn/apps/frontend/frontend-dependencies-e5a1f9e295
chore(deps): bump the frontend-dependencies group in /apps/frontend with 12 updates
2026-09-22 09:02:17 +02:00
Johan LEROY 3d6d2eb760 style(infra): reflow un commentaire du compose Airflow à 100 colonnes 2026-09-22 09:01:07 +02:00
Johan LEROY 154deca511 docs(architecture): décrit les composants Airflow 3
Quatre services au lieu de trois (api-server, dag-processor), le secret JWT
partagé et le choix du FabAuthManager. Le Python 3.12 d'etl/airflow est celui
de l'image retenue, plus une limite d'Airflow.
2026-09-22 09:01:07 +02:00
Johan LEROY a577356198 feat(infra): fait tourner Airflow 3 dans la stack Compose
Image apache/airflow:3.3.2-python3.12. Le webserver devient l'api-server
(healthcheck /api/v2/monitor/health) et le dag-processor est un service à
part : depuis Airflow 3 le scheduler ne parse plus les DAGs.

Les tâches passent par l'Execution API de l'api-server avec un jeton signé
par AIRFLOW_JWT_SECRET, nouveau secret du .env partagé entre conteneurs.
AIRFLOW_WEBSERVER_SECRET_KEY devient AIRFLOW_API_SECRET_KEY. airflow-init
refuse toujours de démarrer si l'un des secrets manque.

FabAuthManager explicite : le SimpleAuthManager par défaut ne sait pas créer
le compte admin que airflow-init pose via _AIRFLOW_WWW_USER_*. Pas de
triggerer, aucun opérateur déférable dans les DAGs.
2026-09-22 09:01:07 +02:00
Johan LEROY 19ff152445 refactor(etl): migre les DAGs et leurs tests vers le SDK Airflow 3
DAG et BaseOperator viennent d'airflow.sdk, BashOperator du provider
standard (airflow.operators.bash n'est plus qu'un alias déprécié). DagBag
s'importe depuis airflow.dag_processing et ne prend plus include_examples,
les exemples étant déjà coupés par la configuration de conftest.py.

Les timetables n'exposent plus summary : la planification se lit sur
dag.schedule (None) et timetable.expression (cron normalisé).
2026-09-22 09:01:07 +02:00
Johan LEROY bf3584182f chore(deps): passe apache-airflow à 3.3.2
Dernier correctif de la branche 3.3 (17 septembre 2026) plutôt que le 3.3.0
proposé par Dependabot. Le verrou est régénéré par uv lock ; le provider
standard et le Task SDK, requis par Airflow 3, y figurent déjà.
2026-09-22 09:01:07 +02:00
dependabot[bot]andJohan LEROY 28e8260216 chore(deps): bump apache-airflow from 2.10.4 to 3.3.0 in /etl/airflow
Bumps [apache-airflow](https://github.com/apache/airflow) from 2.10.4 to 3.3.0.
- [Release notes](https://github.com/apache/airflow/releases)
- [Changelog](https://github.com/apache/airflow/blob/main/docker-stack-docs/changelog.rst)
- [Commits](https://github.com/apache/airflow/compare/2.10.4...3.3.0)

---
updated-dependencies:
- dependency-name: apache-airflow
  dependency-version: 3.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 09:01:07 +02:00
Johan LEROYandGitHub e90e563a1c Merge pull request #133 from ineszang/dependabot/uv/apps/backend/backend-dependencies-b3e2c9fee8
chore(deps): bump the backend-dependencies group in /apps/backend with 2 updates
2026-09-22 08:59:17 +02:00
Johan LEROYandGitHub d7ca9fd8b0 Merge pull request #126 from ineszang/dependabot/docker_compose/nginx-1.31-alpine
chore(deps): bump nginx from 1.28-alpine to 1.31-alpine
2026-09-22 08:59:06 +02:00
Johan LEROYandGitHub 67204b5eb6 Merge pull request #127 from ineszang/dependabot/docker/apps/frontend/node-26-alpine3.22
chore(deps): bump node from 24-alpine3.22 to 26-alpine3.22 in /apps/frontend
2026-09-22 08:58:54 +02:00
Johan LEROY 985466e9cc ci(frontend): teste sur Node 26, comme l'image
Frontend / Audit des dépendances (push) Successful in 6s
SonarQube / test-ml (push) Failing after 5m46s
Frontend / build (push) Successful in 9m47s
SonarQube / build-front (push) Successful in 9m54s
SonarQube / build-back (push) Successful in 10m30s
Frontend / test (push) Failing after 5m3s
SonarQube / test-front (push) Failing after 5m8s
SonarQube / test-back (push) Failing after 5m22s
SonarQube / SonarQube (push) Skipped
L'image frontend passe sur node:26-alpine3.22 ; la CI restait sur Node 24
et ne validait donc plus l'interpréteur qui construit le SPA. Angular 22.1.8
accepte >=26.0.0 ; Node 26 devient LTS le 28 octobre 2026, Node 24 passe en
maintenance le 20.
2026-09-22 08:42:46 +02:00
dependabot[bot]andJohan LEROY c347212c93 chore(deps): bump node in /apps/frontend
Bumps node from 24-alpine3.22 to 26-alpine3.22.

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26-alpine3.22
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 08:42:46 +02:00
Johan LEROY c67c7802c1 Merge pull request #131 from ineszang/dependabot/github_actions/actions/download-artifact-8
chore(deps): bump actions/download-artifact from 4 to 8
2026-09-22 08:42:06 +02:00
Johan LEROY e824423889 Merge pull request #128 from ineszang/dependabot/github_actions/actions/upload-artifact-7
chore(deps): bump actions/upload-artifact from 4 to 7
2026-09-22 08:42:03 +02:00
Johan LEROY 436147c8cb Merge pull request #132 from ineszang/dependabot/github_actions/actions/setup-node-7
chore(deps): bump actions/setup-node from 6 to 7
2026-09-22 08:42:00 +02:00
Johan LEROYandGitHub 8fb7ab8680 Merge pull request #130 from ineszang/dependabot/github_actions/astral-sh/setup-uv-7
chore(deps): bump astral-sh/setup-uv from 5 to 7
2026-09-22 08:40:33 +02:00
Johan LEROY 65baf3fddb docs(infra): aligne le README du proxy sur nginx 1.31-alpine
Le tag de l'image y est cité deux fois (présentation, commande nginx -t) ;
il suit celui de docker-compose.prod.yml. Le runner du Dockerfile frontend
est une image durcie dhi.io, suivie séparément, et n'est pas concerné.
2026-09-22 08:38:03 +02:00
dependabot[bot]andGitHub 19f4ddc62f chore(deps): bump actions/upload-artifact from 4 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 06:37:24 +00:00
dependabot[bot]andGitHub 282472bae9 chore(deps): bump actions/download-artifact from 4 to 8
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 06:37:23 +00:00
dependabot[bot]andGitHub 8d80cdc79d chore(deps): bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 06:37:23 +00:00
dependabot[bot]andGitHub b61287c8d2 chore(deps): bump astral-sh/setup-uv from 5 to 7
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 5 to 7.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/v5...v7)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 06:37:20 +00:00
Johan LEROYandGitHub a4be46c6e6 Merge pull request #129 from ineszang/dependabot/github_actions/actions/checkout-7
chore(deps): bump actions/checkout from 4 to 7
2026-09-22 08:36:32 +02:00
dependabot[bot]andGitHub 66a65991b5 chore(deps): bump actions/checkout from 4 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 06:33:45 +00:00
Johan LEROYandGitHub 095cb4559b Merge pull request #142 from ineszang/chore/sonar-skip-dependabot
chore(ci): ignore le scan SonarQube pour les PR Dependabot
2026-09-22 08:31:48 +02:00
Johan LEROY d48b0eef4d chore(deps): ignore les majeures TypeScript et vitest côté Dependabot
Tant qu'Angular épingle typescript (>=6.0 <6.1) et vitest (^4), une majeure
de l'un ou l'autre casse npm ci. Dependabot arrête de les proposer ; à lever
quand Angular relâchera ces bornes.
2026-09-22 08:31:16 +02:00
Johan LEROY c8e8a44390 fix(frontend): garde vitest 4 et régénère le verrou pour TypeScript 6
@angular/build 22.1.8 exige typescript >=6.0 <6.1 et vitest ^4.0.8 : le
groupe Dependabot proposait TypeScript 7.0.2 et vitest 5.0.1, et npm ci
sortait en ERESOLVE. Le commit précédent avait ramené TypeScript à ~6.0.2
sans régénérer le verrou, qui pointait encore 7.0.2.

Verrou régénéré avec npm 11.19.0 (packageManager). Conservés : Angular
22.1.7, jsdom 30.1.0, prettier 3.9.8, @vitest/coverage-v8 aligné sur vitest.
2026-09-22 08:31:15 +02:00
Johan LEROY 401d5a3174 chore(ci): ignore le scan SonarQube pour les PR Dependabot
SonarQube / build-back (push) Successful in 1m20s
SonarQube / test-ml (push) Failing after 1m11s
SonarQube / build-front (push) Successful in 9m44s
SonarQube / test-back (push) Failing after 59s
SonarQube / test-front (push) Failing after 5m8s
SonarQube / SonarQube (push) Skipped
GitHub ne fournit pas les secrets du dépôt aux workflows déclenchés par
dependabot[bot] : SONAR_TOKEN arrive vide et le scan échoue sans rien
analyser, ce qui marque rouge toutes les PR de mise à jour de dépendances.
Les jobs de build et de tests du workflow restent joués sur ces PR.
2026-09-22 08:28:03 +02:00
Johan LEROYandGitHub 6a70443d52 Merge pull request #139 from ineszang/feat/deploy-rec-prod
feat(infra,ci): deux environnements rec et prod sur la VM ENI, déployés par un runner auto-hébergé
2026-09-22 08:27:08 +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
ValentinDeFariaandGitHub 1502eea036 Update package.json 2026-09-21 15:56:46 +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
PhyriosandGitHub cf50d3d36c Revise project milestones in README
Updated project milestones in the README to reflect new tasks and structure.
2026-09-21 14:42:41 +02:00
PhyriosandGitHub 4afcfb3ced Update project status in README.md 2026-09-21 14:28:56 +02:00
dependabot[bot]andGitHub 79b157d37e chore(deps): bump the frontend-dependencies group
Bumps the frontend-dependencies group in /apps/frontend with 12 updates:

| Package | From | To |
| --- | --- | --- |
| [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `22.1.6` | `22.1.7` |
| [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `22.1.6` | `22.1.7` |
| [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `22.1.6` | `22.1.7` |
| [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `22.1.6` | `22.1.7` |
| [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `22.1.6` | `22.1.7` |
| [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `22.1.6` | `22.1.7` |
| [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `22.1.6` | `22.1.7` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.11` | `5.0.1` |
| [jsdom](https://github.com/jsdom/jsdom) | `28.1.0` | `30.1.0` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [typescript](https://github.com/microsoft/TypeScript) | `6.0.3` | `7.0.2` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.11` | `5.0.1` |


Updates `@angular/common` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/common)

Updates `@angular/compiler` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/compiler)

Updates `@angular/core` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/core)

Updates `@angular/forms` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/forms)

Updates `@angular/platform-browser` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/platform-browser)

Updates `@angular/router` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/router)

Updates `@angular/compiler-cli` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/compiler-cli)

Updates `@vitest/coverage-v8` from 4.1.11 to 5.0.1
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.1/packages/coverage-v8)

Updates `jsdom` from 28.1.0 to 30.1.0
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v28.1.0...v30.1.0)

Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)

Updates `typescript` from 6.0.3 to 7.0.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v6.0.3...v7.0.2)

Updates `vitest` from 4.1.11 to 5.0.1
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.1/packages/vitest)

---
updated-dependencies:
- dependency-name: "@angular/common"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@angular/compiler"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@angular/core"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@angular/forms"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@angular/platform-browser"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@angular/router"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@angular/compiler-cli"
  dependency-version: 22.1.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 5.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: jsdom
  dependency-version: 30.1.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: prettier
  dependency-version: 3.9.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: vitest
  dependency-version: 5.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 12:19:44 +00:00
dependabot[bot]andGitHub 851e0279f6 chore(deps): bump the backend-dependencies group
Bumps the backend-dependencies group in /apps/backend with 2 updates: [pandas](https://github.com/pandas-dev/pandas) and [ruff](https://github.com/astral-sh/ruff).


Updates `pandas` from 3.0.5 to 3.0.6
- [Release notes](https://github.com/pandas-dev/pandas/releases)
- [Commits](https://github.com/pandas-dev/pandas/compare/v3.0.5...v3.0.6)

Updates `ruff` from 0.16.7 to 0.16.8
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.16.7...0.16.8)

---
updated-dependencies:
- dependency-name: pandas
  dependency-version: 3.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: backend-dependencies
- dependency-name: ruff
  dependency-version: 0.16.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: backend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 12:19:00 +00:00
dependabot[bot]andGitHub 7f025f72ac chore(deps): bump nginx from 1.28-alpine to 1.31-alpine
Bumps nginx from 1.28-alpine to 1.31-alpine.

---
updated-dependencies:
- dependency-name: nginx
  dependency-version: 1.31-alpine
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 12:17:47 +00:00
PhyriosandGitHub 1cd206adc8 Merge pull request #125 from ineszang/dev
Push dev to main
2026-09-21 14:17:01 +02:00
Valentin 268496a8c4 feat(ml): enregistrer le modèle dans le MLflow Model Registry 2026-09-21 12:04:49 +02:00
210 changed files with 16445 additions and 2014 deletions
+57 -11
View File
@@ -29,15 +29,18 @@ APP_MOCK_API_USERNAME=change_me
APP_MOCK_API_PASSWORD=change_me APP_MOCK_API_PASSWORD=change_me
APP_MOCK_API_TIMEOUT_SECONDS=10 APP_MOCK_API_TIMEOUT_SECONDS=10
# Airflow (webserver + scheduler, LocalExecutor). Base de métadonnées dédiée `airflow` dans le # Airflow (api-server + scheduler + dag-processor, LocalExecutor). Base de métadonnées dédiée `airflow` dans le
# même conteneur `db` (cf. db/init/120-airflow-database.sql), pas un conteneur de plus. # même conteneur `db` (cf. db/init/120-airflow-database.sql), pas un conteneur de plus.
AIRFLOW_PORT=8080 AIRFLOW_PORT=8080
# Chiffre les connexions/variables stockées par Airflow. Générer la vôtre : # Chiffre les connexions/variables stockées par Airflow. Générer la vôtre :
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
AIRFLOW_FERNET_KEY=change_me AIRFLOW_FERNET_KEY=change_me
# Clé Flask du webserver Airflow (signature de session), distincte de la précédente. Générer la # Clé de session de l'api-server Airflow, distincte de la précédente. Générer la
# vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))" # vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))"
AIRFLOW_WEBSERVER_SECRET_KEY=change_me AIRFLOW_API_SECRET_KEY=change_me
# Secret des jetons JWT entre scheduler, tâches et api-server (conteneurs distincts, le secret
# doit être partagé). Même générateur que ci-dessus.
AIRFLOW_JWT_SECRET=change_me
AIRFLOW_ADMIN_USERNAME=admin AIRFLOW_ADMIN_USERNAME=admin
# Compte Airflow créé au premier démarrage (service `airflow-init`), sans rapport avec les # Compte Airflow créé au premier démarrage (service `airflow-init`), sans rapport avec les
# comptes `app_user` d'EnerVision. # comptes `app_user` d'EnerVision.
@@ -49,25 +52,68 @@ AIRFLOW_ADMIN_EMAIL=admin@enervision.fr
# python -c "import secrets; print(secrets.token_urlsafe(48))" # python -c "import secrets; print(secrets.token_urlsafe(48))"
AIRFLOW_APP_SECRET_KEY=change_me AIRFLOW_APP_SECRET_KEY=change_me
# Garage, stockage objet S3 par environnement (ADR 0019) : un conteneur par projet Compose, publié
# sur 127.0.0.1 seulement. Les six secrets ci-dessous sont exigés par `make services-up` et
# `make stack-up` ; scripts/provision-host.sh les génère sur la VM.
# 32 octets en hexadécimal, rien d'autre n'est accepté : openssl rand -hex 32
GARAGE_RPC_SECRET=change_me
# Jetons de l'API d'administration et de /metrics (port 3903). Même générateur qu'APP_SECRET_KEY.
GARAGE_ADMIN_TOKEN=change_me
GARAGE_METRICS_TOKEN=change_me
# Clé S3 créée au premier démarrage (`--default-bucket`). Identifiant : echo "GK$(openssl rand -hex 12)"
# Secret : openssl rand -hex 32. Ne plus le changer ensuite, Garage refuserait de démarrer.
GARAGE_ACCESS_KEY=change_me
GARAGE_SECRET_KEY=change_me
GARAGE_BUCKET=enervision-archives
# Ports S3 et admin sur 127.0.0.1. Recette : 3910 et 3913, dev : 3920 et 3923.
GARAGE_S3_PORT=3900
GARAGE_ADMIN_PORT=3903
# Rétention des mesures (ADR 0019, 0020) : le DAG `retention` exporte chaque nuit vers Garage les
# chunks de `reading` plus vieux que cette borne, puis les supprime. L'historique de démonstration
# s'arrête fin 2024 : sous 21 mois, la démo disparaîtrait.
READING_RETENTION_DAYS=1095
# Clé SSE-C des archives, 32 octets en base64 : openssl rand -base64 32. La perdre rend les
# archives illisibles ; la sauvegarder hors de la VM.
GARAGE_SSE_KEY=change_me
# Stack complète derrière le reverse proxy (docker-compose.prod.yml). # Stack complète derrière le reverse proxy (docker-compose.prod.yml).
# PUBLIC_HOST alimente l'origine CORS, le lien de réinitialisation et le certificat. # PUBLIC_HOST alimente l'origine CORS, le lien de réinitialisation et le certificat.
PUBLIC_HOST=enervision.local PUBLIC_HOST=enervision.local
ACME_EMAIL= ACME_EMAIL=
# Deux environnements sur la même machine (ADR 0009) : un dossier, un `.env` et un projet Compose # Trois environnements sur la même machine (ADR 0009, 0017) : un dossier, un `.env` et un projet
# chacun. Le nom de projet préfixe volumes, réseau et conteneurs et l'emporte sur `name:`. # Compose chacun. Le nom de projet préfixe volumes, réseau et conteneurs et l'emporte sur `name:`.
# Vide sur un poste de développement : le projet reste `enervision`. # Vide sur un poste de développement : le projet reste `enervision`.
COMPOSE_PROJECT_NAME= COMPOSE_PROJECT_NAME=
# Origine publique, avec le port si le proxy HTTPS n'écoute pas 443. Vide : https://PUBLIC_HOST. # Origine publique, avec le port si le proxy HTTPS n'écoute pas 443. Vide : https://PUBLIC_HOST.
# Recette : PUBLIC_HOST=rec.enervision.local et PUBLIC_ORIGIN=https://rec.enervision.local:8443. # Sur la VM, provision-host.sh pose https://<nom de l'environnement>, sans port (frontal SNI).
PUBLIC_ORIGIN= PUBLIC_ORIGIN=
# Ports publiés par le proxy. Vides : 80 et 443. Recette : PROXY_HTTPS_PORT=8443 et # Ports publiés par le proxy. Vides : 80 et 443. Sur la VM, provision-host.sh les pose sur 127.0.0.1,
# PROXY_HTTP_PORT=127.0.0.1:8081, la redirection vers 443 n'ayant pas à être joignable de # derrière le frontal SNI, et décale aussi base, Mailpit et Airflow par environnement.
# l'extérieur. Décaler aussi POSTGRES_PORT, MAILPIT_UI_PORT et AIRFLOW_PORT (5434, 8026, 8082).
PROXY_HTTP_PORT= PROXY_HTTP_PORT=
PROXY_HTTPS_PORT= PROXY_HTTPS_PORT=
# Écouteur PROXY protocol du proxy, que seul le frontal de la VM joint (infra/front, ADR 0018).
# Vide : port aléatoire sur 127.0.0.1. VM : 127.0.0.1:10444 en prod, 8444 en recette, 9444 en dev.
PROXY_FRONT_PORT=
# Réglages mémoire de la stack déployée. Sans eux, timescaledb-tune réserve 25 % de la RAM de la # Réglages mémoire de la stack déployée. Sans eux, timescaledb-tune réserve 25 % de la RAM de la
# machine à chaque base au premier démarrage, et le webserver Airflow lance 4 workers gunicorn. # machine à chaque base au premier démarrage. L'api-server Airflow 3 n'a rien à régler ici : son
# nombre de workers vaut 1 par défaut, contre 4 pour le webserver d'Airflow 2.
TS_TUNE_MEMORY=2GB TS_TUNE_MEMORY=2GB
TS_TUNE_NUM_CPUS=2 TS_TUNE_NUM_CPUS=2
AIRFLOW_WEBSERVER_WORKERS=2
# Supervision (ADR 0016) : `monitoring` la démarre avec `make stack-up`, réglage de la prod.
# Vide ailleurs, où `make monitoring-up` la lance à la demande.
COMPOSE_PROFILES=
# Jeton présenté par Prometheus sur `/metrics`, exigé par l'API dès qu'il est posé. Requis dès
# que la supervision tourne ; même générateur que APP_SECRET_KEY.
APP_METRICS_TOKEN=change_me
# Compte `admin` de Grafana. Sans lui, le conteneur refuse de démarrer.
GRAFANA_ADMIN_PASSWORD=change_me
# Rôle PostgreSQL `supervision`, en lecture seule, de Grafana et de postgres-exporter
# (db/roles/supervision.sql, posé par `make db-ensure-supervision`).
SUPERVISION_DB_PASSWORD=change_me
# Interfaces publiées sur 127.0.0.1 seulement, par tunnel SSH. 3000 est pris par le frontend.
GRAFANA_PORT=3001
PROMETHEUS_PORT=9090
ALERTMANAGER_PORT=9093
+3
View File
@@ -0,0 +1,3 @@
self-hosted-runner:
labels:
- eni-g3
+20
View File
@@ -10,6 +10,26 @@ updates:
frontend-dependencies: frontend-dependencies:
patterns: patterns:
- "*" - "*"
ignore:
# Pourquoi : @angular/build épingle typescript (>=6.0 <6.1) et vitest (^4). Une majeure
# de l'un ou l'autre casse `npm ci` tant qu'Angular ne suit pas.
- dependency-name: "typescript"
update-types: ["version-update:semver-major"]
- dependency-name: "vitest"
update-types: ["version-update:semver-major"]
- dependency-name: "@vitest/coverage-v8"
update-types: ["version-update:semver-major"]
# Tests de bout en bout, paquet npm distinct du frontend
- package-ecosystem: "npm"
directory: "/tests/e2e"
schedule:
interval: "weekly"
open-pull-requests-limit: 2
groups:
e2e-dependencies:
patterns:
- "*"
# Backend — uv (lit pyproject.toml / uv.lock) # Backend — uv (lit pyproject.toml / uv.lock)
- package-ecosystem: "uv" - package-ecosystem: "uv"
+19 -36
View File
@@ -1,65 +1,46 @@
name: Airflow name: Airflow
# Piège : la version de Python vient de etl/airflow/.python-version. C'est 3.12 et non 3.14 # Piège : la version de Python vient de etl/airflow/.python-version. C'est 3.12 et non 3.14
# (contrairement à backend.yml et ml.yml) : apache-airflow 2.10 ne supporte pas 3.14. Le 3.14 de # (contrairement à backend.yml et ml.yml) : celui de l'image apache/airflow retenue, et les tests
# ml/ ne vit que dans l'image Docker, dans son propre environnement (cf. etl/airflow/Dockerfile). # doivent tourner sur le même interpréteur qu'elle. Le 3.14 de ml/ ne vit que dans l'image
# Docker, dans son propre environnement (cf. etl/airflow/Dockerfile).
# #
# Piège : l'image COPY les fichiers de dépendances et le code de ml/ et de apps/backend/. Une # Piège : l'image COPY les fichiers de dépendances et le code de ml/ et de apps/backend/. Une
# modification de l'un ou de l'autre peut donc casser sa construction, d'où ces chemins dans # modification de l'un ou de l'autre peut donc casser sa construction : le filtre `airflow` de
# les déclencheurs, alors même que ce workflow ne teste ni le modèle ni l'API. # ci.yml, qui appelle ce workflow, inclut ces chemins alors qu'il ne teste ni le modèle ni l'API.
on: on:
push: workflow_call:
paths:
- "etl/airflow/**"
- "ml/pyproject.toml"
- "ml/uv.lock"
- "ml/enervision_ml/**"
- "apps/backend/pyproject.toml"
- "apps/backend/uv.lock"
- "apps/backend/app/**"
- ".github/workflows/airflow.yml"
pull_request:
paths:
- "etl/airflow/**"
- "ml/pyproject.toml"
- "ml/uv.lock"
- "ml/enervision_ml/**"
- "apps/backend/pyproject.toml"
- "apps/backend/uv.lock"
- "apps/backend/app/**"
- ".github/workflows/airflow.yml"
permissions: permissions:
contents: read contents: read
concurrency:
group: airflow-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
verification: verification:
name: Lint et intégrité des DAGs name: Lint et intégrité des DAGs
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15
defaults: defaults:
run: run:
working-directory: etl/airflow working-directory: etl/airflow
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
# Action tierce, épinglée sur le commit du tag (règle Sonar githubactions:S7637).
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: etl/airflow/uv.lock cache-dependency-glob: etl/airflow/uv.lock
prune-cache: false
- name: Installe l'interpréteur déclaré par .python-version - name: Installe l'interpréteur déclaré par .python-version
run: uv python install run: uv python install
- name: Synchronise les dépendances sans dévier du verrou - name: Synchronise les dépendances sur le verrou
run: uv sync --all-groups --frozen run: uv sync --all-groups --locked
- name: Vérifie le formatage - name: Vérifie le formatage
run: uv run ruff format --check . run: uv run ruff format --check .
@@ -75,10 +56,11 @@ jobs:
image: image:
name: Construction de l'image name: Construction de l'image
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 25
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Construit l'image (contexte à la racine, elle COPY ml/ et apps/backend/) - name: Construit l'image (contexte à la racine, elle COPY ml/ et apps/backend/)
run: docker build -f etl/airflow/Dockerfile -t enervision-airflow:ci . run: docker build -f etl/airflow/Dockerfile -t enervision-airflow:ci .
@@ -92,11 +74,12 @@ jobs:
# `--help` sort par argparse avant `get_settings()` : ni base ni secret requis, et # `--help` sort par argparse avant `get_settings()` : ni base ni secret requis, et
# l'import des modules prouve que l'environnement /opt/backend est complet. # l'import des modules prouve que l'environnement /opt/backend est complet.
# Les deux commandes du DAG `alertes` et la commande du DAG historique sont couvertes. - name: Vérifie que les cinq commandes backend s'importent sans réseau
- name: Vérifie que les trois commandes backend s'importent sans réseau
run: > run: >
docker run --rm --network none enervision-airflow:ci docker run --rm --network none enervision-airflow:ci
bash -c "cd /opt/backend bash -c "cd /opt/backend
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.detection.internal_alerts --help && env -u VIRTUAL_ENV uv run --no-sync python -m app.detection.internal_alerts --help
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.cli generate-recommendations --help && env -u VIRTUAL_ENV uv run --no-sync python -m app.cli generate-recommendations --help
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.historical_import --help" && env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.historical_import --help
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.mock_api_import --help
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.reading_retention --help"
+39 -31
View File
@@ -2,47 +2,42 @@ name: Backend
# Piège : la version de Python vient de apps/backend/.python-version, et elle doit rester # Piège : la version de Python vient de apps/backend/.python-version, et elle doit rester
# en 3.14. Le code utilise le PEP 758, qu'un interpréteur 3.13 refuse de compiler. # en 3.14. Le code utilise le PEP 758, qu'un interpréteur 3.13 refuse de compiler.
# Pourquoi : aucun déclencheur propre. ci.yml appelle ce workflow quand le backend change, et
# Sonar y reprend la couverture versée par le job `verification` (ADR 0014).
on: on:
push: workflow_call:
paths:
- "apps/backend/**"
- ".github/workflows/backend.yml"
pull_request:
paths:
- "apps/backend/**"
- ".github/workflows/backend.yml"
permissions: permissions:
contents: read contents: read
concurrency:
group: backend-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
verification: verification:
name: Lint, typage et tests name: Lint, typage et tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15
defaults: defaults:
run: run:
working-directory: apps/backend working-directory: apps/backend
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
# Action tierce, épinglée sur le commit du tag (règle Sonar githubactions:S7637).
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: apps/backend/uv.lock cache-dependency-glob: apps/backend/uv.lock
prune-cache: false
- name: Installe l'interpréteur déclaré par .python-version - name: Installe l'interpréteur déclaré par .python-version
run: uv python install run: uv python install
- name: Synchronise les dépendances sans dévier du verrou # `--locked` et non `--frozen` : un verrou qui ne suit plus pyproject.toml doit casser ici.
run: uv sync --all-groups --frozen - name: Synchronise les dépendances sur le verrou
run: uv sync --all-groups --locked
- name: Vérifie le formatage - name: Vérifie le formatage
run: uv run ruff format --check . run: uv run ruff format --check .
@@ -55,14 +50,21 @@ jobs:
# Le marqueur `integration` est exclu par défaut, donc aucune base n'est nécessaire ici. # Le marqueur `integration` est exclu par défaut, donc aucune base n'est nécessaire ici.
- name: Tests et couverture - name: Tests et couverture
run: uv run pytest --cov-fail-under=85 run: uv run pytest --cov-fail-under=85 --cov-report=xml
# Piège : l'image est celle de docker-compose.yml, pas une image `postgres` nue. La première - name: Verse la couverture pour Sonar
# migration (`5353c0e4f094`) échoue volontairement si l'extension TimescaleDB manque, et un uses: actions/upload-artifact@v7
# écart d'image entre la CI et le poste rendrait ce job vert sur une base qui n'est pas la nôtre. with:
name: backend-coverage
path: apps/backend/coverage.xml
if-no-files-found: error
# Piège : même image que docker-compose.yml, pas un `postgres` nu. La première migration refuse
# de s'appliquer sans TimescaleDB, et une autre image testerait une base qui n'est pas la nôtre.
integration: integration:
name: Tests exigeant une base name: Tests exigeant une base
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15
defaults: defaults:
run: run:
working-directory: apps/backend working-directory: apps/backend
@@ -90,19 +92,20 @@ jobs:
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: apps/backend/uv.lock cache-dependency-glob: apps/backend/uv.lock
prune-cache: false
- name: Installe l'interpréteur déclaré par .python-version - name: Installe l'interpréteur déclaré par .python-version
run: uv python install run: uv python install
- name: Synchronise les dépendances sans dévier du verrou - name: Synchronise les dépendances sur le verrou
run: uv sync --all-groups --frozen run: uv sync --all-groups --locked
# Sur le poste, c'est db/init/110-test-database.sql qui pose l'extension. Ce fichier n'est # Sur le poste, c'est db/init/110-test-database.sql qui pose l'extension. Ce fichier n'est
# pas monté ici, et sans lui `alembic upgrade head` s'arrête sur la garde de la révision 1. # pas monté ici, et sans lui `alembic upgrade head` s'arrête sur la garde de la révision 1.
@@ -112,50 +115,55 @@ jobs:
- name: Applique les migrations - name: Applique les migrations
run: uv run alembic upgrade head run: uv run alembic upgrade head
# `-m` en ligne de commande écrase celui d'`addopts`. La couverture est désactivée : ce job # Couverture désactivée : ce job ne joue qu'une partie de la suite, son taux n'aurait
# ne joue qu'une partie de la suite, son taux n'aurait aucun sens face au seuil de 85 %. # aucun sens face au seuil de 85 %.
- name: Tests d'intégration - name: Tests d'intégration
run: uv run pytest -m integration --no-cov run: uv run pytest -m integration --no-cov
security-audit: security-audit:
name: Audit des dépendances name: Audit des dépendances
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
defaults: defaults:
run: run:
working-directory: apps/backend working-directory: apps/backend
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: apps/backend/uv.lock cache-dependency-glob: apps/backend/uv.lock
prune-cache: false
# L'audit porte sur le verrou, pas sur l'environnement : sinon pip-audit auditerait # L'audit porte sur le verrou, pas sur l'environnement : sinon pip-audit auditerait
# aussi les paquets que son propre `--with` injecte, hors dépendances du projet. # aussi les paquets que son propre `--with` injecte, hors dépendances du projet.
- name: Audite les dépendances livrées - name: Audite les dépendances livrées
# Piège : sans `shell: bash`, un échec de `uv export` serait masqué par le pipe. # Piège : sans `shell: bash`, un échec de `uv export` serait masqué par le pipe.
shell: bash shell: bash
run: uv export --frozen --no-dev --no-emit-project --no-hashes | uvx pip-audit --requirement /dev/stdin --no-deps run: uv export --locked --no-dev --no-emit-project --no-hashes | uvx pip-audit --requirement /dev/stdin --no-deps
sast: sast:
name: Analyse statique de sécurité name: Analyse statique de sécurité
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
defaults: defaults:
run: run:
working-directory: apps/backend working-directory: apps/backend
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc # Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit. # aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: false
# Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des # Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des
# secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod. # secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod.
+227
View File
@@ -0,0 +1,227 @@
# Pourquoi : un seul point d'entrée pour toute la CI (ADR 0014) - workflow CI. Chaque composant
# ne tourne que si ses fichiers changent, Sonar reprend les couvertures déjà produites au lieu de
# tout rejouer, et le déploiement ne part que d'un commit dont la CI est verte.
# Piège : le seul check à exiger dans les règles de branche est « CI ok ». Un job sauté par son
# filtre ne publie pas les checks de son workflow, qui resteraient en attente s'ils étaient exigés.
# Piège : sur un push vers dev ou main, tous les filtres valent vrai. paths-filter comparerait
# sinon à la base de fusion avec main, et Sonar n'analyserait qu'une partie de la branche.
# Piège : pas d'annulation des runs de push. Un run coupé en plein `make stack-up` laisserait la
# stack à moitié redémarrée ; le groupe par SHA évite aussi de mettre `dev` en file derrière lui.
name: CI
on:
pull_request:
push:
branches: [dev, main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
changes:
name: Périmètre modifié
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pull-requests: read
outputs:
backend: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.ci == 'true' || steps.filtre.outputs.backend == 'true' }}
frontend: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.ci == 'true' || steps.filtre.outputs.frontend == 'true' }}
ml: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.ci == 'true' || steps.filtre.outputs.ml == 'true' }}
airflow: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.ci == 'true' || steps.filtre.outputs.airflow == 'true' }}
terraform: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.ci == 'true' || steps.filtre.outputs.terraform == 'true' }}
compose: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.ci == 'true' || steps.filtre.outputs.compose == 'true' }}
workflows: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.workflows == 'true' }}
e2e: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.ci == 'true' || steps.filtre.outputs.e2e == 'true' }}
sonar: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.ci == 'true' || steps.filtre.outputs.sonar == 'true' }}
steps:
# Sur une PR, la liste des fichiers vient de l'API : ni checkout ni historique requis.
- name: Calcule le périmètre de la PR
id: filtre
if: github.event_name == 'pull_request'
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
with:
filters: |
ci:
- ".github/workflows/ci.yml"
backend:
- "apps/backend/**"
- ".github/workflows/backend.yml"
frontend:
- "apps/frontend/**"
- ".github/workflows/frontend.yml"
ml:
- "ml/**"
- "apps/backend/alembic/**"
- "apps/backend/app/models/**"
- "apps/backend/tests/test_chaine_ml_api.py"
- "apps/backend/pyproject.toml"
- "apps/backend/uv.lock"
- ".github/workflows/ml.yml"
airflow:
- "etl/airflow/**"
- "ml/pyproject.toml"
- "ml/uv.lock"
- "ml/enervision_ml/**"
- "apps/backend/pyproject.toml"
- "apps/backend/uv.lock"
- "apps/backend/app/**"
- ".github/workflows/airflow.yml"
terraform:
- "infra/terraform/**"
- ".github/workflows/infra.yml"
compose:
- "docker-compose*.yml"
- ".env.example"
- "infra/front/**"
- "infra/garage/**"
- "tests/garage/**"
- "monitoring/**"
- ".github/workflows/infra.yml"
workflows:
- ".github/**"
e2e:
- "apps/frontend/**"
- "apps/backend/app/**"
- "apps/backend/alembic/**"
- "apps/backend/Dockerfile"
- "apps/backend/pyproject.toml"
- "apps/backend/uv.lock"
- "infra/proxy/**"
- "docker-compose*.yml"
- "db/**"
- "tests/**"
- "scripts/comptes-test.sh"
- "scripts/tls-selfsigned.sh"
- "Makefile"
- ".env.example"
- ".github/workflows/e2e.yml"
sonar:
- "apps/backend/**"
- "apps/frontend/**"
- "ml/**"
- "etl/airflow/**"
- "sonar-project.properties"
backend:
name: Backend
needs: changes
if: needs.changes.outputs.backend == 'true'
uses: ./.github/workflows/backend.yml
frontend:
name: Frontend
needs: changes
if: needs.changes.outputs.frontend == 'true'
uses: ./.github/workflows/frontend.yml
ml:
name: ML
needs: changes
if: needs.changes.outputs.ml == 'true'
uses: ./.github/workflows/ml.yml
airflow:
name: Airflow
needs: changes
if: needs.changes.outputs.airflow == 'true'
uses: ./.github/workflows/airflow.yml
infra:
name: Infra
needs: changes
if: >-
needs.changes.outputs.terraform == 'true'
|| needs.changes.outputs.compose == 'true'
|| needs.changes.outputs.workflows == 'true'
uses: ./.github/workflows/infra.yml
with:
terraform: ${{ needs.changes.outputs.terraform == 'true' }}
compose: ${{ needs.changes.outputs.compose == 'true' }}
workflows: ${{ needs.changes.outputs.workflows == 'true' }}
e2e:
name: E2E
needs: changes
if: needs.changes.outputs.e2e == 'true'
uses: ./.github/workflows/e2e.yml
# Ni dependabot[bot] ni une PR de fork ne reçoivent SONAR_TOKEN : le scan échouerait sans rien
# analyser. Tests et couverture restent joués par leurs jobs.
sonar:
name: SonarQube
needs: [changes, backend, frontend, ml]
if: >-
always() && !cancelled()
&& !contains(needs.*.result, 'failure')
&& needs.changes.outputs.sonar == 'true'
&& github.actor != 'dependabot[bot]'
&& (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
with:
fetch-depth: 0
# Un téléchargement par rapport : backend et ML nomment tous deux le leur `coverage.xml`.
- name: Couverture du backend
if: needs.backend.result == 'success'
uses: actions/download-artifact@v8
with:
name: backend-coverage
path: apps/backend
- name: Couverture du pipeline ML
if: needs.ml.result == 'success'
uses: actions/download-artifact@v8
with:
name: ml-coverage
path: ml
- name: Couverture du frontend
if: needs.frontend.result == 'success'
uses: actions/download-artifact@v8
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend
# Action tierce, épinglée sur le commit du tag (règle Sonar githubactions:S7637).
- name: Analyse SonarQube
uses: SonarSource/sonarqube-scan-action@ba9859eae8dd6bd29e412f25ddbbef3d032000f4 # v8.2.2
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
ci-ok:
name: CI ok
needs: [changes, backend, frontend, ml, airflow, infra, e2e, sonar]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Refuse si un job a échoué ou a été annulé
env:
RESULTATS: ${{ toJSON(needs.*.result) }}
run: |
echo "$RESULTATS"
if grep -qE '"(failure|cancelled)"' <<<"$RESULTATS"; then
echo "::error::Au moins un job de la CI a échoué ou a été annulé."
exit 1
fi
deploy:
name: Déploiement
needs: ci-ok
if: ${{ !cancelled() && needs.ci-ok.result == 'success' && github.event_name == 'push' }}
uses: ./.github/workflows/deploy.yml
+316
View File
@@ -0,0 +1,316 @@
name: DAST
# Scan dynamique OWASP ZAP de l'API (issue #41). Il attaque une API qui tourne : le job démarre
# la base et le backend sur le runner, sème le jeu de démonstration (sans ça le scan ne frappe que
# des gestionnaires d'erreur), crée des comptes jetables (scripts/dast-token.sh), puis lance ZAP
# sur le contrat OpenAPI avec le jeton du `lecteur`.
#
# Non bloquant pour l'instant sur les alertes (`continue-on-error` sur la seule étape du scan) :
# le volume d'un premier passage trié est inconnu. Deux étapes suivantes, elles, bloquent si le
# scan n'a rien testé (import du contrat, absence de toute réponse de succès) : un job vert doit
# vouloir dire qu'un scan a eu lieu.
#
# Piège : ce scan tape la configuration par défaut du backend (`APP_ENV=local`, pas de TLS, pas
# de reverse proxy). Il ne dit rien des en-têtes ni du TLS posés par le proxy en production, et
# remontera des alertes (HSTS absent...) qui n'existent pas derrière lui.
on:
workflow_dispatch:
schedule:
# Un scan actif est long : hebdomadaire plutôt qu'à chaque PR.
- cron: "0 3 * * 1"
pull_request:
# Ne se lance sur une PR que si le scan lui-même change.
paths:
- ".github/workflows/dast.yml"
- "scripts/dast-token.sh"
- "scripts/comptes-test.sh"
- "db/seeds/**"
permissions:
contents: read
concurrency:
group: dast-${{ github.ref }}
cancel-in-progress: true
jobs:
zap:
name: Scan OWASP ZAP de l'API
runs-on: ubuntu-latest
# Généreux face aux ~2 minutes observées de bout en bout : le vrai plafond est
# `scanner.maxScanDurationInMins` (étape Scan ZAP), sous le TTL du jeton. Une annulation par
# ce timeout-ci n'exécute pas les étapes `always()` : mieux vaut ne jamais l'atteindre.
timeout-minutes: 30
# Même image que docker-compose.yml : la première migration refuse de s'appliquer sans
# l'extension TimescaleDB (cf. backend.yml).
services:
db:
image: timescale/timescaledb-ha:pg17
env:
POSTGRES_USER: enervision
POSTGRES_PASSWORD: change_me
POSTGRES_DB: enervision_dast
ports:
- "5433:5432"
options: >-
--health-cmd "pg_isready -U enervision -d enervision_dast"
--health-interval 10s
--health-timeout 5s
--health-retries 12
--health-start-period 40s
env:
# Base jetable : ZAP y écrira et le script y crée deux comptes.
DATABASE_URL: postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_dast
APP_SECRET_KEY: secret-de-scan-assez-long-pour-le-validateur
APP_ENV: local
# Le jeton du lecteur doit survivre à toute la durée du scan (15 minutes par défaut).
# 3600 est le plafond accepté par la configuration ; `scanner.maxScanDurationInMins`
# (étape Scan ZAP) reste très en dessous, marge comprise pour les étapes qui l'entourent.
APP_ACCESS_TOKEN_TTL_SECONDS: "3600"
PGPASSWORD: change_me
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
- name: Installe uv
# Épinglé sur le commit du tag v7 (règle Sonar githubactions:S7637 : dépendance tierce,
# contrairement à actions/checkout ou actions/upload-artifact, premières parties).
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
# `prune-cache` vaut `true` par défaut (encore sur ce commit) : l'étape de post-job
# « Pruning cache » est restée bloquée 5 minutes avant d'échouer (exit code 2) sur un
# run où les 16 étapes précédentes passaient, sans lien avec le scan. Le prune n'est
# qu'une optimisation de taille de cache entre deux runs, pas une garantie : le
# désactiver retire le blocage sans rien changer au comportement du job.
prune-cache: false
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
working-directory: apps/backend
# `--no-build` : aucune dépendance n'est construite depuis ses sources, donc aucun script de
# build exécuté (règle Sonar S8541). Le projet lui-même n'est pas installé : il tourne depuis
# `apps/backend`, comme dans son Dockerfile. Les `uv run` suivants portent `--frozen
# --no-sync` pour ne rien résoudre ni reconstruire (règle S8544).
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --locked --no-dev --no-install-project --no-build
working-directory: apps/backend
- name: Active TimescaleDB sur la base du scan
run: psql -h localhost -p 5433 -U enervision -d enervision_dast -c "CREATE EXTENSION IF NOT EXISTS timescaledb"
- name: Applique les migrations
run: uv run --frozen --no-sync --no-build alembic upgrade head
working-directory: apps/backend
# Sans données, `GET /sites` rend `[]`, chaque `/{site_id}` rend 404 et le scan actif ne
# frappe que des gestionnaires d'erreur plutôt que la logique métier.
- name: Sème le jeu de démonstration
run: psql -h localhost -p 5433 -U enervision -d enervision_dast -v ON_ERROR_STOP=1 -f db/seeds/demo.sql
- name: Démarre l'API
run: |
nohup uv run --frozen --no-sync --no-build uvicorn app.main:create_app --factory \
--host 0.0.0.0 --port 8000 > "$RUNNER_TEMP/api.log" 2>&1 &
for _ in $(seq 1 30); do
curl -fsS http://localhost:8000/api/v1/health/ready >/dev/null 2>&1 && exit 0
sleep 2
done
echo "L'API ne répond pas sur /health/ready" >&2
cat "$RUNNER_TEMP/api.log" >&2
exit 1
working-directory: apps/backend
- name: Crée le compte lecteur du scan
id: jeton
run: |
jeton="$(../../scripts/dast-token.sh)"
echo "::add-mask::$jeton"
echo "jeton=$jeton" >> "$GITHUB_OUTPUT"
working-directory: apps/backend
# Étape distincte du scan lui-même, et sans `continue-on-error` : un `curl` qui échoue ici
# (API tombée juste après la sonde de readiness, par exemple) doit rester un échec visible,
# pas se travestir en « ZAP n'a importé aucune URL » à l'étape de garde suivante.
- name: Prépare le contrat pour ZAP
run: |
mkdir -p zap-out zap-logs
curl -fsS http://localhost:8000/openapi.json -o zap-out/openapi.json
# Le dossier passe à l'uid 1000 (utilisateur du conteneur ZAP) : le runner n'y écrit
# plus après ce chown, d'où `zap-logs/` (uid du runner) pour les journaux ci-dessous.
# Pas de `chmod 777` (règle Sonar S2612).
sudo chown -R 1000:1000 zap-out
# `--network host` : ZAP atteint l'API sur le localhost du runner.
#
# Piège vécu : la clé du nom d'en-tête est `matchstr`, pas `matchstring`. ZAP accepte
# n'importe quelle clé `-config` sans erreur ; avec la mauvaise, il ajoutait à TOUTES les
# requêtes un en-tête au nom vide (`: Bearer <jeton>`), qu'uvicorn refuse par un 400
# (« Invalid HTTP request received »), y compris sur les routes publiques.
#
# Le jeton ne passe ni par `${{ }}` dans ce script (il finirait en clair dans le fichier de
# commande que GitHub écrit sur le disque du runner pour toute la durée de l'étape), ni par
# l'argv de `docker run` (visible par `ps aux` et par `docker inspect zap` tant que le
# conteneur existe) : il est écrit dans un fichier de configuration ZAP séparé, monté en
# lecture seule hors de `/zap/wrk` pour ne jamais atterrir dans l'artefact publié.
#
# Les routes d'authentification qui changent l'état du compte du scan sont exclues : un
# scan actif y déclencherait la limitation de débit du login, la réinitialisation de mots de
# passe et la fermeture des sessions, sans rien apprendre de plus.
#
# `scanner.maxScanDurationInMins`/`maxRuleDurationInMins` bornent le scan actif, que `-T` ne
# couvre pas (il ne borne que le démarrage et le scan passif) : sans ça, une règle qui
# traîne peut dépasser le TTL du jeton (401 muets en fin de scan) ou le timeout du job (qui
# annule sans exécuter les étapes `always()`, rapport et journaux perdus).
- name: Scan ZAP
id: zap
continue-on-error: true
env:
JETON: ${{ steps.jeton.outputs.jeton }}
run: |
set -o pipefail
printf 'replacer.full_list(0).description=auth\nreplacer.full_list(0).enabled=true\nreplacer.full_list(0).matchtype=REQ_HEADER\nreplacer.full_list(0).matchstr=Authorization\nreplacer.full_list(0).regex=false\nreplacer.full_list(0).replacement=Bearer %s\n' "$JETON" > "$RUNNER_TEMP/zap-auth.conf"
# Piège vécu : `chmod 600` seul rend le fichier illisible pour le conteneur, qui lit un
# montage bind avec son propre uid (1000), distinct de celui du runner qui l'a écrit.
# ZAP échoue alors dès le lancement (« File not readable: /zap/auth.conf »), et
# `zap-api-scan.py` attend `-T` minutes complètes avant d'abandonner : dix minutes qui
# ressemblent à un scan actif, pour un daemon mort depuis le début.
#
# Piège vécu (numéro deux) : une fois le fichier passé à l'uid 1000 par `sudo chown`,
# l'utilisateur du runner n'en est plus propriétaire et un `chmod` sans `sudo` échoue
# (« Operation not permitted »). Avec le `-e` implicite de bash sur les étapes GitHub
# Actions, cette erreur arrêtait toute l'étape avant même `docker run` : scan « réussi »
# en une fraction de seconde, sans le moindre journal ni rapport produit.
sudo chown 1000:1000 "$RUNNER_TEMP/zap-auth.conf"
sudo chmod 644 "$RUNNER_TEMP/zap-auth.conf"
docker run --name zap --network host \
-v "$PWD/zap-out:/zap/wrk:rw" \
-v "$RUNNER_TEMP/zap-auth.conf:/zap/auth.conf:ro" \
ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py \
-t /zap/wrk/openapi.json -f openapi -O http://localhost:8000 \
-T 10 \
-r zap-report.html -J zap-report.json -w zap-report.md \
-z "-configfile /zap/auth.conf \
-config globalexcludeurl.url_list.url(0).description=auth-etat \
-config globalexcludeurl.url_list.url(0).enabled=true \
-config globalexcludeurl.url_list.url(0).regex='.*/api/v1/auth/(login|password|logout-all|forgot-password|reset-password).*' \
-config scanner.maxScanDurationInMins=15 \
-config scanner.maxRuleDurationInMins=5" \
2>&1 | tee "$RUNNER_TEMP/zap-stdout.log"
- name: Récupère les journaux de ZAP
if: always()
run: |
mkdir -p zap-logs
# ZAP journalise la valeur de chaque `-config`/`-configfile` chargé, y compris le jeton,
# à un niveau visible sans `-d` : les copies publiées en artefact sont donc caviardées,
# même si `::add-mask::` (posé à la création du jeton) protège déjà le journal du job.
masque() { sed -E 's/(Bearer )[A-Za-z0-9._-]+/\1[MASQUE]/Ig'; }
[ -f "$RUNNER_TEMP/zap-stdout.log" ] && masque < "$RUNNER_TEMP/zap-stdout.log" > zap-logs/zap-stdout.log
docker cp zap:/home/zap/.ZAP/zap.log "$RUNNER_TEMP/zap-internal.log" 2>/dev/null || true
[ -f "$RUNNER_TEMP/zap-internal.log" ] && masque < "$RUNNER_TEMP/zap-internal.log" > zap-logs/zap.log
[ -f "$RUNNER_TEMP/api.log" ] && masque < "$RUNNER_TEMP/api.log" > zap-logs/api.log
rm -f "$RUNNER_TEMP/zap-auth.conf"
docker rm -f zap >/dev/null 2>&1 || true
# `continue-on-error` sur le scan ne doit pas faire passer pour vert un scan qui n'a rien
# testé. Constaté une première fois : 2 URL importées sur 26 opérations, ZAP n'avait envoyé
# que des requêtes vouées au 404. Le seuil est dérivé du contrat plutôt que d'un nombre fixe
# : un contrat qui grossit ne doit pas rendre la garde plus permissive qu'elle ne l'était.
- name: Vérifie que le contrat a bien été importé
run: |
attendu="$(python3 -c "
import json
d = json.load(open('zap-out/openapi.json'))
methodes = ('get', 'post', 'put', 'patch', 'delete', 'head', 'options')
print(sum(1 for chemin in d['paths'].values() for m in chemin if m in methodes))
")"
minimum=$((attendu * 80 / 100))
importees="$(sed -n 's/.*Number of Imported URLs: \([0-9]*\).*/\1/p' "$RUNNER_TEMP/zap-stdout.log" | tail -1)"
echo "URL importées depuis le contrat OpenAPI : ${importees:-aucune} (contrat : $attendu opérations, minimum accepté : $minimum)"
if [ "${importees:-0}" -lt "$minimum" ]; then
echo "::error::ZAP n'a importé que ${importees:-0} URL sur $attendu opérations du contrat OpenAPI (minimum attendu : $minimum, soit 80%). Le scan n'a pas testé l'API, voir zap-logs/zap.log dans l'artefact zap-report."
exit 1
fi
# Deuxième garde-fou : le contrat peut être importé et ZAP n'obtenir que des erreurs
# (constaté : base sans données, toutes les routes de site répondaient 404).
#
# Piège de conception, trouvé en répétant ce job en local avant de l'écrire ici : borner le
# pourcentage de 4xx ne marche pas. Un scan actif fuzze délibérément un grand nombre
# d'entrées invalides (identifiants inventés, méthodes non supportées...), donc même un scan
# sain, contre l'API seedée juste au-dessus, reste à 98% de 4xx avec seulement 1% de 2xx :
# c'est la forme normale d'un scan actif, pas un signe d'échec. Le signal qui distingue
# vraiment un scan cassé (0% de 2xx, `insight.code.2xx` absent du rapport dans le premier
# incident) d'un scan sain (2xx non nul, aussi faible soit-il) est donc l'absence de succès,
# pas la part d'échecs. Dérivé de `zap-report.json` (champ structuré `insights[]`) plutôt
# que du texte libre du rapport Markdown, qui aurait le même défaut de conception en plus
# d'être fragile au format.
- name: Vérifie que le scan a obtenu au moins une réponse de succès
run: |
python3 - <<'PY'
import json
import sys
try:
rapport = json.load(open("zap-out/zap-report.json"))
except FileNotFoundError:
print("::error::Aucun rapport ZAP produit : le scan n'a rien testé.")
sys.exit(1)
pourcentage_2xx = 0.0
for insight in rapport.get("insights", []):
if insight.get("key") == "insight.code.2xx":
pourcentage_2xx = float(insight.get("statistic", 0))
break
print(f"Pourcentage de réponses 2xx : {pourcentage_2xx}%")
if pourcentage_2xx <= 0:
print(
"::error::Aucune réponse 2xx (succès) reçue : le scan n'a atteint aucune route "
"réelle de l'API. Voir zap-logs/api.log et zap-logs/zap.log dans l'artefact "
"zap-report."
)
sys.exit(1)
PY
# Uniquement la synthèse (jusqu'à « Alert Detail » exclu) : `$GITHUB_STEP_SUMMARY` est
# limité à 1 Mio, et cette étape tourne sous `always()` - son échec ferait échouer le job
# après le passage des deux garde-fous, pour une simple raison de mise en forme. Le rapport
# complet reste dans l'artefact `zap-report`.
- name: Publie le résumé
if: always()
run: |
if [ -f zap-out/zap-report.md ]; then
{
awk '/^## Alert Detail/{exit} {print}' zap-out/zap-report.md
echo ""
echo "Rapport complet (HTML/JSON/Markdown) dans l'artefact \`zap-report\`."
} >> "$GITHUB_STEP_SUMMARY"
else
echo "Aucun rapport ZAP produit, voir le journal du job." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Publie les rapports
if: always()
uses: actions/upload-artifact@v7
with:
name: zap-report
path: |
zap-out/
zap-logs/
if-no-files-found: warn
# Diagnostic de dernier recours : les journaux de l'API sont déjà dans l'artefact
# (zap-logs/api.log) via l'étape « Récupère les journaux de ZAP » (always()), mais les
# afficher directement dans le journal du job évite d'avoir à le télécharger pour un échec
# évident (l'API n'a jamais démarré, par exemple).
- name: Journal de l'API en cas d'échec
if: failure() || steps.zap.outcome == 'failure'
run: cat "$RUNNER_TEMP/api.log" || true
+39 -21
View File
@@ -1,57 +1,75 @@
# Pourquoi : le runner tourne sur la VM ENI, adresse privée que les runners hébergés par GitHub # Pourquoi : le runner tourne sur la VM ENI, adresse privée que les runners hébergés par GitHub
# ne joignent pas, et travaille dans un dossier stable par environnement plutôt que dans son # ne joignent pas, et travaille dans un dossier stable par environnement plutôt que dans son
# espace de travail : `.env`, certificats et volumes y survivent d'un déploiement à l'autre. # espace de travail : `.env`, certificats et volumes y survivent d'un déploiement à l'autre.
# Pourquoi : appelé par ci.yml une fois « CI ok » vert, jamais directement par un push, et il
# déploie `GITHUB_SHA`, le commit testé, pas la pointe de branche du moment (ADR 0014).
# Pourquoi : `main` va en prod, `dev` en recette, et toute autre branche lancée à la main
# (workflow_dispatch) va dans `dev`, la vitrine d'une branche de travail (ADR 0017).
# Piège : jamais de déclencheur `pull_request` ici. Sur un dépôt public, une PR de fork # Piège : jamais de déclencheur `pull_request` ici. Sur un dépôt public, une PR de fork
# exécuterait son code sur la machine de production (ADR 0009) - job deploy. # exécuterait son code sur la machine de production (ADR 0009) - job deploy.
# Piège : les CI de deux push finissent parfois dans le désordre. Un commit qui précède celui déjà
# déployé depuis la même branche est ignoré, et le verrou est un `flock` sur le dossier de
# l'environnement plutôt qu'un groupe `concurrency` : GitHub n'y garde qu'un job en attente, et
# le suivant l'évince sans bruit.
name: Déploiement name: Déploiement
on: on:
push: workflow_call:
branches: [dev, main]
workflow_dispatch: workflow_dispatch:
permissions: permissions:
contents: read contents: read
concurrency:
group: deploy-${{ github.ref_name }}
cancel-in-progress: false
jobs: jobs:
deploy: deploy:
name: Déploie sur la VM
runs-on: [self-hosted, linux, eni-g3] runs-on: [self-hosted, linux, eni-g3]
timeout-minutes: 30 timeout-minutes: 30
environment: environment:
name: ${{ github.ref_name == 'main' && 'prod' || 'rec' }} name: ${{ github.ref_name == 'main' && 'prod' || github.ref_name == 'dev' && 'rec' || 'dev' }}
url: ${{ github.ref_name == 'main' && 'https://enervision.local' || 'https://rec.enervision.local:8443' }} url: ${{ github.ref_name == 'main' && 'https://prod.enervision-g3.dynv6.net' || github.ref_name == 'dev' && 'https://rec.enervision-g3.dynv6.net' || 'https://dev.enervision-g3.dynv6.net' }}
env: env:
ENVIRONNEMENT: ${{ github.ref_name == 'main' && 'prod' || 'rec' }} ENVIRONNEMENT: ${{ github.ref_name == 'main' && 'prod' || github.ref_name == 'dev' && 'rec' || 'dev' }}
PORT_HTTPS: ${{ github.ref_name == 'main' && '443' || '8443' }} PORT_HTTPS: ${{ github.ref_name == 'main' && '10443' || github.ref_name == 'dev' && '8443' || '9443' }}
steps: steps:
- name: Aligner le dossier de l'environnement sur la branche poussée # Un seul step : le verrou tombe avec le shell qui l'a posé.
- name: Déploie le commit testé, sans jamais reculer
run: | run: |
cd "/srv/enervision/${ENVIRONNEMENT}" cd "/srv/enervision/${ENVIRONNEMENT}"
exec 9>"$(git rev-parse --git-dir)/verrou-deploiement"
flock 9
echo "::group::Aligne le dossier de l'environnement sur le commit testé"
git fetch --quiet origin "${GITHUB_REF_NAME}" git fetch --quiet origin "${GITHUB_REF_NAME}"
deploye="$(git rev-parse HEAD)"
if [ "$(git branch --show-current)" = "$GITHUB_REF_NAME" ] && [ "$deploye" != "$GITHUB_SHA" ] \
&& git merge-base --is-ancestor "$GITHUB_SHA" "$deploye"; then
echo "::notice::${GITHUB_SHA:0:7} précède le commit déjà déployé (${deploye:0:7}) : rien à déployer."
exit 0
fi
git checkout --quiet "${GITHUB_REF_NAME}" git checkout --quiet "${GITHUB_REF_NAME}"
git reset --quiet --hard "origin/${GITHUB_REF_NAME}" git reset --quiet --hard "${GITHUB_SHA}"
git log -1 --format='%h %s' git log -1 --format='%h %s'
echo "::endgroup::"
- name: Reconstruire et redémarrer la stack echo "::group::Reconstruit et redémarre la stack"
run: | # Un `.env` pas encore réaligné par provision-host.sh porte encore un nom en `.local`.
cd "/srv/enervision/${ENVIRONNEMENT}" if [ -r ../dns.token ] && ! grep -q '^PUBLIC_HOST=.*\.local$' .env; then make tls-dns01; fi
make stack-up make stack-up
if [ "${ENVIRONNEMENT}" = prod ]; then make front-up; fi
echo "::endgroup::"
- name: Attendre que l'API réponde derrière le proxy echo "::group::Attend que l'API réponde derrière le proxy"
run: | for _ in $(seq 1 36); do
for tentative in $(seq 1 36); do
if curl --fail --silent --insecure "https://localhost:${PORT_HTTPS}/api/v1/health/ready"; then if curl --fail --silent --insecure "https://localhost:${PORT_HTTPS}/api/v1/health/ready"; then
exit 0 exit 0
fi fi
sleep 5 sleep 5
done done
echo "::endgroup::"
echo "L'API ne répond pas après 3 minutes" >&2 echo "L'API ne répond pas après 3 minutes" >&2
cd "/srv/enervision/${ENVIRONNEMENT}" compose="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
docker compose ps $compose ps
docker compose logs --tail=50 backend proxy $compose logs --tail=50 backend proxy
exit 1 exit 1
+135
View File
@@ -0,0 +1,135 @@
name: E2E
# Pourquoi : les parcours tournent contre la stack telle qu'elle est déployée, derrière le proxy
# TLS (cookie `__Secure-`, CSP, limitation de débit), pas contre `ng serve` - job parcours. Il
# construit aussi les images backend et frontend, que rien d'autre ne construit avant le
# déploiement (ADR 0015).
# Piège : pas d'Airflow ici. `up` nomme ses services : sans eux, la construction de l'image
# Airflow doublerait la durée du job sans rien tester de plus.
on:
workflow_call:
permissions:
contents: read
jobs:
parcours:
name: Parcours Playwright et tirs k6
runs-on: ubuntu-latest
timeout-minutes: 30
env:
COMPOSE_FILE: docker-compose.yml:docker-compose.prod.yml
PUBLIC_HOST: localhost
E2E_BASE_URL: https://localhost
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
- name: Prépare le .env de la stack
run: |
secret() { openssl rand -hex 32; }
sed -e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(secret)|" \
-e "s|^APP_SECRET_KEY=.*|APP_SECRET_KEY=$(secret)|" \
-e "s|^PUBLIC_HOST=.*|PUBLIC_HOST=localhost|" \
.env.example > .env
- name: Génère le certificat de démonstration
run: ./scripts/tls-selfsigned.sh
- name: Construit et démarre la stack derrière le proxy
run: docker compose up --detach --build --wait --wait-timeout 300 db mailpit backend frontend proxy
- name: Applique les migrations
run: docker compose exec -T backend alembic upgrade head
# Même cible que `make stack-up` en prod : les droits du rôle portent sur le schéma réel.
- name: Pose le rôle de supervision en lecture seule
run: make db-ensure-supervision
- name: Sème le jeu de démonstration
run: docker compose exec -T db psql -U enervision -d enervision -v ON_ERROR_STOP=1 < db/seeds/demo.sql
- name: Crée les comptes de test
env:
BASE_URL: https://localhost
APP_CLI: docker compose exec -T backend python -m app.cli
COMPTES_FICHIER: ${{ runner.temp }}/comptes.json
run: ./scripts/comptes-test.sh
- name: Installe Node
uses: actions/setup-node@v7
with:
node-version: 26
cache: npm
cache-dependency-path: tests/e2e/package-lock.json
- name: Installe Playwright
working-directory: tests/e2e
run: npm ci
- name: Restaure les navigateurs de Playwright
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('tests/e2e/package-lock.json') }}
# `--with-deps` tourne même quand le cache a servi : il pose aussi les bibliothèques système.
- name: Installe Chromium
working-directory: tests/e2e
run: npx playwright install --with-deps chromium
- name: Joue les parcours
working-directory: tests/e2e
env:
E2E_COMPTES: ${{ runner.temp }}/comptes.json
run: npx playwright test
# Direct sur `backend:8000` : ce tir mesure l'API, pas la limitation de nginx.
- name: Tir k6 de fumée sur l'API
env:
K6_RESUME: /results/resume-smoke.md
run: |
K6_EMAIL="$(jq -r .lecteur.email "$RUNNER_TEMP/comptes.json")"
K6_PASSWORD="$(jq -r .lecteur.password "$RUNNER_TEMP/comptes.json")"
echo "::add-mask::$K6_PASSWORD"
export K6_EMAIL K6_PASSWORD
make load-smoke
- name: Vérifie par k6 que le proxy limite le débit
env:
K6_RESUME: /results/resume-limitation.md
run: make load-limits
- name: Publie la synthèse k6
if: ${{ !cancelled() }}
run: cat tests/load/results/resume-*.md >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
- name: Publie les rapports k6
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: k6-rapports
path: tests/load/results/
if-no-files-found: ignore
retention-days: 14
- name: Publie le rapport Playwright
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: |
tests/e2e/playwright-report/
tests/e2e/test-results/
if-no-files-found: ignore
retention-days: 14
- name: Journaux de la stack en cas d'échec
if: failure()
run: docker compose logs --tail=200 backend proxy frontend
- name: Arrête la stack
if: always()
run: docker compose down --volumes
+48 -42
View File
@@ -1,64 +1,70 @@
name: Frontend name: Frontend
# Pourquoi : aucun déclencheur propre. ci.yml appelle ce workflow quand le frontend change, et
# Sonar y reprend la couverture versée par le job `verification` (ADR 0014).
on: on:
push: workflow_call:
paths:
- "apps/frontend/**"
- ".github/workflows/frontend.yml"
pull_request:
paths:
- "apps/frontend/**"
- ".github/workflows/frontend.yml"
permissions: permissions:
contents: read contents: read
jobs: jobs:
build: # Un seul `npm ci` pour la construction et les tests : un job de plus ne ferait que le rejouer.
verification:
name: Construction et tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: apps/frontend
steps: steps:
- uses: actions/checkout@v6 - name: Récupère le dépôt
- uses: actions/setup-node@v6 uses: actions/checkout@v7
- name: Installe Node
uses: actions/setup-node@v7
with: with:
node-version: 24 node-version: 26
cache: npm cache: npm
cache-dependency-path: apps/frontend/package-lock.json cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci - name: Installe les dépendances
working-directory: apps/frontend run: npm ci
- run: npm run build
working-directory: apps/frontend - name: Construit l'application
run: npm run build
# Piège : `npm test --watch=false` garde l'option pour npm, `ng test` ne la reçoit jamais.
# La couverture lcov vient d'angular.json (`coverage: true`).
- name: Tests et couverture
run: npm run test:ci
- name: Verse la couverture pour Sonar
uses: actions/upload-artifact@v7
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info
if-no-files-found: error
security-audit: security-audit:
name: Audit des dépendances name: Audit des dépendances
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: timeout-minutes: 10
- uses: actions/checkout@v6 defaults:
- uses: actions/setup-node@v6 run:
with:
node-version: 24
# Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison.
- run: npm audit --audit-level=high --package-lock-only
working-directory: apps/frontend working-directory: apps/frontend
test:
needs: build
runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - name: Récupère le dépôt
- uses: actions/setup-node@v6 uses: actions/checkout@v7
- name: Installe Node
uses: actions/setup-node@v7
with: with:
node-version: 24 node-version: 26
cache: npm
cache-dependency-path: apps/frontend/package-lock.json # Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison.
- name : Installation des dépendances (Front) - name: Audite le verrou
run: npm ci run: npm audit --audit-level=high --package-lock-only
working-directory: apps/frontend
- name : Lancement des tests et génénration du rapport de couverture (Front)
run: npm test --watch=false --code-coverage --coverageReporters=lcov
working-directory: apps/frontend
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info
+140
View File
@@ -0,0 +1,140 @@
name: Infra
# Pourquoi : rien de ce qui décrit l'infrastructure ne s'exécute avant le déploiement. Terraform est
# resté cassé sans que rien ne le dise, faute de job qui le joue : ce workflow n'applique rien, il
# vérifie le Terraform, les fichiers Compose et les workflows eux-mêmes - jobs terraform, compose,
# workflows. ci.yml choisit par ses entrées ceux qui tournent (ADR 0014).
# Piège : la boucle Terraform parcourt `environments/*`, pour qu'une racine ajoutée soit couverte
# sans toucher à ce fichier.
on:
workflow_call:
inputs:
terraform:
type: boolean
default: false
compose:
type: boolean
default: false
workflows:
type: boolean
default: false
permissions:
contents: read
jobs:
terraform:
name: Formatage et validation Terraform
if: inputs.terraform
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
# Action tierce, épinglée sur le commit du tag (règle 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
compose:
name: Validation des fichiers Compose et de la supervision
if: inputs.compose
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
# Compose interpole tout le fichier : les `:?` exigent une valeur, pas un vrai secret.
- name: Prépare un .env d'exemple
run: cp .env.example .env
# Garage refuse un rpc_secret qui n'est pas 32 octets hexadécimaux : `change_me` ne suffit pas.
- name: Génère les secrets Garage du .env
run: |
sed -i -e "s|^GARAGE_RPC_SECRET=.*|GARAGE_RPC_SECRET=$(openssl rand -hex 32)|" \
-e "s|^GARAGE_ADMIN_TOKEN=.*|GARAGE_ADMIN_TOKEN=$(openssl rand -hex 32)|" \
-e "s|^GARAGE_METRICS_TOKEN=.*|GARAGE_METRICS_TOKEN=$(openssl rand -hex 32)|" \
-e "s|^GARAGE_ACCESS_KEY=.*|GARAGE_ACCESS_KEY=GK$(openssl rand -hex 12)|" \
-e "s|^GARAGE_SECRET_KEY=.*|GARAGE_SECRET_KEY=$(openssl rand -hex 32)|" \
-e "s|^GARAGE_SSE_KEY=.*|GARAGE_SSE_KEY=$(openssl rand -base64 32)|" .env
- name: Valide la stack de développement
run: docker compose config --quiet
- name: Valide la stack déployée, profils compris
run: docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile acme --profile monitoring --profile load config --quiet
- name: Valide le frontal SNI de la VM
run: |
docker compose -f infra/front/compose.yml config --quiet
docker run --rm -v "$PWD/infra/front/nginx.conf:/etc/nginx/nginx.conf:ro" nginx:1.31-alpine nginx -t
# Mêmes commandes que `make monitoring-check` : images et montages viennent du fichier Compose.
- name: Valide la configuration de Prometheus et ses règles
run: docker compose --profile monitoring run --rm --no-deps --entrypoint promtool prometheus check config /etc/prometheus/prometheus.yml
- name: Joue les tests unitaires des règles d'alerte
run: docker compose --profile monitoring run --rm --no-deps --entrypoint promtool prometheus test rules /etc/prometheus/tests/enervision.test.yml
- name: Valide la configuration d'Alertmanager
run: docker compose --profile monitoring run --rm --no-deps --entrypoint amtool alertmanager check-config /etc/alertmanager/alertmanager.yml
- name: Valide les tableaux de bord Grafana
run: for tableau in monitoring/grafana/dashboards/*.json; do jq empty "$tableau"; done
# Action tierce, épinglée sur le commit du tag (règle Sonar githubactions:S7637).
- name: Installe uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: false
# Même image et même healthcheck qu'en prod : `--wait` ne rend la main qu'une fois le S3 prêt.
- name: Démarre Garage
run: docker compose up -d --wait --wait-timeout 120 garage
- name: Fumée S3 sur Garage, SSE-C compris
run: |
set -a; . ./.env; set +a
uvx --no-build --with boto3==1.43.101 pytest==9.1.1 tests/garage -q
- name: Journaux de Garage en cas d'échec
if: failure()
run: docker compose logs --tail=100 garage
- name: Arrête Garage
if: always()
run: docker compose down --volumes
workflows:
name: Analyse des workflows
if: inputs.workflows
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
# Image épinglée par tag, comme les images des fichiers Compose. Elle embarque shellcheck,
# qui analyse aussi les blocs `run:`.
- name: actionlint
run: docker run --rm -v "$PWD:/repo" --workdir /repo rhysd/actionlint:1.7.12 -color
+107 -22
View File
@@ -2,47 +2,41 @@ name: ML
# Piège : la version de Python vient de ml/.python-version, et doit rester en 3.14 (cf. # Piège : la version de Python vient de ml/.python-version, et doit rester en 3.14 (cf.
# .github/workflows/backend.yml, même contrainte). # .github/workflows/backend.yml, même contrainte).
# Pourquoi : aucun déclencheur propre. ci.yml l'appelle aussi quand les migrations ou les modèles
# du backend changent, dont dépend le job `integration` (ADR 0014).
on: on:
push: workflow_call:
paths:
- "ml/**"
- ".github/workflows/ml.yml"
pull_request:
paths:
- "ml/**"
- ".github/workflows/ml.yml"
permissions: permissions:
contents: read contents: read
concurrency:
group: ml-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
verification: verification:
name: Lint, typage et tests name: Lint, typage et tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15
defaults: defaults:
run: run:
working-directory: ml working-directory: ml
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
# Action tierce, épinglée sur le commit du tag (règle Sonar githubactions:S7637).
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: ml/uv.lock cache-dependency-glob: ml/uv.lock
prune-cache: false
- name: Installe l'interpréteur déclaré par .python-version - name: Installe l'interpréteur déclaré par .python-version
run: uv python install run: uv python install
- name: Synchronise les dépendances sans dévier du verrou - name: Synchronise les dépendances sur le verrou
run: uv sync --all-groups --frozen run: uv sync --all-groups --locked
- name: Vérifie le formatage - name: Vérifie le formatage
run: uv run ruff format --check . run: uv run ruff format --check .
@@ -53,26 +47,117 @@ 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`, écarté par défaut et
# synthetiques ou un magasin SQLite local jetable (cf. ml/tests/test_train.py). # joué par le job `integration` ci-dessous.
- name: Tests - name: Tests et couverture
run: uv run pytest run: uv run pytest --cov-report=xml
- name: Verse la couverture pour Sonar
uses: actions/upload-artifact@v7
with:
name: ml-coverage
path: ml/coverage.xml
if-no-files-found: error
# Piège : le schéma de la base ML est celui du backend (apps/backend/alembic, propriétaire du
# schéma). Le reconstruire ici à la main rendrait ce job vert sur une base qui n'est pas la nôtre.
integration:
name: ML - DB et chaîne ML - DB - API
runs-on: ubuntu-latest
timeout-minutes: 20
services:
db:
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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
cache-dependency-glob: |
ml/uv.lock
apps/backend/uv.lock
prune-cache: false
- name: Installe l'interpréteur déclaré par .python-version
working-directory: ml
run: uv python install
- name: Synchronise le pipeline ML sur le verrou
working-directory: ml
run: uv sync --all-groups --locked
# Le backend est installé ici parce qu'il porte les migrations, seule source du schéma, et
# le test de chaîne, qui interroge l'API.
- name: Synchronise le backend sur le verrou
working-directory: apps/backend
run: uv sync --all-groups --locked
# db/init/110-test-database.sql n'est pas monté ici, et sans l'extension la première
# révision Alembic refuse de s'appliquer.
- 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
# 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
timeout-minutes: 10
defaults: defaults:
run: run:
working-directory: ml working-directory: ml
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc # Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit. # aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: false
- name: Analyse le code livré (bloquant à partir de MEDIUM) - name: Analyse le code livré (bloquant à partir de MEDIUM)
run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium
-169
View File
@@ -1,169 +0,0 @@
name: SonarQube
on:
push:
paths:
- "apps/frontend/**"
- "apps/backend/**"
- "ml/**"
- "etl/airflow/**"
- ".github/workflows/sonarqube.yml"
pull_request:
paths:
- "apps/frontend/**"
- "apps/backend/**"
- "ml/**"
- "etl/airflow/**"
- ".github/workflows/sonarqube.yml"
# Build l'ensemble du projet, puis lance les tests
# Génère les rapports de couverture, puis lance l'analyse SonarQube
jobs:
build-front:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci
working-directory: apps/frontend
- run: npm run build
working-directory: apps/frontend
test-front:
needs: build-front
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- name : Installation des dépendances (Front)
run: npm ci
working-directory: apps/frontend
- name : Lancement des tests et génénration du rapport de couverture (Front)
run: npm test --watch=false --code-coverage --coverageReporters=lcov
working-directory: apps/frontend
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info
build-back:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Installe uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
working-directory: apps/backend
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --all-groups --frozen
working-directory: apps/backend
- name: Vérifie le formatage
run: uv run ruff format --check .
working-directory: apps/backend
- name: Analyse statique
run: uv run ruff check --output-format=github .
working-directory: apps/backend
- name: Typage
run: uv run mypy app
working-directory: apps/backend
test-back:
needs: build-back
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Installe uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
- name : Lancement des tests et génénration du rapport de couverture (Back)
run: uv run pytest --cov-fail-under=85 --cov-report=xml
working-directory: apps/backend
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: backend-coverage
path: apps/backend/coverage.xml
test-ml:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Installe uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: ml/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
working-directory: ml
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --all-groups --frozen
working-directory: ml
- name: Lancement des tests et génération du rapport de couverture (ML)
run: uv run pytest --cov-report=xml
working-directory: ml
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: ml-coverage
path: ml/coverage.xml
sonarqube:
needs: [build-front, build-back, test-front, test-back, test-ml]
name: SonarQube
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Téléchargement du rapport de couverture (Front)
uses: actions/download-artifact@v4
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend
- name: Téléchargement du rapport de couverture (Back)
uses: actions/download-artifact@v4
with:
name: backend-coverage
path: apps/backend
- name: Téléchargement du rapport de couverture (ML)
uses: actions/download-artifact@v4
with:
name: ml-coverage
path: ml
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v8
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+9 -4
View File
@@ -22,6 +22,12 @@ apps/frontend/.angular/
npm-debug.log* npm-debug.log*
yarn-error.log* yarn-error.log*
# Tests de bout en bout et de charge : rapports générés et identifiants des comptes de test
playwright-report/
blob-report/
tests/e2e/.comptes.json
tests/load/results/
# Terraform # Terraform
.terraform/ .terraform/
# .terraform.lock.hcl est versionne (pas ignore) pour figer les versions de provider entre contributeurs/CI # .terraform.lock.hcl est versionne (pas ignore) pour figer les versions de provider entre contributeurs/CI
@@ -40,7 +46,6 @@ kubeconfig
# Airflow # Airflow
etl/airflow/logs/ etl/airflow/logs/
airflow.db airflow.db
airflow-webserver.pid
standalone_admin_password.txt standalone_admin_password.txt
# Environnement et secrets # Environnement et secrets
@@ -55,8 +60,6 @@ secrets/
data/raw/* data/raw/*
!data/raw/.gitkeep !data/raw/.gitkeep
*.sqlite3 *.sqlite3
monitoring/grafana/data/
monitoring/prometheus/data/
# ML : jeu de donnees, modeles entraines et suivi MLflow local, tous generes/volumineux # ML : jeu de donnees, modeles entraines et suivi MLflow local, tous generes/volumineux
ml/data/ ml/data/
@@ -64,13 +67,15 @@ ml/models/*
!ml/models/.gitkeep !ml/models/.gitkeep
ml/mlruns/ ml/mlruns/
ml/mlartifacts/ ml/mlartifacts/
ml/mlflow.db ml/mlflow.db*
ml/.env
# Airflow : base sqlite locale generee par les tests d'integrite des DAGs (etl/airflow/tests) # Airflow : base sqlite locale generee par les tests d'integrite des DAGs (etl/airflow/tests)
etl/airflow/tests/.airflow_home/ etl/airflow/tests/.airflow_home/
# TLS : certificats du reverse proxy, générés par script ou par certbot # TLS : certificats du reverse proxy, générés par script ou par certbot
infra/proxy/tls/*.pem infra/proxy/tls/*.pem
infra/proxy/acme/
# IDE et OS # IDE et OS
.idea/ .idea/
+159 -12
View File
@@ -2,6 +2,7 @@ BACKEND := apps/backend
FRONTEND := apps/frontend FRONTEND := apps/frontend
ML := ml ML := ml
AIRFLOW := etl/airflow AIRFLOW := etl/airflow
E2E := tests/e2e
COMPOSE_PROD := docker compose -f docker-compose.yml -f docker-compose.prod.yml COMPOSE_PROD := docker compose -f docker-compose.yml -f docker-compose.prod.yml
# Piège : sans `export`, une valeur passée en ligne de commande n'atteindrait pas docker compose. # Piège : sans `export`, une valeur passée en ligne de commande n'atteindrait pas docker compose.
@@ -20,11 +21,53 @@ 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)
# Piege : ni make ni ces cibles ne lisent `.env` pour COMPOSE_PROFILES, que docker compose y lit
# seul. `stack-up` le relit ici pour savoir s'il doit poser le role `supervision` apres migration.
SUPERVISION := $(findstring monitoring,$(COMPOSE_PROFILES) $(call env-val,COMPOSE_PROFILES))
SERVICES_SUPERVISION := prometheus alertmanager grafana postgres-exporter node-exporter cadvisor
GRAFANA_PORT := $(or $(strip $(call env-val,GRAFANA_PORT)),3001)
PROMETHEUS_PORT := $(or $(strip $(call env-val,PROMETHEUS_PORT)),9090)
supervision-garde = for cle in APP_METRICS_TOKEN GRAFANA_ADMIN_PASSWORD SUPERVISION_DB_PASSWORD GARAGE_METRICS_TOKEN; do \
sed -n "s/^$$cle=//p" .env 2>/dev/null | tail -1 | grep -q . \
|| { echo "$$cle manquant dans .env, requis par la supervision (cf. .env.example)"; exit 1; }; \
done
MONITORING := docker compose --profile monitoring
# Piege : l'image Garage n'a pas de shell, elle ne peut pas porter sa garde comme grafana ou
# airflow-init. Un secret vide ou laisse a change_me la ferait redemarrer en boucle (ADR 0019).
CLES_GARAGE := GARAGE_RPC_SECRET GARAGE_ADMIN_TOKEN GARAGE_METRICS_TOKEN GARAGE_ACCESS_KEY GARAGE_SECRET_KEY GARAGE_SSE_KEY
garage-garde = for cle in $(CLES_GARAGE); do \
sed -n "s/^$$cle=//p" .env 2>/dev/null | tail -1 | grep -qv '^change_me$$' \
|| { echo "$$cle manquant ou laisse a change_me dans .env, requis par Garage (cf. .env.example)"; exit 1; }; \
done
PROMTOOL := $(MONITORING) run --rm --no-deps --entrypoint promtool prometheus
# Piege : `e2e-prepare` ajoute trois sites `demo-*` et des comptes `test-*` a la base visee. Elle
# vise la base de `make dev` ; ne jamais la lancer contre la recette ou la prod.
E2E_COMPTES ?= $(CURDIR)/$(E2E)/.comptes.json
E2E_API ?= http://localhost:$(or $(strip $(call env-val,BACKEND_PORT)),8000)
# Piege : `run` ne demarre que k6, la stack doit deja tourner. `--user` fait ecrire les rapports
# de tests/load/results avec l'uid du poste, pas celui de l'image (12345), qui n'y a pas acces.
k6-run = mkdir -p tests/load/results && $(COMPOSE_PROD) --profile load run --rm \
--user "$$(id -u):$$(id -g)" -e K6_WEB_DASHBOARD=true \
-e K6_WEB_DASHBOARD_EXPORT=/results/$(1)-$$(date +%Y%m%dT%H%M%S).html \
k6 run /scripts/$(1).js
# Le jeu historique s'arrete au 31/12/2024 : score et detection ancres a l'horloge reelle ne # Le jeu historique s'arrete au 31/12/2024 : score et detection ancres a l'horloge reelle ne
# verraient qu'un parc muet depuis des mois. Cf. `--now` de enervision_ml.score. # verraient qu'un parc muet depuis des mois. Cf. `--now` de enervision_ml.score.
DEMO_NOW ?= 2024-12-31T00:00:00Z DEMO_NOW ?= 2024-12-31T00:00:00Z
@@ -32,15 +75,18 @@ 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 tls-dns01 front-up stack-up stack-down stack-logs \
e2e-install e2e-prepare e2e load-smoke load-test load-stress load-limits \
db-ensure-supervision monitoring-up monitoring-down monitoring-logs monitoring-check
help: ## Liste les cibles disponibles help: ## Liste les cibles disponibles
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
install: install-backend install-frontend install-ml install-airflow ## Installe les dépendances backend, frontend, ML et Airflow install: install-backend install-frontend install-ml install-airflow ## Installe les dépendances backend, frontend, ML et Airflow
@@ -63,11 +109,12 @@ dev: services-up migrate demo-data ## Lance toute la stack : base, Mailpit, Airf
$(MAKE) --no-print-directory dev-frontend & \ $(MAKE) --no-print-directory dev-frontend & \
wait wait
services-up: ## Démarre les services conteneurisés dont `make dev` dépend (base, Mailpit, Airflow) services-up: ## Démarre les services conteneurisés dont `make dev` dépend (base, Mailpit, Garage, Airflow)
docker compose up -d db mailpit @$(garage-garde)
docker compose up -d db mailpit garage
@$(MAKE) --no-print-directory db-wait @$(MAKE) --no-print-directory db-wait
@$(MAKE) --no-print-directory db-ensure-airflow @$(MAKE) --no-print-directory db-ensure-airflow
docker compose up -d airflow-init airflow-webserver airflow-scheduler docker compose up -d airflow-init airflow-apiserver airflow-scheduler airflow-dag-processor
dev-backend: ## Lance l'API seule en rechargement à chaud dev-backend: ## Lance l'API seule en rechargement à chaud
@echo "backend -> http://localhost:8000 (docs sur /docs)" @echo "backend -> http://localhost:8000 (docs sur /docs)"
@@ -112,12 +159,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),)
@@ -132,12 +197,12 @@ airflow-test: ## Verifie que les DAGs s'importent sans erreur et ont la structur
airflow-check: airflow-lint airflow-test ## Chaîne de vérification complète des DAGs Airflow airflow-check: airflow-lint airflow-test ## Chaîne de vérification complète des DAGs Airflow
airflow-up: db-ensure-airflow ## Démarre Airflow (webserver + scheduler, LocalExecutor). db-up requis avant. airflow-up: db-ensure-airflow ## Démarre Airflow (api-server + scheduler + dag-processor, LocalExecutor). db-up requis avant.
docker compose up -d airflow-init airflow-webserver airflow-scheduler docker compose up -d airflow-init airflow-apiserver airflow-scheduler airflow-dag-processor
@echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}" @echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}"
airflow-down: ## Arrête le webserver et le scheduler Airflow airflow-down: ## Arrête l'api-server, le scheduler et le dag-processor Airflow
docker compose stop airflow-webserver airflow-scheduler docker compose stop airflow-apiserver airflow-scheduler airflow-dag-processor
airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâches, LocalExecutor) airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâches, LocalExecutor)
docker compose logs -f airflow-scheduler docker compose logs -f airflow-scheduler
@@ -155,8 +220,11 @@ stack-up: ## Démarre la stack derrière le reverse proxy, puis migre la base. P
|| { echo "Aucun certificat dans infra/proxy/tls. Lancer d'abord make tls-selfsigned"; exit 1; } || { echo "Aucun certificat dans infra/proxy/tls. Lancer d'abord make tls-selfsigned"; exit 1; }
@openssl x509 -in infra/proxy/tls/fullchain.pem -noout -checkhost "$(PUBLIC_HOST)" >/dev/null \ @openssl x509 -in infra/proxy/tls/fullchain.pem -noout -checkhost "$(PUBLIC_HOST)" >/dev/null \
|| { echo "Le certificat ne couvre pas $(PUBLIC_HOST). Relancer make tls-selfsigned PUBLIC_HOST=$(PUBLIC_HOST) FORCE=1"; exit 1; } || { echo "Le certificat ne couvre pas $(PUBLIC_HOST). Relancer make tls-selfsigned PUBLIC_HOST=$(PUBLIC_HOST) FORCE=1"; exit 1; }
@$(if $(SUPERVISION),$(supervision-garde),true)
@$(garage-garde)
$(COMPOSE_PROD) up -d --build $(COMPOSE_PROD) up -d --build
$(COMPOSE_PROD) exec -T backend alembic upgrade head $(COMPOSE_PROD) exec -T backend alembic upgrade head
@$(if $(SUPERVISION),$(MAKE) --no-print-directory db-ensure-supervision,true)
stack-down: ## Arrête la stack complète en conservant les données stack-down: ## Arrête la stack complète en conservant les données
$(COMPOSE_PROD) stop $(COMPOSE_PROD) stop
@@ -177,6 +245,82 @@ tls-renew: ## Renouvelle les certificats Let's Encrypt et recharge le proxy
$(COMPOSE_PROD) --profile acme run --rm certbot renew --deploy-hook /deploy-hook.sh $(COMPOSE_PROD) --profile acme run --rm certbot renew --deploy-hook /deploy-hook.sh
$(COMPOSE_PROD) exec proxy nginx -s reload $(COMPOSE_PROD) exec proxy nginx -s reload
# Pourquoi : la VM n'a qu'une IP privée, que Let's Encrypt ne joint pas ; le défi DNS-01 passe
# par l'API du fournisseur DNS, dynv6 par défaut (ADR 0018). Le jeton ne passe jamais par `argv`.
ACME_SH := neilpang/acme.sh:3.1.6
DNS01_API ?= dns_dynv6
DNS01_JETON_VAR ?= DYNV6_TOKEN
DNS01_JETON_FICHIER ?= $(abspath $(CURDIR)/../dns.token)
acme-sh = docker run --rm --user "$$(id -u):$$(id -g)" -e $(DNS01_JETON_VAR) -e AUTO_UPGRADE=0 \
-v "$(CURDIR)/infra/proxy/acme:/acme.sh" -v "$(CURDIR)/infra/proxy/tls:/tls" $(ACME_SH)
# acme.sh sort en 2 quand le certificat n'est pas à renouveler, et recopie le jeton dans
# acme/account.conf, d'où le chmod. `--dnssleep` : Let's Encrypt valide depuis plusieurs réseaux.
tls-dns01: ## Certificat Let's Encrypt par DNS-01, renouvelé seulement à échéance. Jeton : ../dns.token
@case "$(PUBLIC_HOST)" in *.local | localhost) echo "PUBLIC_HOST=$(PUBLIC_HOST) n'est pas un nom public"; exit 1 ;; esac
@test -r "$(DNS01_JETON_FICHIER)" || { echo "Jeton DNS illisible : $(DNS01_JETON_FICHIER)"; exit 1; }
@mkdir -p infra/proxy/acme && chmod 700 infra/proxy/acme
@$(DNS01_JETON_VAR)="$$(tr -d '[:space:]' < "$(DNS01_JETON_FICHIER)")"; export $(DNS01_JETON_VAR); \
$(acme-sh) --issue --server letsencrypt --dns $(DNS01_API) --dnssleep 90 -d "$(PUBLIC_HOST)"; \
code=$$?; chmod -R go-rwx infra/proxy/acme; [ $$code -eq 0 ] || [ $$code -eq 2 ] || exit $$code
@$(acme-sh) --install-cert --ecc -d "$(PUBLIC_HOST)" \
--fullchain-file /tls/fullchain.pem --key-file /tls/privkey.pem
@$(COMPOSE_PROD) exec -T proxy nginx -s reload 2>/dev/null \
|| echo "Proxy arrêté : il lira le certificat à son démarrage"
front-up: ## Démarre ou recharge le frontal SNI de la VM, sur les ports 80 et 443 de l'hôte
docker compose -f infra/front/compose.yml up -d
docker compose -f infra/front/compose.yml exec -T front nginx -s reload
e2e-install: ## Installe Playwright et Chromium pour les tests de bout en bout
cd $(E2E) && npm ci && npx playwright install chromium
e2e-prepare: ## Sème le jeu de démonstration et crée les comptes de test sur la base de `make dev`
docker compose exec -T db psql -U $(PG_USER) -d $(PG_DB) -v ON_ERROR_STOP=1 < db/seeds/demo.sql
cd $(BACKEND) && BASE_URL=$(E2E_API) COMPTES_FICHIER=$(E2E_COMPTES) ADMIN_SUPPLEMENTAIRE=1 \
../../scripts/comptes-test.sh
e2e: ## Joue les parcours Playwright. E2E_BASE_URL= optionnel (défaut http://localhost:4200)
cd $(E2E) && E2E_COMPTES=$(E2E_COMPTES) npx playwright test
load-smoke: ## Tir k6 d'une minute. K6_EMAIL= et K6_PASSWORD= d'un lecteur, K6_BASE_URL= optionnel
$(call k6-run,smoke)
load-test: ## Charge nominale k6, 50 utilisateurs pendant 8 minutes. Rapport HTML dans tests/load/results
$(call k6-run,charge)
load-stress: ## Monte le débit jusqu'à la rupture de l'API. Sur la VM, la prod partage la machine
$(call k6-run,stress)
load-limits: ## Vérifie par le proxy que nginx limite le débit d'une même adresse (429)
$(call k6-run,limitation-debit)
# Piege : le mot de passe est lu dans `.env` par le shell et passe a psql sur son entree
# standard. Developpe par make, il apparaitrait en clair dans la ligne de commande (`ps`).
db-ensure-supervision: ## Crée ou réaligne le rôle `supervision`, en lecture seule, de Grafana et de l'exportateur
@mdp="$$(sed -n 's/^SUPERVISION_DB_PASSWORD=//p' .env 2>/dev/null | tail -1)"; \
[ -n "$$mdp" ] || { echo "SUPERVISION_DB_PASSWORD manquant dans .env"; exit 1; }; \
{ printf '\\set mot_de_passe %s\n' "$$mdp"; cat db/roles/supervision.sql; } \
| docker compose exec -T db psql -U $(PG_USER) -d $(PG_DB) -v ON_ERROR_STOP=1 -v base=$(PG_DB) -q
monitoring-up: ## Démarre la supervision sur la stack en cours : Prometheus, Alertmanager, Grafana, exporteurs
@$(supervision-garde)
$(MONITORING) up -d --no-deps $(SERVICES_SUPERVISION)
@$(MAKE) --no-print-directory db-ensure-supervision
@echo "grafana -> http://localhost:$(GRAFANA_PORT) prometheus -> http://localhost:$(PROMETHEUS_PORT)"
monitoring-down: ## Arrête la supervision en conservant ses données
$(MONITORING) stop $(SERVICES_SUPERVISION)
monitoring-logs: ## Suit les journaux de Prometheus, Alertmanager et Grafana
$(MONITORING) logs -f prometheus alertmanager grafana
monitoring-check: ## Valide la configuration de supervision et joue les tests des règles d'alerte, comme la CI
$(PROMTOOL) check config /etc/prometheus/prometheus.yml
$(PROMTOOL) test rules /etc/prometheus/tests/enervision.test.yml
$(MONITORING) run --rm --no-deps --entrypoint amtool alertmanager check-config /etc/alertmanager/alertmanager.yml
@for tableau in monitoring/grafana/dashboards/*.json; do jq empty "$$tableau" || exit 1; done
db-up: ## Démarre la base PostgreSQL TimescaleDB db-up: ## Démarre la base PostgreSQL TimescaleDB
docker compose up -d db docker compose up -d db
@@ -209,6 +353,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}
+61 -28
View File
@@ -9,29 +9,34 @@ series temporelles energetiques, deployee sur une machine on-premise.
|-------|----------------------------------------------------------| |-------|----------------------------------------------------------|
| J1 | Valider la préparation de l'environnement et du repo | | J1 | Valider la préparation de l'environnement et du repo |
| J2 | Valider le périmètre retenu et les choix technologiques | | J2 | Valider le périmètre retenu et les choix technologiques |
| J3 | Valider l'architecture et la gestion de la sécurité | | J3 | Ingestion & backend |
| J4 | Valider la robustesse et assurer les livrables | | J4 | Architecture, sécurité & frontend |
| J5 | Valider la robustesse et assurer les livrables |
| J6 | Amélioration possible |
Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.md](docs/architecture/00-vue-ensemble.md). Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.md](docs/architecture/00-vue-ensemble.md).
## Stack cible ## Stack
| Domaine | Technologie | Emplacement | Etat | | Domaine | Technologie | Emplacement | Etat |
|------------|-------------------------------------|---------------------|---------------| |------------|-------------------------------------|---------------------|---------------|
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise | | Backend | FastAPI, Python 3.14 | `apps/backend` | En place |
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord | | Frontend | Angular 22, Node 26 | `apps/frontend` | En place |
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise | | Base | PostgreSQL 17 + TimescaleDB | `db` | En place |
| ETL | Apache Airflow | `etl/airflow` | Quatre DAGs | | ETL | Apache Airflow | `etl/airflow` | Sept DAGs |
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | | Infra | Terraform (VM ENI ; module k3s) | `infra/terraform` | VM appliquée, k3s écrit non appliqué |
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place | | Reverse proxy | Nginx, TLS, frontal SNI | `infra/proxy`, `infra/front` | En place, certificats Let's Encrypt |
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place | | CI/CD | GitHub Actions | `.github/workflows` | En place |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | En place, profil Compose |
| ML | LightGBM, MLflow | `ml` | Entrainement initialise | | Stockage objet | Garage (S3), un par environnement | `infra/garage` | En place, archives de `reading` |
| Tests e2e et de charge | Playwright, k6 | `tests` | En place |
| ML | LightGBM, MLflow | `ml` | En place |
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend Toutes ces briques tournent sur la machine du groupe, en trois environnements (production,
sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints recette, dev). Le frontend sert le tableau de bord, les vues sites, recommandations et
correspondants restent à écrire côté API. Les autres dossiers portent l'arborescence et un README supervision des capteurs, toutes branchées sur l'API réelle : les fixtures sont coupées
de cadrage, leur contenu fait l'objet d'un ticket dedie. (`useMockFixtures: false`). Le module Terraform k3s reste une cible, écrite et validée, jamais
appliquée.
L'etat detaille de chaque brique et les vues d'architecture sont dans L'etat detaille de chaque brique et les vues d'architecture sont dans
[docs/architecture](docs/architecture/README.md). [docs/architecture](docs/architecture/README.md).
@@ -46,13 +51,16 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
├── db/ ├── db/
│ ├── init/ Bootstrap PostgreSQL + TimescaleDB │ ├── init/ Bootstrap PostgreSQL + TimescaleDB
│ ├── migrations/ Migrations SQL versionnees │ ├── migrations/ Migrations SQL versionnees
│ └── seeds/ Jeux de donnees de reference │ ├── roles/ Roles PostgreSQL hors schema (supervision)
│ └── seeds/ Jeu de demonstration des tests
├── etl/airflow/ ├── etl/airflow/
│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, import historique) │ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, imports, dérive, rétention)
│ ├── plugins/ Operateurs et hooks maison │ ├── plugins/ Operateurs et hooks maison
│ ├── include/ Requetes SQL et ressources des DAGs │ ├── include/ Requetes SQL et ressources des DAGs
│ └── tests/ Tests d'integrite des DAGs │ └── tests/ Tests d'integrite des DAGs
├── infra/ ├── infra/
│ ├── front/ Frontal SNI de la machine : ports 80 et 443, aiguillage par nom
│ ├── garage/ Stockage objet S3 : configuration sans secret
│ ├── proxy/ Reverse proxy Nginx : terminaison TLS et routage │ ├── proxy/ Reverse proxy Nginx : terminaison TLS et routage
│ └── terraform/ │ └── terraform/
│ ├── modules/ Modules reutilisables │ ├── modules/ Modules reutilisables
@@ -62,13 +70,17 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
│ ├── prometheus/ Collecte et regles d'alerte │ ├── prometheus/ Collecte et regles d'alerte
│ ├── grafana/ Provisioning et dashboards │ ├── grafana/ Provisioning et dashboards
│ └── alertmanager/ Routage des alertes │ └── alertmanager/ Routage des alertes
├── docs/ ADR et vues d'architecture ├── tests/
│ ├── e2e/ Parcours Playwright contre la stack
│ ├── garage/ Tests de fumée S3 joués par la CI contre Garage
│ └── load/ Scenarios de charge k6
├── docs/ ADR, vues d'architecture, runbook de pilotage, livrables de rendu
└── scripts/ Outillage local └── scripts/ Outillage local
``` ```
## Demarrage ## Demarrage
Prerequis : uv, Docker, Node 24 LTS (npm fourni). Le poste doit disposer de Python 3.14, que Prerequis : uv, Docker, Node 26 (version de la CI et de l'image frontend, npm fourni). Le poste doit disposer de Python 3.14, que
`uv` installe seul. `uv` installe seul.
```bash ```bash
@@ -92,8 +104,11 @@ et frontend en rechargement a chaud sur le poste.
| Mailpit | <http://localhost:8025> | | Mailpit | <http://localhost:8025> |
Le `.env` doit porter les cles Airflow avant le premier `make dev` : `AIRFLOW_FERNET_KEY`, Le `.env` doit porter les cles Airflow avant le premier `make dev` : `AIRFLOW_FERNET_KEY`,
`AIRFLOW_WEBSERVER_SECRET_KEY`, `AIRFLOW_APP_SECRET_KEY` et `AIRFLOW_ADMIN_PASSWORD`. Sans elles `AIRFLOW_API_SECRET_KEY`, `AIRFLOW_JWT_SECRET`, `AIRFLOW_APP_SECRET_KEY` et
`airflow-init` refuse de demarrer, et `airflow-webserver` comme `airflow-scheduler` avec lui. `AIRFLOW_ADMIN_PASSWORD`. Sans elles `airflow-init` refuse de demarrer, et `airflow-apiserver`,
`airflow-scheduler` et `airflow-dag-processor` avec lui. Il doit aussi porter les six clés
`GARAGE_*` (rpc, jetons, clé S3, clé SSE-C) : `make services-up` refuse sinon de démarrer Garage,
où le DAG `retention` archive les mesures anciennes ([ADR 0019](docs/adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)).
Les cibles d'origine restent disponibles pour ne demarrer qu'une partie : `make db-up`, Les cibles d'origine restent disponibles pour ne demarrer qu'une partie : `make db-up`,
`make airflow-up`, `make dev-backend`, `make dev-frontend`. `make airflow-up`, `make dev-backend`, `make dev-frontend`.
@@ -135,20 +150,38 @@ L'overlay emploie `!override` et `!reset`, donc **Docker Compose 2.24.4 ou plus
```bash ```bash
make tls-selfsigned PUBLIC_HOST=enervision.local # certificat de démonstration make tls-selfsigned PUBLIC_HOST=enervision.local # certificat de démonstration
make stack-up PUBLIC_HOST=enervision.local # nginx en 80/443, rien d'autre n'est publié make stack-up PUBLIC_HOST=enervision.local # nginx en 80/443, le reste sur 127.0.0.1
``` ```
Le navigateur avertit d'un émetteur inconnu : Let's Encrypt reste hors d'atteinte tant qu'aucun Le navigateur avertit d'un émetteur inconnu : sur le poste, le certificat est auto-signé. Sur la
nom de domaine public ne résout vers la machine. Routage, mode ACME et renouvellement dans machine, les certificats viennent de Let's Encrypt par défi DNS-01
([ADR 0018](docs/adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md)). Routage, mode ACME et renouvellement dans
[`infra/proxy/README.md`](infra/proxy/README.md) ; la décision et ses motifs dans [`infra/proxy/README.md`](infra/proxy/README.md) ; la décision et ses motifs dans
[l'ADR 0007](docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md). [l'ADR 0007](docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
Sur la VM ENI, deux environnements cohabitent, recette sur `dev` et production sur `main`, Sur la VM ENI, trois environnements cohabitent, production sur `main`, recette sur `dev`, et
chacun dans son dossier et son projet Compose : `scripts/provision-host.sh` les prépare, le `dev` pour toute autre branche lancée à la main
workflow `deploy.yml` les redéploie à chaque push par un runner auto-hébergé. Ports, noms ([ADR 0017](docs/adr/0017-environnement-dev-a-la-demande.md)), chacun dans son dossier et son
projet Compose, derrière un frontal SNI commun : `scripts/provision-host.sh` les prépare, le
workflow `deploy.yml` les redéploie par un runner auto-hébergé, une fois la CI du commit poussé
verte ([ADR 0014](docs/adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md)). Ports, noms
d'hôte et garde-fous dans [`docs/architecture/10-infra.md`](docs/architecture/10-infra.md) et d'hôte et garde-fous dans [`docs/architecture/10-infra.md`](docs/architecture/10-infra.md) et
[l'ADR 0009](docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md). [l'ADR 0009](docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md).
## Tests de bout en bout, charge et supervision
| Besoin | Commandes | Détail |
|---|---|---|
| Parcours utilisateur (Playwright) | `make e2e-install`, puis `make e2e-prepare e2e` contre `make dev` | [`tests/e2e/README.md`](tests/e2e/README.md) |
| Tir de charge (k6) | `make load-smoke`, `load-test`, `load-stress`, `load-limits` | [`tests/load/README.md`](tests/load/README.md) |
| Supervision | `make monitoring-up`, Grafana sur <http://localhost:3001> | [`monitoring/README.md`](monitoring/README.md) |
La CI joue les parcours, un tir de fumée et le contrôle de la limitation de débit à chaque PR
qui touche l'application, contre la stack de prod derrière le proxy
([ADR 0015](docs/adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md)). La supervision
est active en prod, à la demande ailleurs
([ADR 0016](docs/adr/0016-supervision-en-profil-compose.md)).
## Conventions ## Conventions
- Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court. - Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court.
@@ -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,
+3
View File
@@ -16,6 +16,9 @@ EN_TETES: Final[dict[str, str]] = {
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY", "X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer", "Referrer-Policy": "no-referrer",
# same-origin : aucun client ne charge l'API en no-cors depuis une autre origine
# (proxy.conf.json en dev, reverse proxy nginx ensuite, cf. docs/architecture/20-backend.md).
"Cross-Origin-Resource-Policy": "same-origin",
} }
PREFIXE_AUTHENTIFICATION: Final = "/auth" PREFIXE_AUTHENTIFICATION: Final = "/auth"
+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
)
+24 -1
View File
@@ -1,7 +1,7 @@
from functools import lru_cache from functools import lru_cache
from typing import Literal, Self from typing import Literal, Self
from pydantic import Field, SecretStr, model_validator from pydantic import Field, SecretStr, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
Environment = Literal["local", "dev", "staging", "prod"] Environment = Literal["local", "dev", "staging", "prod"]
@@ -76,6 +76,29 @@ class Settings(BaseSettings):
expose_api_docs: bool | None = None expose_api_docs: bool | None = None
metrics_token: SecretStr | None = None metrics_token: SecretStr | None = None
s3_endpoint_url: str | None = None
s3_region: str = "garage"
s3_access_key: str | None = None
s3_secret_key: SecretStr | None = None
s3_bucket: str | None = None
s3_sse_key: SecretStr | None = None
reading_retention_days: int = Field(default=1095, ge=30)
# Compose passe `APP_METRICS_TOKEN` et les réglages S3 vides quand rien n'est posé : vide vaut
# absent, sinon `/metrics` exigerait un `Bearer` sans valeur et l'archivage un endpoint vide.
@field_validator(
"metrics_token",
"s3_endpoint_url",
"s3_access_key",
"s3_secret_key",
"s3_bucket",
"s3_sse_key",
mode="before",
)
@classmethod
def _jeton_vide_vaut_absent(cls, valeur: object) -> object:
return None if valeur == "" else valeur
@property @property
def allowed_origins(self) -> list[str]: def allowed_origins(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
+140 -29
View File
@@ -1,17 +1,19 @@
# Contrainte : la réponse de l'API Mock est une entrée hostile, pas une source de confiance. # Contrainte : la réponse de l'API Mock est une entrée hostile, pas une source de confiance.
# Voir OWASP API10 dans docs/architecture/owasp-traceabilite.md. Rien de ce qu'elle renvoie # Voir OWASP API10 dans docs/architecture/owasp-traceabilite.md. Rien de ce qu'elle renvoie
# n'atteint la base sans passer par build_site_row() ou build_reading_row() : seuls les champs # n'atteint la base sans passer par build_site_row() ou build_reading_row() : seuls les champs
# attendus sont recopiés, les grandeurs physiques sont bornées par PHYSICAL_BOUNDS et la taille # attendus sont recopiés, les grandeurs physiques sont bornées par PHYSICAL_BOUNDS, la taille des
# des tableaux est plafonnée par MAX_SITES et par --limit. Une valeur hors bornes devient NULL # tableaux est plafonnée par MAX_SITES et par limit_for_window() (dérivé de la fenêtre, jamais
# et laisse sa trace dans null_reasons plutôt que de lever : le mock émet des anomalies par # fourni par l'appelant), et les lectures dont le timestamp déborde de la fenêtre demandée sont
# construction, et raw_data conserve de toute façon la réponse d'origine intacte. # écartées (fetch_readings). Une valeur hors bornes devient NULL et laisse sa trace dans
# null_reasons plutôt que de lever : le mock émet des anomalies par construction, et raw_data
# conserve de toute façon la réponse d'origine intacte.
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
import json import json
from datetime import datetime from datetime import UTC, datetime
from typing import Any from typing import Any
import httpx import httpx
@@ -19,6 +21,7 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
from app.core.config import get_settings from app.core.config import get_settings
from app.etl.historical_import import SOURCE_NAME as SOURCE_CSV
SOURCE_HISTORY = "api_history" SOURCE_HISTORY = "api_history"
@@ -45,15 +48,19 @@ CAPACITY_BOUNDS = (0.0, 100_000.0)
def create_mock_api_client() -> httpx.AsyncClient: def create_mock_api_client() -> httpx.AsyncClient:
settings = get_settings() settings = get_settings()
if settings.mock_api_username is None or settings.mock_api_password is None: username = settings.mock_api_username
password = (
settings.mock_api_password.get_secret_value()
if settings.mock_api_password is not None
else None
)
if not username or not username.strip() or not password or not password.strip():
raise ValueError("Les identifiants de l'API Mock ne sont pas configurés.") raise ValueError("Les identifiants de l'API Mock ne sont pas configurés.")
return httpx.AsyncClient( return httpx.AsyncClient(
base_url=settings.mock_api_base_url.rstrip("/"), base_url=settings.mock_api_base_url.rstrip("/"),
auth=( auth=(username, password),
settings.mock_api_username,
settings.mock_api_password.get_secret_value(),
),
timeout=settings.mock_api_timeout_seconds, timeout=settings.mock_api_timeout_seconds,
) )
@@ -177,6 +184,19 @@ async def upsert_sites(
) )
def _timestamp_in_window(reading: dict[str, Any], start_time: datetime, end_time: datetime) -> bool:
valeur = reading.get("timestamp")
if not isinstance(valeur, str):
return False
try:
instant = parse_datetime(valeur)
except ValueError:
return False
return start_time <= instant < end_time
async def fetch_readings( async def fetch_readings(
client: httpx.AsyncClient, client: httpx.AsyncClient,
site_id: str, site_id: str,
@@ -204,7 +224,22 @@ async def fetch_readings(
if len(payload) > limit: if len(payload) > limit:
raise ValueError(f"La réponse /api/v1/readings dépasse la limite demandée de {limit}.") raise ValueError(f"La réponse /api/v1/readings dépasse la limite demandée de {limit}.")
return payload # Le garde-fou `refuse_if_overlaps_historical_dataset` ne vérifie que la fenêtre demandée :
# une réponse (bug du mock, ou hostile) dont les `timestamp` débordent de
# `[start_time, end_time)` contournerait ce contrôle et écrirait exactement le doublon
# inter-source qu'il doit empêcher. Écarter ces lectures ici rend le contrôle par fenêtre
# suffisant.
dans_la_fenetre = [
lecture
for lecture in payload
if isinstance(lecture, dict) and _timestamp_in_window(lecture, start_time, end_time)
]
if len(dans_la_fenetre) != len(payload):
ecartees = len(payload) - len(dans_la_fenetre)
print(f"{site_id}: {ecartees} lecture(s) hors fenêtre écartée(s).")
return dans_la_fenetre
def build_reading_row( def build_reading_row(
@@ -240,6 +275,36 @@ def build_reading_row(
} }
# `uq_reading_source` autorise deux lignes au même (site_id, timestamp) dès que `source` diffère :
# sans ce garde-fou, importer une fenêtre déjà couverte par le dataset historique (source='csv')
# dupliquerait silencieusement chaque point plutôt que de lever une erreur. Ce garde-fou protège
# l'ingestion ; il ne dit rien de la lecture (`GET /readings` renvoie les deux lignes en cas de
# doublon malgré tout, cf. la section réconciliation de 40-data.md).
OVERLAP_CHECK = text(
"SELECT count(*) FROM reading WHERE source = :source_csv "
"AND timestamp >= :start_time AND timestamp < :end_time"
)
async def refuse_if_overlaps_historical_dataset(
connection: AsyncConnection,
start_time: datetime,
end_time: datetime,
) -> None:
resultat = await connection.execute(
OVERLAP_CHECK,
{"source_csv": SOURCE_CSV, "start_time": start_time, "end_time": end_time},
)
nombre = resultat.scalar_one()
if nombre > 0:
raise ValueError(
f"La fenêtre [{start_time.isoformat()}, {end_time.isoformat()}) recouvre "
f"{nombre} lecture(s) déjà importée(s) du dataset historique (source='{SOURCE_CSV}') : "
"import refusé pour éviter un doublon inter-source."
)
# Le conflit vise l'index unique uq_reading_source plutôt que la table entière : sans cible # Le conflit vise l'index unique uq_reading_source plutôt que la table entière : sans cible
# nommée, DO NOTHING avalerait aussi une violation de clé primaire. # nommée, DO NOTHING avalerait aussi une violation de clé primaire.
READING_INSERT = text( READING_INSERT = text(
@@ -298,13 +363,68 @@ def build_reading_batch(
return [build_reading_row(reading) for reading in readings] return [build_reading_row(reading) for reading in readings]
def limit_for_window(start_time: datetime, end_time: datetime) -> int:
"""Nombre de lectures à demander pour que l'API Mock en rende une par heure, alignée.
L'API ne renvoie pas un flux à un rythme naturel : elle répartit exactement `limit` lectures,
espacées uniformément, sur toute la fenêtre `[start_time, end_time)` demandée, la première
au tout début de la fenêtre (vérifié empiriquement). Deux façons d'obtenir une lecture
alignée sur l'heure :
- une fenêtre d'exactement N heures (`start_time` sur l'heure) donne, avec `limit=N`, N
lectures espacées d'1h pile, la première à `start_time` : c'est le chemin du backfill
manuel (plusieurs jours d'historique en un seul appel).
- une fenêtre plus courte qu'une heure, ou qui n'est pas un multiple entier d'heure, ne peut
espacer plusieurs lectures d'1h pile (l'espacement de l'API vaut toujours
`durée / limit`) : seule `limit=1` reste alignée, la lecture unique atterrissant à
`start_time`. C'est le chemin du DAG horaire, dont la fenêtre part de l'heure pile qui
précède son déclenchement jusqu'à l'instant du déclenchement lui-même (`:45`), donc plus
courte qu'une heure.
Dans les deux cas, `start_time` doit tomber pile sur l'heure : c'est elle qui ancre
l'alignement, jamais `end_time`. Un `limit` plus grand que celui rendu ici fabriquerait des
lectures infra-horaires, incompatibles avec les lags positionnels de `build_features`.
"""
if start_time.minute or start_time.second or start_time.microsecond:
raise ValueError(
f"La fenêtre doit démarrer pile sur l'heure : {start_time.isoformat()} ne l'est pas."
)
duree = end_time - start_time
heures, reste = divmod(duree.total_seconds(), 3600)
# Fenêtre plus courte qu'une heure, ou pas un multiple entier : aucun `limit` supérieur à 1
# n'espacerait ses lectures d'1h pile (l'espacement vaut toujours durée / limit). Seule la
# lecture unique, ancrée sur `start_time`, reste alignée.
limit = int(heures) if reste == 0 and heures >= 1 else 1
if limit > MAX_LIMIT:
raise ValueError(
f"La fenêtre demandée couvre {limit}h, au-delà du plafond de {MAX_LIMIT} "
"lectures accepté par l'API Mock."
)
return limit
async def import_mock_api_history( async def import_mock_api_history(
start_time: datetime, start_time: datetime,
end_time: datetime, end_time: datetime,
limit: int,
dry_run: bool, dry_run: bool,
) -> None: ) -> None:
settings = get_settings() settings = get_settings()
limit = limit_for_window(start_time, end_time)
engine = create_async_engine(
str(settings.database_url),
pool_pre_ping=True,
)
try:
# Garde-fou d'abord, y compris en dry-run : il est en lecture seule, et annoncer un
# succès pour une fenêtre que l'import réel refusera serait trompeur.
async with engine.connect() as connection:
await refuse_if_overlaps_historical_dataset(connection, start_time, end_time)
async with create_mock_api_client() as client: async with create_mock_api_client() as client:
sites = await fetch_sites(client) sites = await fetch_sites(client)
@@ -334,12 +454,6 @@ async def import_mock_api_history(
print("Dry-run terminé : aucune donnée écrite.") print("Dry-run terminé : aucune donnée écrite.")
return return
engine = create_async_engine(
str(settings.database_url),
pool_pre_ping=True,
)
try:
async with engine.begin() as connection: async with engine.begin() as connection:
await upsert_sites( await upsert_sites(
connection, connection,
@@ -361,7 +475,14 @@ async def import_mock_api_history(
def parse_datetime(value: str) -> datetime: def parse_datetime(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00")) # Sans fuseau, l'API le traite comme reçu, telle quelle, mais l'encodeur `timestamptz`
# d'asyncpg lirait un datetime naif dans le fuseau *local du processus* (correct dans le
# conteneur Airflow en UTC, décalé de 1-2h pour un import manuel lancé depuis un poste en
# Europe/Paris). Poser `tzinfo=UTC` explicitement, même pattern que `_vers_utc()` dans
# `app/services/reading.py`, garantit que la borne envoyée à l'API et celle comparée en SQL
# (refuse_if_overlaps_historical_dataset) désignent le même instant.
instant = datetime.fromisoformat(value.replace("Z", "+00:00"))
return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC)
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
@@ -379,12 +500,6 @@ def parse_args() -> argparse.Namespace:
type=parse_datetime, type=parse_datetime,
) )
parser.add_argument(
"--limit",
type=int,
default=MAX_LIMIT,
)
parser.add_argument( parser.add_argument(
"--dry-run", "--dry-run",
action="store_true", action="store_true",
@@ -396,9 +511,6 @@ def parse_args() -> argparse.Namespace:
def main() -> None: def main() -> None:
args = parse_args() args = parse_args()
if args.limit < 1 or args.limit > MAX_LIMIT:
raise ValueError(f"--limit doit être compris entre 1 et {MAX_LIMIT}.")
if args.start_time >= args.end_time: if args.start_time >= args.end_time:
raise ValueError("--start-time doit être antérieur à --end-time.") raise ValueError("--start-time doit être antérieur à --end-time.")
@@ -406,7 +518,6 @@ def main() -> None:
import_mock_api_history( import_mock_api_history(
start_time=args.start_time, start_time=args.start_time,
end_time=args.end_time, end_time=args.end_time,
limit=args.limit,
dry_run=args.dry_run, dry_run=args.dry_run,
) )
) )
+349
View File
@@ -0,0 +1,349 @@
# Pourquoi : la suppression n'est pas confiée à add_retention_policy, qui ignorerait l'export.
# archive_reading_chunks() exporte chaque chunk vers Garage, le relit, puis le supprime seul.
# Piège : drop_chunks pose un verrou exclusif sur reading, site et dataset jusqu'au COMMIT. La
# suppression tient donc dans une transaction dédiée et courte, séparée de la lecture du chunk.
from __future__ import annotations
import argparse
import asyncio
import base64
import hashlib
import io
import json
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
import anyio.to_thread
import boto3
import pandas as pd
from botocore.exceptions import ClientError
from pydantic import SecretStr
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
from app.core.config import Settings, get_settings
if TYPE_CHECKING:
from types_boto3_s3.client import S3Client
SSE_KEY_LENGTH = 32
FORMAT_BORNE = "%Y%m%dT%H%M%SZ"
ELIGIBLE_CHUNKS = text(
"SELECT chunk_schema, chunk_name, range_start, range_end "
"FROM timescaledb_information.chunks "
"WHERE hypertable_name = 'reading' AND range_end <= :older_than "
"ORDER BY range_start"
)
# Lecture via l'hypertable, jamais la table interne : l'exclusion de partition vise le seul chunk.
CHUNK_ROWS = text(
"SELECT * FROM reading WHERE timestamp >= :start AND timestamp < :end "
"ORDER BY timestamp, reading_id"
)
# Les deux bornes sont inclusives pour drop_chunks : celles du chunk le désignent, et lui seul.
DROP_CHUNK = text(
"SELECT drop_chunks('reading', "
"older_than => CAST(:end AS timestamptz), newer_than => CAST(:start AS timestamptz))"
)
@dataclass(frozen=True)
class Chunk:
schema: str
name: str
range_start: datetime
range_end: datetime
@property
def qualified_name(self) -> str:
return f"{self.schema}.{self.name}"
@dataclass
class Rapport:
chunks_vus: int = 0
exportes: int = 0
deja_presents: int = 0
supprimes: int = 0
lignes: int = 0
def object_key(chunk: Chunk) -> str:
start = chunk.range_start.astimezone(UTC)
end = chunk.range_end.astimezone(UTC)
return (
f"reading/{start.year}/reading_{start.strftime(FORMAT_BORNE)}_"
f"{end.strftime(FORMAT_BORNE)}.csv.gz"
)
async def eligible_chunks(conn: AsyncConnection, older_than: datetime) -> list[Chunk]:
result = await conn.execute(ELIGIBLE_CHUNKS, {"older_than": older_than})
return [
Chunk(
schema=row["chunk_schema"],
name=row["chunk_name"],
range_start=row["range_start"],
range_end=row["range_end"],
)
for row in result.mappings().all()
]
async def read_chunk_rows(conn: AsyncConnection, chunk: Chunk) -> list[dict[str, Any]]:
result = await conn.execute(CHUNK_ROWS, {"start": chunk.range_start, "end": chunk.range_end})
return [dict(row) for row in result.mappings().all()]
def _csv_cell(value: object) -> object:
if isinstance(value, dict | list):
return json.dumps(value, ensure_ascii=False, sort_keys=True)
return value
def serialize_csv_gzip(rows: list[dict[str, Any]]) -> bytes:
if not rows:
raise ValueError("Aucune ligne à sérialiser : un CSV sans colonne ne se relit pas.")
frame = pd.DataFrame([{name: _csv_cell(value) for name, value in row.items()} for row in rows])
buffer = io.BytesIO()
frame.to_csv(buffer, mode="wb", index=False, compression={"method": "gzip", "mtime": 0})
return buffer.getvalue()
def sha256_of(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _is_missing_object(erreur: ClientError) -> bool:
error = erreur.response.get("Error")
metadata = erreur.response.get("ResponseMetadata")
code = error.get("Code") if error is not None else None
status = metadata.get("HTTPStatusCode") if metadata is not None else None
return code == "NoSuchKey" or status == 404
class ArchiveStore:
def __init__(self, client: S3Client, bucket: str, sse_key: bytes | None) -> None:
self._client = client
self._bucket = bucket
self._sse_key = sse_key
# boto3 encode lui-même la clé en base64 et calcule son MD5 : la fournir brute, sans MD5.
def _sse_headers(self) -> dict[str, Any]:
if self._sse_key is None:
return {}
return {"SSECustomerAlgorithm": "AES256", "SSECustomerKey": self._sse_key}
def put(self, key: str, body: bytes, metadata: dict[str, str]) -> None:
self._client.put_object(
Bucket=self._bucket,
Key=key,
Body=body,
ContentType="text/csv",
ContentEncoding="gzip",
Metadata=metadata,
**self._sse_headers(),
)
def fetch_sha256(self, key: str) -> str | None:
try:
response = self._client.get_object(Bucket=self._bucket, Key=key, **self._sse_headers())
except ClientError as erreur:
if _is_missing_object(erreur):
return None
raise
return sha256_of(response["Body"].read())
def decode_sse_key(encoded: SecretStr | None) -> bytes | None:
if encoded is None:
return None
key = base64.b64decode(encoded.get_secret_value(), validate=True)
if len(key) != SSE_KEY_LENGTH:
raise ValueError(
f"APP_S3_SSE_KEY doit encoder exactement {SSE_KEY_LENGTH} octets en base64, "
f"pas {len(key)}."
)
return key
def build_archive_store(settings: Settings) -> ArchiveStore:
endpoint = settings.s3_endpoint_url
access_key = settings.s3_access_key
secret_key = settings.s3_secret_key
bucket = settings.s3_bucket
if endpoint is None or access_key is None or secret_key is None or bucket is None:
raise ValueError(
"L'archivage vers Garage exige APP_S3_ENDPOINT_URL, APP_S3_ACCESS_KEY, "
"APP_S3_SECRET_KEY et APP_S3_BUCKET."
)
client = boto3.client(
"s3",
endpoint_url=endpoint,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key.get_secret_value(),
region_name=settings.s3_region,
)
return ArchiveStore(client, bucket=bucket, sse_key=decode_sse_key(settings.s3_sse_key))
async def drop_chunk(conn: AsyncConnection, chunk: Chunk) -> None:
result = await conn.execute(DROP_CHUNK, {"start": chunk.range_start, "end": chunk.range_end})
supprimes = list(result.scalars().all())
if supprimes != [chunk.qualified_name]:
raise RuntimeError(
f"drop_chunks devait supprimer exactement {chunk.qualified_name}, "
f"il a rendu {supprimes}."
)
async def _export(
store: ArchiveStore,
key: str,
rows: list[dict[str, Any]],
*,
dry_run: bool,
rapport: Rapport,
) -> str:
body = serialize_csv_gzip(rows)
sha = sha256_of(body)
if await anyio.to_thread.run_sync(store.fetch_sha256, key) == sha:
rapport.deja_presents += 1
return f"{len(body)} octets déjà présents"
if dry_run:
return f"{len(body)} octets à exporter"
metadata = {"sha256": sha, "rows": str(len(rows))}
await anyio.to_thread.run_sync(store.put, key, body, metadata)
relu = await anyio.to_thread.run_sync(store.fetch_sha256, key)
if relu != sha:
raise RuntimeError(
f"Relecture de {key} : sha256 {relu} au lieu de {sha}, le chunk est conservé."
)
rapport.exportes += 1
return f"{len(body)} octets exportés et relus"
async def _archive_chunk(
engine: AsyncEngine,
store: ArchiveStore,
chunk: Chunk,
*,
dry_run: bool,
rapport: Rapport,
) -> None:
async with engine.connect() as conn:
rows = await read_chunk_rows(conn, chunk)
key = object_key(chunk)
rapport.lignes += len(rows)
if rows:
action = await _export(store, key, rows, dry_run=dry_run, rapport=rapport)
else:
action = "vide, rien à exporter"
if dry_run:
print(f"{key} : {len(rows)} ligne(s), {action}, suppression simulée.")
return
async with engine.begin() as conn:
await drop_chunk(conn, chunk)
rapport.supprimes += 1
print(f"{key} : {len(rows)} ligne(s), {action}, chunk {chunk.qualified_name} supprimé.")
async def archive_reading_chunks(
engine: AsyncEngine,
store: ArchiveStore,
*,
older_than: datetime,
dry_run: bool,
) -> Rapport:
rapport = Rapport()
async with engine.connect() as conn:
chunks = await eligible_chunks(conn, older_than)
rapport.chunks_vus = len(chunks)
print(
f"{len(chunks)} chunk(s) de reading entièrement antérieur(s) au {older_than.isoformat()}."
)
for chunk in chunks:
await _archive_chunk(engine, store, chunk, dry_run=dry_run, rapport=rapport)
bilan = "Dry-run terminé : rien n'a été écrit ni supprimé." if dry_run else "Archivage terminé."
print(
f"{bilan} Chunks vus : {rapport.chunks_vus}, exportés : {rapport.exportes}, "
f"déjà présents : {rapport.deja_presents}, supprimés : {rapport.supprimes}, "
f"lignes : {rapport.lignes}."
)
return rapport
async def _run(
settings: Settings,
store: ArchiveStore,
*,
older_than: datetime,
dry_run: bool,
) -> Rapport:
engine = create_async_engine(str(settings.database_url), pool_pre_ping=True)
try:
return await archive_reading_chunks(engine, store, older_than=older_than, dry_run=dry_run)
finally:
await engine.dispose()
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="python -m app.etl.reading_retention",
description=(
"Exporte vers Garage puis supprime les chunks de reading entièrement plus vieux "
"que la borne de rétention."
),
)
parser.add_argument(
"--older-than-days",
type=int,
default=None,
help="Borne en jours, par défaut APP_READING_RETENTION_DAYS.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Liste et mesure les chunks éligibles sans rien écrire ni supprimer.",
)
return parser
def main(argv: list[str] | None = None) -> None:
args = build_parser().parse_args(argv)
settings = get_settings()
jours = (
settings.reading_retention_days if args.older_than_days is None else args.older_than_days
)
older_than = datetime.now(UTC) - timedelta(days=jours)
store = build_archive_store(settings)
asyncio.run(_run(settings, store, older_than=older_than, dry_run=args.dry_run))
if __name__ == "__main__":
main()
+20 -2
View File
@@ -6,7 +6,8 @@ from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from prometheus_fastapi_instrumentator import Instrumentator from prometheus_client import CollectorRegistry, GCCollector, PlatformCollector, ProcessCollector
from prometheus_fastapi_instrumentator import Instrumentator, metrics
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse
@@ -37,6 +38,16 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
await get_engine().dispose() await get_engine().dispose()
# Pourquoi : le registre global n'accepte chaque métrique qu'une fois. Toute application créée
# après la première, dans les tests notamment, n'aurait rien mesuré.
def _registre_de_metriques() -> CollectorRegistry:
registre = CollectorRegistry()
ProcessCollector(registry=registre)
PlatformCollector(registry=registre)
GCCollector(registry=registre)
return registre
def create_app(settings: Settings | None = None) -> FastAPI: def create_app(settings: Settings | None = None) -> FastAPI:
resolved = settings or get_settings() resolved = settings or get_settings()
configure_logging(resolved) configure_logging(resolved)
@@ -102,7 +113,14 @@ def create_app(settings: Settings | None = None) -> FastAPI:
register_error_handlers(application) register_error_handlers(application)
Instrumentator().instrument(application).expose( # Les sondes de santé tombent toutes les 30 s : comptées, elles fausseraient latences et débit.
# Seaux fins autour du seuil de charge (p95 < 500 ms, ADR 0015), route par route.
registre = _registre_de_metriques()
Instrumentator(
excluded_handlers=["/metrics", f"{resolved.api_prefix}/health/.*"], registry=registre
).add(
metrics.default(latency_lowr_buckets=(0.05, 0.1, 0.25, 0.5, 1, 2.5), registry=registre)
).instrument(application).expose(
application, application,
endpoint="/metrics", endpoint="/metrics",
include_in_schema=False, include_in_schema=False,
+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é."
} }
] ]
} }
+7 -2
View File
@@ -19,6 +19,7 @@ dependencies = [
"aiosmtplib>=5.1.3", "aiosmtplib>=5.1.3",
"httpx>=0.28.1", "httpx>=0.28.1",
"pandas>=3.0.5", "pandas>=3.0.5",
"boto3>=1.43.101",
] ]
[dependency-groups] [dependency-groups]
@@ -29,6 +30,7 @@ dev = [
"pytest-asyncio>=1.4.0", "pytest-asyncio>=1.4.0",
"pytest-cov>=7.1.0", "pytest-cov>=7.1.0",
"pandas-stubs>=3.0.5.260914", "pandas-stubs>=3.0.5.260914",
"types-boto3[s3]>=1.43.101",
] ]
[build-system] [build-system]
@@ -87,8 +89,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)
+16 -1
View File
@@ -23,8 +23,9 @@ async def interroge(
("x-content-type-options", "nosniff"), ("x-content-type-options", "nosniff"),
("x-frame-options", "DENY"), ("x-frame-options", "DENY"),
("referrer-policy", "no-referrer"), ("referrer-policy", "no-referrer"),
("cross-origin-resource-policy", "same-origin"),
], ],
ids=["nosniff", "anti_iframe", "referrer"], ids=["nosniff", "anti_iframe", "referrer", "corp"],
) )
async def test_every_response_carries_the_security_headers( async def test_every_response_carries_the_security_headers(
client: AsyncClient, entete: str, valeur: str client: AsyncClient, entete: str, valeur: str
@@ -71,6 +72,20 @@ async def test_metrics_stay_open_when_no_token_is_configured(client: AsyncClient
assert response.status_code == 200 assert response.status_code == 200
async def test_an_empty_metrics_token_means_no_token() -> None:
assert (await interroge({"metrics_token": ""}, "/metrics")).status_code == 200
async def test_metrics_ignore_health_probes_but_count_business_routes(client: AsyncClient) -> None:
await client.get("/api/v1/health/live")
await client.get("/api/v1/sites")
exposition = (await client.get("/metrics")).text
assert 'handler="/api/v1/health/live"' not in exposition
assert 'handler="/api/v1/sites"' in exposition
async def test_metrics_demand_the_token_once_one_is_configured() -> None: async def test_metrics_demand_the_token_once_one_is_configured() -> None:
surcharges = {"metrics_token": "un-jeton-de-supervision-assez-long"} surcharges = {"metrics_token": "un-jeton-de-supervision-assez-long"}
+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)
+251 -68
View File
@@ -1,6 +1,6 @@
import json import json
import sys import sys
from datetime import datetime from datetime import UTC, datetime
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
@@ -110,8 +110,8 @@ async def test_fetch_readings_sends_expected_query_parameters() -> None:
transport = MockTransport(handler) transport = MockTransport(handler)
start_time = datetime.fromisoformat("2024-06-15T12:00:00") start_time = datetime.fromisoformat("2024-06-15T12:00:00+00:00")
end_time = datetime.fromisoformat("2024-06-15T13:00:00") end_time = datetime.fromisoformat("2024-06-15T13:00:00+00:00")
async with AsyncClient( async with AsyncClient(
transport=transport, transport=transport,
@@ -127,11 +127,61 @@ async def test_fetch_readings_sends_expected_query_parameters() -> None:
assert len(readings) == 1 assert len(readings) == 1
assert captured_params["site_id"] == "SITE001" assert captured_params["site_id"] == "SITE001"
assert captured_params["start_time"] == "2024-06-15T12:00:00" assert captured_params["start_time"] == "2024-06-15T12:00:00+00:00"
assert captured_params["end_time"] == "2024-06-15T13:00:00" assert captured_params["end_time"] == "2024-06-15T13:00:00+00:00"
assert captured_params["limit"] == "60" assert captured_params["limit"] == "60"
async def test_fetch_readings_discards_a_reading_outside_the_requested_window() -> None:
# Le garde-fou `refuse_if_overlaps_historical_dataset` ne vérifie que la fenêtre demandée :
# une réponse dont un `timestamp` déborde de `[start_time, end_time)` (bug du mock, ou
# hostile) contournerait ce contrôle si elle atteignait la base telle quelle.
dans_la_fenetre = make_reading()
dans_la_fenetre["timestamp"] = "2024-06-15T12:00:00Z"
hors_fenetre = make_reading()
hors_fenetre["timestamp"] = "2023-01-01T00:00:00Z"
def handler(request: Request) -> Response:
return Response(status_code=200, json=[dans_la_fenetre, hors_fenetre])
async with AsyncClient(
transport=MockTransport(handler),
base_url="https://mock.test",
) as client:
readings = await fetch_readings(
client=client,
site_id="SITE001",
start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"),
end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"),
limit=2,
)
assert readings == [dans_la_fenetre]
async def test_fetch_readings_discards_a_reading_with_an_unparseable_timestamp() -> None:
invalide = make_reading()
invalide["timestamp"] = "pas une date"
def handler(request: Request) -> Response:
return Response(status_code=200, json=[invalide])
async with AsyncClient(
transport=MockTransport(handler),
base_url="https://mock.test",
) as client:
readings = await fetch_readings(
client=client,
site_id="SITE001",
start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"),
end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"),
limit=1,
)
assert readings == []
async def test_fetch_readings_rejects_non_list_response() -> None: async def test_fetch_readings_rejects_non_list_response() -> None:
def handler(request: Request) -> Response: def handler(request: Request) -> Response:
return Response( return Response(
@@ -152,8 +202,8 @@ async def test_fetch_readings_rejects_non_list_response() -> None:
await fetch_readings( await fetch_readings(
client=client, client=client,
site_id="SITE001", site_id="SITE001",
start_time=datetime.fromisoformat("2024-06-15T12:00:00"), start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"),
end_time=datetime.fromisoformat("2024-06-15T13:00:00"), end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"),
limit=60, limit=60,
) )
@@ -175,8 +225,8 @@ async def test_fetch_readings_raises_on_http_error() -> None:
await fetch_readings( await fetch_readings(
client=client, client=client,
site_id="SITE999", site_id="SITE999",
start_time=datetime.fromisoformat("2024-06-15T12:00:00"), start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"),
end_time=datetime.fromisoformat("2024-06-15T13:00:00"), end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"),
limit=60, limit=60,
) )
@@ -283,6 +333,41 @@ def test_create_mock_api_client_requires_credentials(
mock_api_import.create_mock_api_client() mock_api_import.create_mock_api_client()
@pytest.mark.parametrize(
("username", "password_value"),
[
("", "test-password"),
("test-user", ""),
(" ", "test-password"),
("test-user", " "),
],
)
def test_create_mock_api_client_rejects_empty_credentials(
monkeypatch: pytest.MonkeyPatch,
username: str,
password_value: str,
) -> None:
password = MagicMock()
password.get_secret_value.return_value = password_value
settings = SimpleNamespace(
mock_api_username=username,
mock_api_password=password,
)
monkeypatch.setattr(
mock_api_import,
"get_settings",
lambda: settings,
)
with pytest.raises(
ValueError,
match="Les identifiants de l'API Mock ne sont pas configurés",
):
mock_api_import.create_mock_api_client()
async def test_create_mock_api_client_uses_configuration( async def test_create_mock_api_client_uses_configuration(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@@ -322,6 +407,100 @@ async def test_upsert_sites_with_empty_list_does_nothing() -> None:
connection.execute.assert_not_awaited() connection.execute.assert_not_awaited()
async def test_refuse_if_overlaps_historical_dataset_lets_a_clear_window_through() -> None:
connection = AsyncMock()
connection.execute.return_value.scalar_one = MagicMock(return_value=0)
await mock_api_import.refuse_if_overlaps_historical_dataset(
connection,
datetime.fromisoformat("2026-01-01T00:00:00+00:00"),
datetime.fromisoformat("2026-01-01T01:00:00+00:00"),
)
connection.execute.assert_awaited_once()
async def test_refuse_if_overlaps_historical_dataset_rejects_a_window_already_in_the_csv() -> None:
connection = AsyncMock()
connection.execute.return_value.scalar_one = MagicMock(return_value=5)
with pytest.raises(ValueError, match="doublon inter-source"):
await mock_api_import.refuse_if_overlaps_historical_dataset(
connection,
datetime.fromisoformat("2023-06-15T12:00:00+00:00"),
datetime.fromisoformat("2023-06-15T13:00:00+00:00"),
)
def test_limit_for_window_returns_one_per_hour() -> None:
limite = mock_api_import.limit_for_window(
datetime.fromisoformat("2026-09-02T12:00:00+00:00"),
datetime.fromisoformat("2026-09-23T12:00:00+00:00"),
)
assert limite == 21 * 24
def test_limit_for_window_falls_back_to_one_reading_under_an_hour() -> None:
# Le DAG horaire (`:45`) demande desormais [heure pile precedente, instant du declenchement) :
# une fenetre plus courte qu'une heure, dont l'espacement `duree/limit` ne peut jamais valoir
# 1h pile pour plus d'une lecture. Seule `limit=1`, ancree sur `start_time`, reste alignee.
limite = mock_api_import.limit_for_window(
datetime.fromisoformat("2026-09-02T12:00:00+00:00"),
datetime.fromisoformat("2026-09-02T12:45:00+00:00"),
)
assert limite == 1
def test_limit_for_window_falls_back_to_one_reading_for_a_non_whole_hour_span() -> None:
# Meme raisonnement pour une fenetre de plus d'une heure mais qui n'en est pas un multiple
# entier : aucun `limit > 1` ne donnerait un espacement d'1h pile.
limite = mock_api_import.limit_for_window(
datetime.fromisoformat("2026-09-02T12:00:00+00:00"),
datetime.fromisoformat("2026-09-02T13:30:00+00:00"),
)
assert limite == 1
def test_limit_for_window_rejects_a_start_time_not_on_the_hour() -> None:
with pytest.raises(ValueError, match="pile sur l'heure"):
mock_api_import.limit_for_window(
datetime.fromisoformat("2026-09-02T12:05:00+00:00"),
datetime.fromisoformat("2026-09-02T13:05:00+00:00"),
)
def test_limit_for_window_rejects_a_window_above_the_api_cap() -> None:
with pytest.raises(ValueError, match="au-delà du plafond"):
mock_api_import.limit_for_window(
datetime.fromisoformat("2020-01-01T00:00:00+00:00"),
datetime.fromisoformat("2020-03-01T00:00:00+00:00"),
)
def _mock_engine(*, overlap_count: int = 0) -> tuple[MagicMock, AsyncMock]:
"""Engine dont `.connect()` (garde-fou) et `.begin()` (écriture) rendent tous deux la même
connexion, dont `scalar_one()` renvoie `overlap_count` : `import_mock_api_history` ouvre
désormais le garde-fou via `.connect()`, y compris en dry-run."""
connection = AsyncMock()
connection.execute.return_value.scalar_one = MagicMock(return_value=overlap_count)
def _context() -> MagicMock:
context = MagicMock()
context.__aenter__ = AsyncMock(return_value=connection)
context.__aexit__ = AsyncMock(return_value=None)
return context
engine = MagicMock()
engine.connect.return_value = _context()
engine.begin.return_value = _context()
engine.dispose = AsyncMock()
return engine, connection
async def test_import_mock_api_history_dry_run_does_not_write( async def test_import_mock_api_history_dry_run_does_not_write(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@@ -361,22 +540,24 @@ async def test_import_mock_api_history_dry_run_does_not_write(
), ),
) )
create_engine_mock = MagicMock() engine, connection = _mock_engine(overlap_count=0)
monkeypatch.setattr( monkeypatch.setattr(
mock_api_import, mock_api_import,
"create_async_engine", "create_async_engine",
create_engine_mock, MagicMock(return_value=engine),
) )
await mock_api_import.import_mock_api_history( await mock_api_import.import_mock_api_history(
start_time=datetime.fromisoformat("2024-06-15T12:00:00"), start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"),
end_time=datetime.fromisoformat("2024-06-15T13:00:00"), end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"),
limit=60,
dry_run=True, dry_run=True,
) )
create_engine_mock.assert_not_called() # Le garde-fou tourne quand même (lecture seule), mais aucune écriture n'a lieu.
connection.execute.assert_awaited_once()
engine.begin.assert_not_called()
engine.dispose.assert_awaited_once()
async def test_import_mock_api_history_loads_data( async def test_import_mock_api_history_loads_data(
@@ -418,23 +599,8 @@ async def test_import_mock_api_history_loads_data(
), ),
) )
connection = AsyncMock() engine, connection = _mock_engine(overlap_count=0)
create_engine_mock = MagicMock(return_value=engine)
transaction_context = MagicMock()
transaction_context.__aenter__ = AsyncMock(
return_value=connection,
)
transaction_context.__aexit__ = AsyncMock(
return_value=None,
)
engine = MagicMock()
engine.begin.return_value = transaction_context
engine.dispose = AsyncMock()
create_engine_mock = MagicMock(
return_value=engine,
)
upsert_sites_mock = AsyncMock() upsert_sites_mock = AsyncMock()
@@ -451,9 +617,8 @@ async def test_import_mock_api_history_loads_data(
) )
await mock_api_import.import_mock_api_history( await mock_api_import.import_mock_api_history(
start_time=datetime.fromisoformat("2024-06-15T12:00:00"), start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"),
end_time=datetime.fromisoformat("2024-06-15T13:00:00"), end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"),
limit=60,
dry_run=False, dry_run=False,
) )
@@ -467,7 +632,39 @@ async def test_import_mock_api_history_loads_data(
[make_site()], [make_site()],
) )
# Un appel pour le garde-fou (via .connect()), un pour READING_INSERT (via .begin()).
assert connection.execute.await_count == 2
dernier_appel = connection.execute.await_args_list[-1]
assert dernier_appel.args[0] is READING_INSERT
engine.dispose.assert_awaited_once()
async def test_import_mock_api_history_refuses_when_it_overlaps_the_historical_dataset(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
mock_api_import,
"get_settings",
lambda: SimpleNamespace(database_url="postgresql+asyncpg://test:test@localhost/test"),
)
engine, connection = _mock_engine(overlap_count=3)
monkeypatch.setattr(mock_api_import, "create_async_engine", MagicMock(return_value=engine))
# Le garde-fou tourne avant tout appel à l'API Mock : create_mock_api_client() ne doit
# jamais être invoqué pour une fenêtre refusée.
create_client_mock = MagicMock()
monkeypatch.setattr(mock_api_import, "create_mock_api_client", create_client_mock)
with pytest.raises(ValueError, match="doublon inter-source"):
await mock_api_import.import_mock_api_history(
start_time=datetime.fromisoformat("2023-06-15T12:00:00+00:00"),
end_time=datetime.fromisoformat("2023-06-15T13:00:00+00:00"),
dry_run=False,
)
connection.execute.assert_awaited_once() connection.execute.assert_awaited_once()
create_client_mock.assert_not_called()
engine.dispose.assert_awaited_once() engine.dispose.assert_awaited_once()
@@ -481,6 +678,23 @@ def test_parse_datetime_accepts_z_suffix() -> None:
) )
def test_parse_datetime_attaches_utc_to_a_naive_string() -> None:
# `--start-time`/`--end-time` du DAG sont formatés sans fuseau (Jinja `strftime`) : sans ce
# comportement, l'encodeur `timestamptz` d'asyncpg lirait le datetime naïf dans le fuseau
# *local du processus*, pas UTC, et le garde-fou comparerait une autre fenêtre que celle
# envoyée à l'API.
result = mock_api_import.parse_datetime("2024-06-15T12:00:00")
assert result == datetime.fromisoformat("2024-06-15T12:00:00+00:00")
assert result.tzinfo is UTC
def test_parse_datetime_keeps_a_non_utc_offset_as_is() -> None:
result = mock_api_import.parse_datetime("2024-06-15T12:00:00+02:00")
assert result == datetime.fromisoformat("2024-06-15T12:00:00+02:00")
def test_parse_args_reads_cli_parameters( def test_parse_args_reads_cli_parameters(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@@ -493,8 +707,6 @@ def test_parse_args_reads_cli_parameters(
"2024-06-15T12:00:00Z", "2024-06-15T12:00:00Z",
"--end-time", "--end-time",
"2024-06-15T13:00:00Z", "2024-06-15T13:00:00Z",
"--limit",
"60",
"--dry-run", "--dry-run",
], ],
) )
@@ -507,34 +719,9 @@ def test_parse_args_reads_cli_parameters(
assert args.end_time == datetime.fromisoformat( assert args.end_time == datetime.fromisoformat(
"2024-06-15T13:00:00+00:00", "2024-06-15T13:00:00+00:00",
) )
assert args.limit == 60
assert args.dry_run is True assert args.dry_run is True
def test_main_rejects_limit_out_of_bounds(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
sys,
"argv",
[
"mock_api_import",
"--start-time",
"2024-06-15T12:00:00Z",
"--end-time",
"2024-06-15T13:00:00Z",
"--limit",
"0",
],
)
with pytest.raises(
ValueError,
match="--limit doit être compris entre 1 et 1000",
):
mock_api_import.main()
def test_main_rejects_invalid_period( def test_main_rejects_invalid_period(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@@ -547,8 +734,6 @@ def test_main_rejects_invalid_period(
"2024-06-15T14:00:00Z", "2024-06-15T14:00:00Z",
"--end-time", "--end-time",
"2024-06-15T13:00:00Z", "2024-06-15T13:00:00Z",
"--limit",
"60",
], ],
) )
@@ -577,7 +762,6 @@ def test_main_runs_import(
lambda: SimpleNamespace( lambda: SimpleNamespace(
start_time=start_time, start_time=start_time,
end_time=end_time, end_time=end_time,
limit=60,
dry_run=True, dry_run=True,
), ),
) )
@@ -593,7 +777,6 @@ def test_main_runs_import(
import_mock.assert_awaited_once_with( import_mock.assert_awaited_once_with(
start_time=start_time, start_time=start_time,
end_time=end_time, end_time=end_time,
limit=60,
dry_run=True, dry_run=True,
) )
@@ -638,8 +821,8 @@ async def test_fetch_readings_rejects_a_response_above_the_requested_limit() ->
await fetch_readings( await fetch_readings(
client=client, client=client,
site_id="SITE001", site_id="SITE001",
start_time=datetime.fromisoformat("2024-06-15T12:00:00"), start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"),
end_time=datetime.fromisoformat("2024-06-15T13:00:00"), end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"),
limit=2, limit=2,
) )
@@ -0,0 +1,710 @@
import base64
import io
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta, timezone
from decimal import Decimal
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import boto3
import pandas as pd
import pytest
from botocore.exceptions import ClientError
from botocore.response import StreamingBody
from botocore.stub import Stubber
from pydantic import SecretStr
from tests.factories import make_settings
import app.etl.reading_retention as reading_retention
from app.core.config import Settings
from app.etl.reading_retention import (
CHUNK_ROWS,
DROP_CHUNK,
ELIGIBLE_CHUNKS,
ArchiveStore,
Chunk,
Rapport,
archive_reading_chunks,
build_archive_store,
build_parser,
decode_sse_key,
drop_chunk,
eligible_chunks,
object_key,
read_chunk_rows,
serialize_csv_gzip,
sha256_of,
)
CLE_SSE = b"0123456789abcdef0123456789abcdef"
CLE_SSE_BASE64 = base64.b64encode(CLE_SSE).decode("ascii")
CHUNK = Chunk(
schema="_timescaledb_internal",
name="_hyper_1_7_chunk",
range_start=datetime(2023, 1, 5, tzinfo=UTC),
range_end=datetime(2023, 1, 12, tzinfo=UTC),
)
CLE_ATTENDUE = "reading/2023/reading_20230105T000000Z_20230112T000000Z.csv.gz"
def make_row(**overrides: Any) -> dict[str, Any]:
ligne: dict[str, Any] = {
"reading_id": 1,
"site_id": "SITE001",
"timestamp": datetime(2023, 1, 5, 12, tzinfo=UTC),
"source": "csv",
"dataset_id": 1,
"consumption_kw": Decimal("87.34"),
"data_quality": "good",
"null_reasons": ["sensor_offline"],
"imputed_values": None,
"raw_data": {"b": 1, "a": "é"},
}
return {**ligne, **overrides}
def settings_s3(**overrides: Any) -> Settings:
reglages: dict[str, Any] = {
"_env_file": None,
"secret_key": SecretStr("secret-de-test-assez-long-pour-le-validateur"),
"database_url": "postgresql+asyncpg://retention:test@localhost:5432/enervision",
"s3_endpoint_url": "http://garage:3900",
"s3_access_key": "GK0123456789",
"s3_secret_key": SecretStr("un-secret-garage"),
"s3_bucket": "enervision-archives",
"s3_sse_key": SecretStr(CLE_SSE_BASE64),
}
return Settings(**{**reglages, **overrides})
def s3_client() -> Any:
return boto3.client(
"s3",
endpoint_url="http://garage:3900",
aws_access_key_id="GK0123456789",
aws_secret_access_key="un-secret-garage",
region_name="garage",
)
def streaming(data: bytes) -> StreamingBody:
return StreamingBody(io.BytesIO(data), len(data))
def test_settings_treat_empty_s3_values_as_absent() -> None:
settings = make_settings(
s3_endpoint_url="", s3_access_key="", s3_secret_key="", s3_bucket="", s3_sse_key=""
)
assert settings.s3_endpoint_url is None
assert settings.s3_access_key is None
assert settings.s3_secret_key is None
assert settings.s3_bucket is None
assert settings.s3_sse_key is None
assert settings.reading_retention_days == 1095
def test_object_key_places_the_chunk_under_the_year_of_its_start() -> None:
assert object_key(CHUNK) == CLE_ATTENDUE
def test_object_key_expresses_the_bounds_in_utc() -> None:
paris = timezone(timedelta(hours=1))
chunk = Chunk(
schema=CHUNK.schema,
name=CHUNK.name,
range_start=datetime(2023, 1, 5, 1, tzinfo=paris),
range_end=datetime(2023, 1, 12, 1, tzinfo=paris),
)
assert object_key(chunk) == CLE_ATTENDUE
def test_serialize_csv_gzip_is_read_back_by_pandas() -> None:
archive = serialize_csv_gzip([make_row(), make_row(reading_id=2, null_reasons=[])])
relu = pd.read_csv(io.BytesIO(archive), compression="gzip")
assert list(relu.columns) == list(make_row())
assert relu["reading_id"].tolist() == [1, 2]
assert relu["site_id"].tolist() == ["SITE001", "SITE001"]
def test_serialize_csv_gzip_writes_jsonb_and_arrays_as_sorted_json() -> None:
archive = serialize_csv_gzip([make_row()])
relu = pd.read_csv(io.BytesIO(archive), compression="gzip")
assert relu.loc[0, "raw_data"] == '{"a": "é", "b": 1}'
assert relu.loc[0, "null_reasons"] == '["sensor_offline"]'
def test_serialize_csv_gzip_is_byte_for_byte_reproducible() -> None:
lignes = [make_row(), make_row(reading_id=2)]
premier = serialize_csv_gzip(lignes)
second = serialize_csv_gzip(lignes)
assert premier == second
def test_serialize_csv_gzip_refuses_an_empty_export() -> None:
with pytest.raises(ValueError, match="Aucune ligne"):
serialize_csv_gzip([])
def test_sha256_of_hashes_the_bytes() -> None:
assert sha256_of(b"hello").startswith("2cf24dba")
def test_store_put_sends_the_sse_c_headers_when_a_key_is_set() -> None:
client = s3_client()
store = ArchiveStore(client, bucket="enervision-archives", sse_key=CLE_SSE)
with Stubber(client) as stub:
stub.add_response(
"put_object",
{},
expected_params={
"Bucket": "enervision-archives",
"Key": CLE_ATTENDUE,
"Body": b"corps",
"ContentType": "text/csv",
"ContentEncoding": "gzip",
"Metadata": {"sha256": "abc"},
"SSECustomerAlgorithm": "AES256",
"SSECustomerKey": CLE_SSE,
},
)
store.put(CLE_ATTENDUE, b"corps", {"sha256": "abc"})
stub.assert_no_pending_responses()
def test_store_put_omits_the_sse_c_headers_without_a_key() -> None:
client = s3_client()
store = ArchiveStore(client, bucket="enervision-archives", sse_key=None)
with Stubber(client) as stub:
stub.add_response(
"put_object",
{},
expected_params={
"Bucket": "enervision-archives",
"Key": CLE_ATTENDUE,
"Body": b"corps",
"ContentType": "text/csv",
"ContentEncoding": "gzip",
"Metadata": {},
},
)
store.put(CLE_ATTENDUE, b"corps", {})
stub.assert_no_pending_responses()
def test_store_fetch_sha256_hashes_the_object_read_with_the_key() -> None:
client = s3_client()
store = ArchiveStore(client, bucket="enervision-archives", sse_key=CLE_SSE)
with Stubber(client) as stub:
stub.add_response(
"get_object",
{"Body": streaming(b"hello")},
expected_params={
"Bucket": "enervision-archives",
"Key": CLE_ATTENDUE,
"SSECustomerAlgorithm": "AES256",
"SSECustomerKey": CLE_SSE,
},
)
assert store.fetch_sha256(CLE_ATTENDUE) == sha256_of(b"hello")
@pytest.mark.parametrize(
("code", "statut"),
[("NoSuchKey", 404), ("NotFound", 404), ("NoSuchKey", 400)],
ids=["no_such_key", "404_sans_code_connu", "no_such_key_sans_404"],
)
def test_store_fetch_sha256_returns_none_for_a_missing_object(code: str, statut: int) -> None:
client = s3_client()
store = ArchiveStore(client, bucket="enervision-archives", sse_key=None)
with Stubber(client) as stub:
stub.add_client_error("get_object", service_error_code=code, http_status_code=statut)
assert store.fetch_sha256(CLE_ATTENDUE) is None
def test_store_fetch_sha256_raises_any_other_error() -> None:
client = s3_client()
store = ArchiveStore(client, bucket="enervision-archives", sse_key=None)
with Stubber(client) as stub:
stub.add_client_error("get_object", service_error_code="AccessDenied", http_status_code=403)
with pytest.raises(ClientError):
store.fetch_sha256(CLE_ATTENDUE)
def test_decode_sse_key_returns_none_without_a_key() -> None:
assert decode_sse_key(None) is None
def test_decode_sse_key_decodes_the_base64_key() -> None:
assert decode_sse_key(SecretStr(CLE_SSE_BASE64)) == CLE_SSE
def test_decode_sse_key_refuses_a_key_of_the_wrong_length() -> None:
courte = SecretStr(base64.b64encode(b"trop-courte").decode("ascii"))
with pytest.raises(ValueError, match="exactement 32 octets"):
decode_sse_key(courte)
@pytest.mark.parametrize(
"manquant",
["s3_endpoint_url", "s3_access_key", "s3_secret_key", "s3_bucket"],
)
def test_build_archive_store_refuses_a_missing_setting(manquant: str) -> None:
reglages = settings_s3(**{manquant: None})
with pytest.raises(ValueError, match="APP_S3_ENDPOINT_URL"):
build_archive_store(reglages)
def test_build_archive_store_refuses_a_sse_key_of_the_wrong_length() -> None:
courte = base64.b64encode(b"trop-courte").decode("ascii")
reglages = settings_s3(s3_sse_key=SecretStr(courte))
with pytest.raises(ValueError, match="exactement 32 octets"):
build_archive_store(reglages)
def test_build_archive_store_configures_the_client_from_the_settings(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recu: dict[str, Any] = {}
def faux_client(service: str, **kwargs: Any) -> MagicMock:
recu["service"] = service
recu.update(kwargs)
return MagicMock()
monkeypatch.setattr(reading_retention.boto3, "client", faux_client)
build_archive_store(settings_s3())
assert recu == {
"service": "s3",
"endpoint_url": "http://garage:3900",
"aws_access_key_id": "GK0123456789",
"aws_secret_access_key": "un-secret-garage",
"region_name": "garage",
}
def test_build_archive_store_uses_the_bucket_and_the_decoded_key() -> None:
store = build_archive_store(settings_s3())
with Stubber(store._client) as stub:
stub.add_response(
"get_object",
{"Body": streaming(b"hello")},
expected_params={
"Bucket": "enervision-archives",
"Key": CLE_ATTENDUE,
"SSECustomerAlgorithm": "AES256",
"SSECustomerKey": CLE_SSE,
},
)
assert store.fetch_sha256(CLE_ATTENDUE) == sha256_of(b"hello")
def test_build_archive_store_accepts_an_absent_sse_key() -> None:
store = build_archive_store(settings_s3(s3_sse_key=None))
with Stubber(store._client) as stub:
stub.add_response(
"get_object",
{"Body": streaming(b"hello")},
expected_params={"Bucket": "enervision-archives", "Key": CLE_ATTENDUE},
)
assert store.fetch_sha256(CLE_ATTENDUE) == sha256_of(b"hello")
class FakeResult:
def __init__(self, rows: list[Any]) -> None:
self._rows = rows
def mappings(self) -> FakeResult:
return self
def scalars(self) -> FakeResult:
return self
def all(self) -> list[Any]:
return self._rows
def chunk_mapping(chunk: Chunk) -> dict[str, Any]:
return {
"chunk_schema": chunk.schema,
"chunk_name": chunk.name,
"range_start": chunk.range_start,
"range_end": chunk.range_end,
}
async def test_eligible_chunks_queries_the_timescaledb_catalog() -> None:
conn = AsyncMock()
conn.execute.return_value = FakeResult([chunk_mapping(CHUNK)])
borne = datetime(2023, 10, 1, tzinfo=UTC)
chunks = await eligible_chunks(conn, borne)
assert chunks == [CHUNK]
statement, params = conn.execute.await_args.args
assert statement is ELIGIBLE_CHUNKS
assert params == {"older_than": borne}
async def test_read_chunk_rows_reads_through_the_hypertable_within_the_chunk_bounds() -> None:
conn = AsyncMock()
conn.execute.return_value = FakeResult([make_row(), make_row(reading_id=2)])
lignes = await read_chunk_rows(conn, CHUNK)
assert lignes == [make_row(), make_row(reading_id=2)]
statement, params = conn.execute.await_args.args
assert statement is CHUNK_ROWS
assert params == {"start": CHUNK.range_start, "end": CHUNK.range_end}
async def test_drop_chunk_targets_the_chunk_by_its_own_bounds() -> None:
conn = AsyncMock()
conn.execute.return_value = FakeResult([CHUNK.qualified_name])
await drop_chunk(conn, CHUNK)
statement, params = conn.execute.await_args.args
assert statement is DROP_CHUNK
assert params == {"start": CHUNK.range_start, "end": CHUNK.range_end}
@pytest.mark.parametrize(
"rendu",
[[], ["_timescaledb_internal._hyper_1_7_chunk", "_timescaledb_internal._hyper_1_8_chunk"]],
ids=["aucun_chunk", "deux_chunks"],
)
async def test_drop_chunk_raises_unless_exactly_the_chunk_was_dropped(rendu: list[str]) -> None:
conn = AsyncMock()
conn.execute.return_value = FakeResult(rendu)
with pytest.raises(RuntimeError, match=r"exactement _timescaledb_internal\._hyper_1_7_chunk"):
await drop_chunk(conn, CHUNK)
class FakeConn:
def __init__(self, journal: list[str], chunks: list[Chunk], rows: list[dict[str, Any]]) -> None:
self._journal = journal
self._chunks = chunks
self._rows = rows
async def execute(self, statement: Any, params: dict[str, Any]) -> FakeResult:
if statement is ELIGIBLE_CHUNKS:
self._journal.append("lister")
return FakeResult([chunk_mapping(chunk) for chunk in self._chunks])
if statement is CHUNK_ROWS:
self._journal.append("lire")
return FakeResult(self._rows)
self._journal.append("drop")
chunk = next(c for c in self._chunks if c.range_start == params["start"])
return FakeResult([chunk.qualified_name])
class FakeEngine:
def __init__(self, conn: FakeConn, journal: list[str]) -> None:
self._conn = conn
self._journal = journal
@asynccontextmanager
async def connect(self) -> AsyncIterator[FakeConn]:
self._journal.append("connect")
yield self._conn
@asynccontextmanager
async def begin(self) -> AsyncIterator[FakeConn]:
self._journal.append("begin")
yield self._conn
async def dispose(self) -> None:
self._journal.append("dispose")
class FakeStore(ArchiveStore):
def __init__(self, journal: list[str], *, corrompt: bool = False) -> None:
super().__init__(MagicMock(), bucket="enervision-archives", sse_key=None)
self._journal = journal
self._corrompt = corrompt
self.objets: dict[str, str] = {}
self.metadata: dict[str, dict[str, str]] = {}
def put(self, key: str, body: bytes, metadata: dict[str, str]) -> None:
self._journal.append("put")
self.objets[key] = "sha-corrompu" if self._corrompt else sha256_of(body)
self.metadata[key] = metadata
def fetch_sha256(self, key: str) -> str | None:
self._journal.append("relire")
return self.objets.get(key)
def make_archive(
chunks: list[Chunk] | None = None,
rows: list[dict[str, Any]] | None = None,
*,
corrompt: bool = False,
) -> tuple[FakeEngine, FakeStore, list[str]]:
journal: list[str] = []
lignes = [make_row(), make_row(reading_id=2)] if rows is None else rows
eligibles = [CHUNK] if chunks is None else chunks
engine = FakeEngine(FakeConn(journal, eligibles, lignes), journal)
return engine, FakeStore(journal, corrompt=corrompt), journal
async def test_archive_reading_chunks_reads_exports_verifies_then_drops(
capsys: pytest.CaptureFixture[str],
) -> None:
engine, store, journal = make_archive()
borne = datetime(2023, 10, 1, tzinfo=UTC)
rapport = await archive_reading_chunks(engine, store, older_than=borne, dry_run=False)
assert journal == [
"connect",
"lister",
"connect",
"lire",
"relire",
"put",
"relire",
"begin",
"drop",
]
assert rapport == Rapport(chunks_vus=1, exportes=1, deja_presents=0, supprimes=1, lignes=2)
assert store.objets[CLE_ATTENDUE] == sha256_of(
serialize_csv_gzip([make_row(), make_row(reading_id=2)])
)
assert store.metadata[CLE_ATTENDUE] == {"sha256": store.objets[CLE_ATTENDUE], "rows": "2"}
sortie = capsys.readouterr().out
assert f"{CLE_ATTENDUE} : 2 ligne(s)" in sortie
assert "exportés et relus" in sortie
assert f"chunk {CHUNK.qualified_name} supprimé" in sortie
assert "Archivage terminé." in sortie
async def test_archive_reading_chunks_skips_the_upload_when_the_object_already_matches() -> None:
engine, store, journal = make_archive()
store.objets[CLE_ATTENDUE] = sha256_of(serialize_csv_gzip([make_row(), make_row(reading_id=2)]))
rapport = await archive_reading_chunks(
engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=False
)
assert "put" not in journal
assert journal[-2:] == ["begin", "drop"]
assert rapport == Rapport(chunks_vus=1, exportes=0, deja_presents=1, supprimes=1, lignes=2)
async def test_archive_reading_chunks_re_uploads_when_the_stored_object_differs() -> None:
engine, store, journal = make_archive()
store.objets[CLE_ATTENDUE] = "un-autre-sha"
rapport = await archive_reading_chunks(
engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=False
)
assert journal.count("put") == 1
assert rapport.exportes == 1
assert rapport.deja_presents == 0
async def test_archive_reading_chunks_in_dry_run_neither_writes_nor_drops(
capsys: pytest.CaptureFixture[str],
) -> None:
engine, store, journal = make_archive()
rapport = await archive_reading_chunks(
engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=True
)
assert "put" not in journal
assert "begin" not in journal
assert "drop" not in journal
assert rapport == Rapport(chunks_vus=1, exportes=0, deja_presents=0, supprimes=0, lignes=2)
sortie = capsys.readouterr().out
assert "octets à exporter, suppression simulée." in sortie
assert "Dry-run terminé : rien n'a été écrit ni supprimé." in sortie
async def test_archive_reading_chunks_keeps_the_chunk_when_the_read_back_differs() -> None:
engine, store, journal = make_archive(corrompt=True)
borne = datetime(2023, 10, 1, tzinfo=UTC)
with pytest.raises(RuntimeError, match="sha256 sha-corrompu au lieu de"):
await archive_reading_chunks(engine, store, older_than=borne, dry_run=False)
assert "put" in journal
assert "drop" not in journal
async def test_archive_reading_chunks_drops_an_empty_chunk_without_exporting(
capsys: pytest.CaptureFixture[str],
) -> None:
engine, store, journal = make_archive(rows=[])
rapport = await archive_reading_chunks(
engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=False
)
assert "put" not in journal
assert "relire" not in journal
assert journal[-2:] == ["begin", "drop"]
assert rapport == Rapport(chunks_vus=1, exportes=0, deja_presents=0, supprimes=1, lignes=0)
assert "vide, rien à exporter" in capsys.readouterr().out
async def test_archive_reading_chunks_handles_each_chunk_in_turn() -> None:
suivant = Chunk(
schema=CHUNK.schema,
name="_hyper_1_8_chunk",
range_start=CHUNK.range_end,
range_end=CHUNK.range_end + timedelta(days=7),
)
engine, store, journal = make_archive(chunks=[CHUNK, suivant])
rapport = await archive_reading_chunks(
engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=False
)
assert rapport == Rapport(chunks_vus=2, exportes=2, deja_presents=0, supprimes=2, lignes=4)
assert set(store.objets) == {CLE_ATTENDUE, object_key(suivant)}
assert journal.count("drop") == 2
async def test_archive_reading_chunks_reports_nothing_to_do_without_eligible_chunks(
capsys: pytest.CaptureFixture[str],
) -> None:
engine, store, journal = make_archive(chunks=[])
rapport = await archive_reading_chunks(
engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=False
)
assert rapport == Rapport()
assert journal == ["connect", "lister"]
assert "0 chunk(s) de reading" in capsys.readouterr().out
def test_build_parser_defaults_to_the_settings_and_a_real_run() -> None:
arguments = build_parser().parse_args([])
assert arguments.older_than_days is None
assert arguments.dry_run is False
def test_build_parser_reads_the_bound_and_the_dry_run() -> None:
arguments = build_parser().parse_args(["--older-than-days", "400", "--dry-run"])
assert arguments.older_than_days == 400
assert arguments.dry_run is True
def test_build_parser_refuses_a_non_integer_bound() -> None:
parser = build_parser()
with pytest.raises(SystemExit):
parser.parse_args(["--older-than-days", "un-an"])
def test_build_parser_answers_help_without_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("APP_SECRET_KEY", raising=False)
parser = build_parser()
with pytest.raises(SystemExit) as sortie:
parser.parse_args(["--help"])
assert sortie.value.code == 0
@pytest.fixture
def main_branche(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
capture: dict[str, Any] = {}
journal: list[str] = []
engine = FakeEngine(FakeConn(journal, [], []), journal)
store = FakeStore(journal)
async def faux_archive(
engine_recu: Any, store_recu: Any, *, older_than: datetime, dry_run: bool
) -> Rapport:
capture.update(engine=engine_recu, store=store_recu, older_than=older_than, dry_run=dry_run)
return Rapport()
def faux_engine(url: str, **kwargs: Any) -> FakeEngine:
capture["url"] = url
capture["engine_kwargs"] = kwargs
return engine
monkeypatch.setattr(reading_retention, "get_settings", settings_s3)
monkeypatch.setattr(reading_retention, "build_archive_store", lambda settings: store)
monkeypatch.setattr(reading_retention, "create_async_engine", faux_engine)
monkeypatch.setattr(reading_retention, "archive_reading_chunks", faux_archive)
capture["journal"] = journal
capture["store_attendu"] = store
capture["engine_attendu"] = engine
return capture
def test_main_uses_the_retention_setting_by_default(main_branche: dict[str, Any]) -> None:
avant = datetime.now(UTC)
reading_retention.main([])
attendu = avant - timedelta(days=1095)
assert timedelta(0) <= main_branche["older_than"] - attendu < timedelta(seconds=5)
assert main_branche["dry_run"] is False
assert main_branche["store"] is main_branche["store_attendu"]
assert main_branche["engine"] is main_branche["engine_attendu"]
assert main_branche["url"] == "postgresql+asyncpg://retention:test@localhost:5432/enervision"
assert main_branche["engine_kwargs"] == {"pool_pre_ping": True}
assert main_branche["journal"] == ["dispose"]
def test_main_honours_an_explicit_bound_and_the_dry_run(main_branche: dict[str, Any]) -> None:
avant = datetime.now(UTC)
reading_retention.main(["--older-than-days", "10", "--dry-run"])
attendu = avant - timedelta(days=10)
assert timedelta(0) <= main_branche["older_than"] - attendu < timedelta(seconds=5)
assert main_branche["dry_run"] is True
def test_main_fails_before_touching_the_database_without_s3_settings(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(reading_retention, "get_settings", lambda: settings_s3(s3_bucket=None))
monkeypatch.setattr(
reading_retention,
"create_async_engine",
lambda *_, **__: pytest.fail("l'engine ne doit pas être créé"),
)
with pytest.raises(ValueError, match="APP_S3_BUCKET"):
reading_retention.main([])
@@ -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
+144 -37
View File
@@ -192,6 +192,43 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
] ]
[[package]]
name = "boto3"
version = "1.43.101"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/ef/096f1520a4b0cbc794348fcf77ada637e5f98145c3453f219d678c3a0798/boto3-1.43.101.tar.gz", hash = "sha256:49f3eb750f70e050df9929a7e9392e67896c97d7d0a448f13ed3354c634268bd", size = 112635, upload-time = "2026-09-23T19:23:29.553Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/73/8dd65374f88b1b2a33656d9c808dc36aef0cb4a22c74d618ba6fe0092cd2/boto3-1.43.101-py3-none-any.whl", hash = "sha256:8a899b0ea94df3f2fab6d0c69caf2791f2971449696834374d8e88ced01c7ef3", size = 140041, upload-time = "2026-09-23T19:23:27.61Z" },
]
[[package]]
name = "botocore"
version = "1.43.101"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/12/12/e90cc51bd65ecdcd0eedcd522d3c9f102b1d2c601f39f1f1c256695d63a3/botocore-1.43.101.tar.gz", hash = "sha256:3bc67fb55046e1e05ce5f2bd0171f37bef1cf54161786ef04ff338614d98169e", size = 16202504, upload-time = "2026-09-23T19:23:24.49Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/0d/6679253333d6ba74b8ad7077560687096629255ef526e106bb3accceffcc/botocore-1.43.101-py3-none-any.whl", hash = "sha256:f380237ffecc3f887265cd09c4d7e9c8e8dd9ba6162af83b1fc9e5d24622e461", size = 15897867, upload-time = "2026-09-23T19:23:21.643Z" },
]
[[package]]
name = "botocore-stubs"
version = "1.43.67"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3f/45/53d662227dc4787b2c854445ee7eb4751cb5d74cfb5c686a6ecbe1f94c17/botocore_stubs-1.43.67.tar.gz", hash = "sha256:853e74014a1f557055c4ffae5fb38d7c65c7c0520e1aab366cac41d5428f419d", size = 42846, upload-time = "2026-08-08T14:57:53.412Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/5e/bdbf19967898a032292da65a47d6e25b2eee55865db4e687f861d80b5602/botocore_stubs-1.43.67-py3-none-any.whl", hash = "sha256:c51262bac3341c1cda71f05fa01141fffd3990d7a92c7960e3b755c1bc830373", size = 67244, upload-time = "2026-08-08T14:57:52.01Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.7.22" version = "2026.7.22"
@@ -325,6 +362,7 @@ dependencies = [
{ name = "anyio" }, { name = "anyio" },
{ name = "argon2-cffi" }, { name = "argon2-cffi" },
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "boto3" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "pandas" }, { name = "pandas" },
@@ -345,6 +383,7 @@ dev = [
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "pytest-cov" }, { name = "pytest-cov" },
{ name = "ruff" }, { name = "ruff" },
{ name = "types-boto3", extra = ["s3"] },
] ]
[package.metadata] [package.metadata]
@@ -354,6 +393,7 @@ requires-dist = [
{ name = "anyio", specifier = ">=4.0" }, { name = "anyio", specifier = ">=4.0" },
{ name = "argon2-cffi", specifier = ">=23.1" }, { name = "argon2-cffi", specifier = ">=23.1" },
{ name = "asyncpg", specifier = ">=0.31.0" }, { name = "asyncpg", specifier = ">=0.31.0" },
{ name = "boto3", specifier = ">=1.43.101" },
{ name = "fastapi", specifier = ">=0.141.1" }, { name = "fastapi", specifier = ">=0.141.1" },
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "pandas", specifier = ">=3.0.5" }, { name = "pandas", specifier = ">=3.0.5" },
@@ -374,6 +414,7 @@ dev = [
{ name = "pytest-asyncio", specifier = ">=1.4.0" }, { name = "pytest-asyncio", specifier = ">=1.4.0" },
{ name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-cov", specifier = ">=7.1.0" },
{ name = "ruff", specifier = ">=0.16.7" }, { name = "ruff", specifier = ">=0.16.7" },
{ name = "types-boto3", extras = ["s3"], specifier = ">=1.43.101" },
] ]
[[package]] [[package]]
@@ -496,6 +537,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
] ]
[[package]]
name = "jmespath"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]
[[package]] [[package]]
name = "librt" name = "librt"
version = "0.15.0" version = "0.15.0"
@@ -655,31 +705,31 @@ wheels = [
[[package]] [[package]]
name = "pandas" name = "pandas"
version = "3.0.5" version = "3.0.6"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "numpy" }, { name = "numpy" },
{ name = "python-dateutil" }, { name = "python-dateutil" },
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } sdist = { url = "https://files.pythonhosted.org/packages/e2/17/d7b106e05bfa642e8694451e7d3d759c6a241c5386a5d962e4f66c047e06/pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7315a31bfe1089cd3d71a7de781c9dca986762d0b4fe7c0ef17465d10", size = 4667686, upload-time = "2026-09-17T23:23:18.345Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, { url = "https://files.pythonhosted.org/packages/75/55/1a8875395b05ccd572cbca0b9255dcd2db6e6508e632a558c1a6884b39ad/pandas-3.0.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ee913a91669056c1de1a6b733fbfeab711de9e54e3bee2dfa5fe79d9457247d1", size = 10492213, upload-time = "2026-09-17T23:21:40.746Z" },
{ url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, { url = "https://files.pythonhosted.org/packages/35/61/47ae13476995cc8a40cd609e93e7cf11f273d8692925c2903cb6d38aa0d1/pandas-3.0.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ff51a4459ed036e93d1eb1bb5e6e7b28685d3cb6b7c12b91c05b31024e234729", size = 10156618, upload-time = "2026-09-17T23:21:44.142Z" },
{ url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, { url = "https://files.pythonhosted.org/packages/bc/f2/cc5f2adb8d6e86a85d9fb5128f8cf205a61189336f70d1f7faf0d1b53ec9/pandas-3.0.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654aae059295dbba6ecd2328ca12712a2cf1676214c8699f1c29213f7ccf9c34", size = 10375479, upload-time = "2026-09-17T23:21:47.159Z" },
{ url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, { url = "https://files.pythonhosted.org/packages/ca/ba/ffdcb19be4ff6bfe7d969e7cef2c567c633df5a3a1cc1053394ad053bca8/pandas-3.0.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f51d7f651c8054c5e82a69265c98082e795d1442df7ca6edc3a545d61214b1", size = 10783244, upload-time = "2026-09-17T23:21:50.367Z" },
{ url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, { url = "https://files.pythonhosted.org/packages/77/5b/e150075b2c6eb69fae896f2d9239bc6ed07db97735971d53d66de6553460/pandas-3.0.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:22172a92e7ee678ec0140c7af4fc9366b55413834a1cd86af78b3caa0b0574de", size = 11382223, upload-time = "2026-09-17T23:21:53.355Z" },
{ url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, { url = "https://files.pythonhosted.org/packages/d6/8a/b441c587dc7355bf6e1f68a91b4f76a6c29740f0c23be3acc5d4ebbeea6d/pandas-3.0.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583be68728a31d0d750d5b8d9e00f02b153df0d4655f858bde93cb84cfc4227c", size = 11854881, upload-time = "2026-09-17T23:21:56.342Z" },
{ url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, { url = "https://files.pythonhosted.org/packages/b7/e9/f43410fada510b43fec09993c08f552086c3d247d3ee801a678f3cb10ea5/pandas-3.0.6-cp314-cp314-win_amd64.whl", hash = "sha256:77ccbe5057aece6fc172b9b77f19c04335af6882bc2e10c8f3ee4e6bfb3da553", size = 9791672, upload-time = "2026-09-17T23:21:59.332Z" },
{ url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, { url = "https://files.pythonhosted.org/packages/8b/9e/db14c059c21f9baa1907d436f8bf30e0c76c6288225c5e8b79a08ba8b2c5/pandas-3.0.6-cp314-cp314-win_arm64.whl", hash = "sha256:fb625f426b375bcc96e3a04c5d5d266cd7be6ae5d6866e0e703382ab5164068c", size = 9121246, upload-time = "2026-09-17T23:22:02.123Z" },
{ url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, { url = "https://files.pythonhosted.org/packages/67/ba/bad0f8dac020ab38a8637fddab01a57a82da7a496a6e6f19590aad53ab62/pandas-3.0.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9e492cd4bdba6778de4fe0df7f4590c012161ebcf9902dce01b01dc683105514", size = 10920612, upload-time = "2026-09-17T23:22:05.404Z" },
{ url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, { url = "https://files.pythonhosted.org/packages/c4/a9/b500982e9aac6d52a58da4ad3f11e14168a315b06906b3f397c427878065/pandas-3.0.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7dcd21238cbb4828ff148481ba01cac8946dc5121457b5aeba28636f8f99a60", size = 10570213, upload-time = "2026-09-17T23:22:08.44Z" },
{ url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, { url = "https://files.pythonhosted.org/packages/4b/fa/e6ecd0073c98be8f840ac3125b955272835d7d9fd69f5944383b164deb5e/pandas-3.0.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ff482fa91fa2bafd92e8fe66ce3645c851824310f295c1f0a2f96e928fc4541", size = 10252356, upload-time = "2026-09-17T23:22:11.302Z" },
{ url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, { url = "https://files.pythonhosted.org/packages/04/f5/001e230a7a7803590d9275a1a3f7e1bb605e3a495cfe5e8d3a532090621b/pandas-3.0.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:db7ec631f26223beee8e5c9e0b8f23c24d8197bbd1d982421d4e3188bea51965", size = 10655952, upload-time = "2026-09-17T23:22:14.283Z" },
{ url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, { url = "https://files.pythonhosted.org/packages/ca/ab/bab587148a3852c96aae26c4b5f9e04ce2221801ad94e166b4fbf969ede0/pandas-3.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd75ed0c840f709fc2ae26ddd9534ac77ca1a48ac0cce521a74acaa85f3340a7", size = 11274177, upload-time = "2026-09-17T23:22:17.352Z" },
{ url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, { url = "https://files.pythonhosted.org/packages/f3/32/74b48d87df2b80892d713c149abfe36d5db4de41b4eccb042a2bc07dafc1/pandas-3.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ef738d71d1059245b6bb03e312be06d8b3821326a83486c1ad03b9aba3710e44", size = 11719041, upload-time = "2026-09-17T23:22:20.227Z" },
{ url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, { url = "https://files.pythonhosted.org/packages/f6/c6/d64b72d64d7eb0fad9fe424d34138e45dee70ddbea1360dcd0adf30e28f6/pandas-3.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:429d9df32731ab01383ed98f2baa7a60368090d1a94fc06019a12062510e8630", size = 10191388, upload-time = "2026-09-17T23:22:23.524Z" },
{ url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, { url = "https://files.pythonhosted.org/packages/7a/30/5e5b2ccabeca73ae2b03fc82bca3eabb7466cf43737f05ac08d591665d47/pandas-3.0.6-cp314-cp314t-win_arm64.whl", hash = "sha256:a4dbd4dc65cbe645b92b8785d0f96dd7311010dc6606cf620e51b07b8788a12a", size = 9387796, upload-time = "2026-09-17T23:22:26.64Z" },
] ]
[[package]] [[package]]
@@ -936,27 +986,39 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.16.7" version = "0.16.8"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" },
{ url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" },
{ url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" },
{ url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" },
{ url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" },
{ url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" },
{ url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" },
{ url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" },
{ url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" },
{ url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" },
{ url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" },
{ url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" },
{ url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" },
{ url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" },
{ url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" },
{ url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" },
{ url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" },
]
[[package]]
name = "s3transfer"
version = "0.19.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" },
] ]
[[package]] [[package]]
@@ -1006,6 +1068,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
] ]
[[package]]
name = "types-boto3"
version = "1.43.101"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore-stubs" },
{ name = "types-s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/32/e9cfa9a44874cc603220713084bd3d347ee8d3f539748673aa7a62cc7b9c/types_boto3-1.43.101.tar.gz", hash = "sha256:a892e195f6b46e73a3278b08f45dea6470306662647ccf212e8e4366e052e863", size = 105304, upload-time = "2026-09-23T20:24:43.981Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/9e/57626d063d66db4329a6ee96524cf768a20b60194120a0d71ee962758d2b/types_boto3-1.43.101-py3-none-any.whl", hash = "sha256:a5303a8024fa0588dad70fb7ba5845adaa4d86c0ea395063d7ae33badbc6396f", size = 71672, upload-time = "2026-09-23T20:24:39.849Z" },
]
[package.optional-dependencies]
s3 = [
{ name = "types-boto3-s3" },
]
[[package]]
name = "types-boto3-s3"
version = "1.43.93"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f7/1a/285aa2a27436e437aea1c6d6f964b692df3d8c349bf6a35fd476d0b2f7bb/types_boto3_s3-1.43.93.tar.gz", hash = "sha256:6a7f979872b81f6bf22eb4dc39ea9909d635ec756275eca69e9caabdc94d5a6a", size = 79218, upload-time = "2026-09-11T19:44:38.149Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/16/db644e738b967336fb0ca335d708c7d659a965b8ae703e9c50fe209c59be/types_boto3_s3-1.43.93-py3-none-any.whl", hash = "sha256:da9249f05ea081bb3b3f3b8cc49099a988ff5c89da8a7393532397c6174e04d3", size = 86538, upload-time = "2026-09-11T19:44:36.68Z" },
]
[[package]]
name = "types-s3transfer"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/64/42689150509eb3e6e82b33ee3d89045de1592488842ddf23c56957786d05/types_s3transfer-0.16.0.tar.gz", hash = "sha256:b4636472024c5e2b62278c5b759661efeb52a81851cde5f092f24100b1ecb443", size = 13557, upload-time = "2025-12-08T08:13:09.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/27/e88220fe6274eccd3bdf95d9382918716d312f6f6cef6a46332d1ee2feff/types_s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:1c0cd111ecf6e21437cb410f5cddb631bfb2263b77ad973e79b9c6d0cb24e0ef", size = 19247, upload-time = "2025-12-08T08:13:08.426Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.16.0" version = "4.16.0"
@@ -1036,6 +1134,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" }, { url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" },
] ]
[[package]]
name = "urllib3"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
]
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.53.0" version = "0.53.0"
+2 -2
View File
@@ -3,7 +3,7 @@
# ================== # ==================
# Image pour frontend # Image pour frontend
FROM node:24-alpine3.22 AS builder FROM node:26-alpine3.22 AS builder
WORKDIR /app WORKDIR /app
@@ -23,7 +23,7 @@ RUN npm run build
# ================== # ==================
FROM nginx:1.28-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 --chmod=755 nginx.conf /etc/nginx/nginx.conf
+9 -3
View File
@@ -24,14 +24,14 @@ it('devrait faire X quand Y', () => {
## Ce qui doit être testé en priorité ## Ce qui doit être testé en priorité
- Services (`core/services/`) : logique métier, gestion des erreurs - Services (`core/services/`) : logique métier, gestion des erreurs
- Guards et interceptors (`core/guards/`, `core/interceptors/`) : chaque branche de décision - Guards et interceptors (`core/guards/`, `core/interceptors/`) : chaque branche de décision
- Composants avec logique (formulaires, conditions d'affichage) — pas nécessaire pour - Composants avec logique (formulaires, conditions d'affichage), mais pas nécessaire pour
un composant 100% template, sans logique un composant 100% template, sans logique
`core/services/`, `core/guards/` et `core/interceptors/` n'existent pas encore : c'est `core/services/`, `core/guards/` et `core/interceptors/` n'existent pas encore : c'est
l'arborescence cible, décrite dans l'arborescence cible, décrite dans
[docs/architecture/30-frontend.md](../../docs/architecture/30-frontend.md). [docs/architecture/30-frontend.md](../../docs/architecture/30-frontend.md).
## Gabarit — tester un service avec appel HTTP ## Gabarit · tester un service avec appel HTTP
```typescript ```typescript
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
@@ -61,7 +61,7 @@ describe('MonService', () => {
}); });
``` ```
## Gabarit — tester un composant standalone ## Gabarit · tester un composant standalone
```typescript ```typescript
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { MonComposant } from './mon-composant'; import { MonComposant } from './mon-composant';
@@ -86,3 +86,9 @@ describe('MonComposant', () => {
- Un fichier ou un dossier seulement : - Un fichier ou un dossier seulement :
`npx ng test --watch=false --coverage=false --include=src/app/core/services/alerts.service.spec.ts` `npx ng test --watch=false --coverage=false --include=src/app/core/services/alerts.service.spec.ts`
(répéter `--include` pour plusieurs cibles ; un dossier joue tous ses specs) (répéter `--include` pour plusieurs cibles ; un dossier joue tous ses specs)
## Au-delà des tests unitaires
Les parcours utilisateur complets (connexion, rôles, sites, recommandations, alertes) sont
testés de bout en bout par Playwright, contre l'API et le proxy réels : voir
[tests/e2e/README.md](../../tests/e2e/README.md). Un élément sans rôle ni libellé stable que ces
parcours doivent viser reçoit un `data-testid`.
+94 -173
View File
@@ -8,12 +8,12 @@
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@angular/common": "^22.1.0", "@angular/common": "^22.1.7",
"@angular/compiler": "^22.1.0", "@angular/compiler": "^22.1.7",
"@angular/core": "^22.1.0", "@angular/core": "^22.1.7",
"@angular/forms": "^22.1.0", "@angular/forms": "^22.1.7",
"@angular/platform-browser": "^22.1.0", "@angular/platform-browser": "^22.1.7",
"@angular/router": "^22.1.0", "@angular/router": "^22.1.7",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -21,21 +21,14 @@
"devDependencies": { "devDependencies": {
"@angular/build": "^22.1.8", "@angular/build": "^22.1.8",
"@angular/cli": "^22.1.8", "@angular/cli": "^22.1.8",
"@angular/compiler-cli": "^22.1.0", "@angular/compiler-cli": "^22.1.7",
"@vitest/coverage-v8": "^4.1.11", "@vitest/coverage-v8": "^4.1.11",
"jsdom": "^28.0.0", "jsdom": "^30.1.0",
"prettier": "^3.8.1", "prettier": "^3.9.8",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vitest": "^4.0.8" "vitest": "^4.1.11"
} }
}, },
"node_modules/@acemir/cssom": {
"version": "0.9.31",
"resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
"integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
"dev": true,
"license": "MIT"
},
"node_modules/@ampproject/remapping": { "node_modules/@ampproject/remapping": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
@@ -255,9 +248,9 @@
} }
}, },
"node_modules/@angular/common": { "node_modules/@angular/common": {
"version": "22.1.6", "version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.6.tgz", "resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.7.tgz",
"integrity": "sha512-giuH+jJvo6YbBxbKofJCXvq6k8g1Z/xCAvh4piNFSSS6/toXTLCeZ+snr6Stw5b2wRbArM5Q5nDeuto3NqVPnQ==", "integrity": "sha512-SLW4AdH2Y8G9SM/iFRNpCQtkmvq55RcIQWsADT9ypZ7FxxcnJRsr4Q/WnbUBbuCslsCkrgzU1i9WDiW0m7gcnw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -266,14 +259,14 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0" "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/core": "22.1.6", "@angular/core": "22.1.7",
"rxjs": "^6.5.3 || ^7.4.0" "rxjs": "^6.5.3 || ^7.4.0"
} }
}, },
"node_modules/@angular/compiler": { "node_modules/@angular/compiler": {
"version": "22.1.6", "version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.1.6.tgz", "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.1.7.tgz",
"integrity": "sha512-JjOUm/qD338+fGfZvxSNn/vTUiVqNwOiPzacInVUq1eVp7Jev+cnvEwXA2cFJkYZoy3Imz6wHoMvTUZNN8cbKQ==", "integrity": "sha512-qhoHAByh805nT4Xy4Wd2kE5FvkTc7wcg8Qps+EH6+z4KRfwGiL5cwdgooGWjTAldFCeqTgJ0BO/0dB6GHZjdkQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -283,9 +276,9 @@
} }
}, },
"node_modules/@angular/compiler-cli": { "node_modules/@angular/compiler-cli": {
"version": "22.1.6", "version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.1.6.tgz", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.1.7.tgz",
"integrity": "sha512-C1fQuaSLnibhfbb7Im/vurdBEcfQk+/GqPkY4+dgEKd4EPleO0xWlD+k5DwyNFX8a9w7HebWfo0Zl66HuaEwOQ==", "integrity": "sha512-VGg8Kyt0YymQfnUUFk79vFaM+x5xmIMHy+BZh3P2Lh/uxqCXTWhJJH8kOHKtIGGH5/wKhBEmSy2CezdcIuM9xg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -306,7 +299,7 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0" "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/compiler": "22.1.6", "@angular/compiler": "22.1.7",
"typescript": ">=6.0 <6.1" "typescript": ">=6.0 <6.1"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
@@ -316,9 +309,9 @@
} }
}, },
"node_modules/@angular/core": { "node_modules/@angular/core": {
"version": "22.1.6", "version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/core/-/core-22.1.6.tgz", "resolved": "https://registry.npmjs.org/@angular/core/-/core-22.1.7.tgz",
"integrity": "sha512-3Ln9YYOhsaU2vPufnpcu6C4dlmX4e/nJTlggVcKMT7bGZH5KlEtw3h0uh9YfANv8YhQCEk1AcuRI7KpBnh5ing==", "integrity": "sha512-T9bt1PIgoN7n+TxGHTUgu0EsomlGMQ84YMXbMB+N6HhD8C1qZxn0uCbnAtCUjcecg1am8ioKxNTO0eII9ouioQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -327,7 +320,7 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0" "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/compiler": "22.1.6", "@angular/compiler": "22.1.7",
"rxjs": "^6.5.3 || ^7.4.0", "rxjs": "^6.5.3 || ^7.4.0",
"zone.js": "~0.15.0 || ~0.16.0" "zone.js": "~0.15.0 || ~0.16.0"
}, },
@@ -341,9 +334,9 @@
} }
}, },
"node_modules/@angular/forms": { "node_modules/@angular/forms": {
"version": "22.1.6", "version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.1.6.tgz", "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.1.7.tgz",
"integrity": "sha512-rfV4G4UB4l69yXSRvhaHPzTMIruvBlRO+ak9NtTcYMnsoj8O5ZCyPv0ledGIrLTBwGSI3X1j4nr9aoV+Rj/n+Q==", "integrity": "sha512-oc0DT39C3ZboJpDx8xKUCltgn56LHi0kbv+ThNK/dCSZLgjF2nd+muMMRYF+amdljQp2q7+hm2ORmhON3CNFog==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
@@ -354,16 +347,16 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0" "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/common": "22.1.6", "@angular/common": "22.1.7",
"@angular/core": "22.1.6", "@angular/core": "22.1.7",
"@angular/platform-browser": "22.1.6", "@angular/platform-browser": "22.1.7",
"rxjs": "^6.5.3 || ^7.4.0" "rxjs": "^6.5.3 || ^7.4.0"
} }
}, },
"node_modules/@angular/platform-browser": { "node_modules/@angular/platform-browser": {
"version": "22.1.6", "version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.6.tgz", "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.7.tgz",
"integrity": "sha512-jrRi6zpdz+jOle5l0OW7QL0a8xPgdnxWT5FrJabbiNKfYzQfqKcaAu02l8uJoJxtGb8fLQ8pbjkPpZEvKO2z+w==", "integrity": "sha512-4dT3qGw3ZHVQUcHKALzMx9MTo+pxAMzHIPxc8HUESklqXfq6GRaIIfFMvE0RsquI9iqu6admD6lOAEzLWS8DxA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -372,9 +365,9 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0" "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/animations": "22.1.6", "@angular/animations": "22.1.7",
"@angular/common": "22.1.6", "@angular/common": "22.1.7",
"@angular/core": "22.1.6" "@angular/core": "22.1.7"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@angular/animations": { "@angular/animations": {
@@ -383,9 +376,9 @@
} }
}, },
"node_modules/@angular/router": { "node_modules/@angular/router": {
"version": "22.1.6", "version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/router/-/router-22.1.6.tgz", "resolved": "https://registry.npmjs.org/@angular/router/-/router-22.1.7.tgz",
"integrity": "sha512-ex0vrkcVyJJdn7NzZXun+5ctaPPLTZJ/gE7A3g1dun7BM3g4Z6zT738mODFeJgtPtJNf4bTs2gpgvyegvzWH7w==", "integrity": "sha512-CxYa2Ym5L0WmqToHU2I0Z4VEpocdryLlvx5svLfo9M3uOaP3/9icB/gjS/vXPbbQxV8/GZ59E/7/LF14anIoRg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -394,60 +387,45 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0" "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/common": "22.1.6", "@angular/common": "22.1.7",
"@angular/core": "22.1.6", "@angular/core": "22.1.7",
"@angular/platform-browser": "22.1.6", "@angular/platform-browser": "22.1.7",
"rxjs": "^6.5.3 || ^7.4.0" "rxjs": "^6.5.3 || ^7.4.0"
} }
}, },
"node_modules/@asamuzakjp/css-color": { "node_modules/@asamuzakjp/css-color": {
"version": "5.1.11", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.0.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "integrity": "sha512-IxddtGnnoidVSO6O/aiTueptbTThHvi2qGHu9/AWuD1J7i86Ox+jl+Y5SXcDH1RixzQlP2cjH/PjnlImZiAurQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.3.0",
"@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0" "@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.5.2"
}, },
"engines": { "engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
} }
}, },
"node_modules/@asamuzakjp/dom-selector": { "node_modules/@asamuzakjp/dom-selector": {
"version": "6.8.1", "version": "9.1.4",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.1.4.tgz",
"integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", "integrity": "sha512-ynDE7RDZM1z+YuSU+iGhRp8WcSXHqK9+G32ZEzsL35TKSoy72fYR7VfrJCQkWe3ciiIWGSTAIBc9XB5jVpqyTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.1.0",
"bidi-js": "^1.0.3", "css-tree": "^3.2.1",
"css-tree": "^3.1.0",
"is-potential-custom-element-name": "^1.0.1", "is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.2.6" "lru-cache": "^11.5.2"
}
}, },
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
"engines": { "engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
} }
}, },
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz",
@@ -854,9 +832,9 @@
} }
}, },
"node_modules/@csstools/css-syntax-patches-for-csstree": { "node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.13", "version": "1.1.14",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.13.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
"integrity": "sha512-i9ZylF5QNhmNfPA9l0vHAWK4kPrbIp6g9lKgaiIFsIBz2F/WNB7OLrzlNNcCOm+h42bkaSD2v1PG+IBPHhc3ZA==", "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -879,9 +857,9 @@
} }
}, },
"node_modules/@csstools/css-tokenizer": { "node_modules/@csstools/css-tokenizer": {
"version": "4.0.0", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", "integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -4490,22 +4468,6 @@
"url": "https://github.com/sponsors/fb55" "url": "https://github.com/sponsors/fb55"
} }
}, },
"node_modules/cssstyle": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz",
"integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.0.1",
"@csstools/css-syntax-patches-for-csstree": "^1.0.28",
"css-tree": "^3.1.0",
"lru-cache": "^11.2.6"
},
"engines": {
"node": ">=20"
}
},
"node_modules/data-urls": { "node_modules/data-urls": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@@ -5283,30 +5245,6 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/http-proxy-agent/node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": { "node_modules/https-proxy-agent": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz",
@@ -5529,39 +5467,38 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsdom": { "node_modules/jsdom": {
"version": "28.1.0", "version": "30.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.0.tgz",
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "integrity": "sha512-h/Q+H+KmlGf49ITw3TrykSVbg04S75S2O7ZtJc9gMGOWBrQRZmx5AJIKLtJ01e6Kszl+/tpFthCVqxNifxhoqg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@acemir/cssom": "^0.9.31", "@asamuzakjp/css-color": "^7.0.0",
"@asamuzakjp/dom-selector": "^6.8.1", "@asamuzakjp/dom-selector": "^9.1.2",
"@bramus/specificity": "^2.4.2", "@bramus/specificity": "^2.4.2",
"@exodus/bytes": "^1.11.0", "@csstools/css-syntax-patches-for-csstree": "^1.1.13",
"cssstyle": "^6.0.1", "@exodus/bytes": "^1.15.1",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0", "data-urls": "^7.0.0",
"decimal.js": "^10.6.0", "decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0", "html-encoding-sniffer": "^6.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1", "is-potential-custom-element-name": "^1.0.1",
"parse5": "^8.0.0", "lru-cache": "^11.5.2",
"parse5": "^8.0.1",
"saxes": "^6.0.0", "saxes": "^6.0.0",
"symbol-tree": "^3.2.4", "tough-cookie": "^6.0.2",
"tough-cookie": "^6.0.0", "undici": "^8.10.2",
"undici": "^7.21.0",
"w3c-xmlserializer": "^5.0.0", "w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1", "webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0", "whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0", "whatwg-url": "^17.1.1",
"xml-name-validator": "^5.0.0" "xml-name-validator": "^5.0.0"
}, },
"engines": { "engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"canvas": "^3.0.0" "canvas": "^3.2.3"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"canvas": { "canvas": {
@@ -5569,28 +5506,19 @@
} }
} }
}, },
"node_modules/jsdom/node_modules/agent-base": { "node_modules/jsdom/node_modules/whatwg-url": {
"version": "7.1.4", "version": "17.1.1",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/jsdom/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"agent-base": "^7.1.2", "@exodus/bytes": "^1.15.1",
"debug": "4" "tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
}, },
"engines": { "engines": {
"node": ">= 14" "node": "^22.14.0 || >=24.0.0"
} }
}, },
"node_modules/jsesc": { "node_modules/jsesc": {
@@ -6708,9 +6636,9 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.9.6", "version": "3.9.8",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz",
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@@ -7308,13 +7236,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -7469,13 +7390,13 @@
} }
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "7.29.1", "version": "8.10.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz",
"integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=20.18.1" "node": ">=22.19.0"
} }
}, },
"node_modules/unpipe": { "node_modules/unpipe": {
+10 -10
View File
@@ -12,12 +12,12 @@
"private": true, "private": true,
"packageManager": "npm@11.19.0", "packageManager": "npm@11.19.0",
"dependencies": { "dependencies": {
"@angular/common": "^22.1.0", "@angular/common": "^22.1.7",
"@angular/compiler": "^22.1.0", "@angular/compiler": "^22.1.7",
"@angular/core": "^22.1.0", "@angular/core": "^22.1.7",
"@angular/forms": "^22.1.0", "@angular/forms": "^22.1.7",
"@angular/platform-browser": "^22.1.0", "@angular/platform-browser": "^22.1.7",
"@angular/router": "^22.1.0", "@angular/router": "^22.1.7",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -25,11 +25,11 @@
"devDependencies": { "devDependencies": {
"@angular/build": "^22.1.8", "@angular/build": "^22.1.8",
"@angular/cli": "^22.1.8", "@angular/cli": "^22.1.8",
"@angular/compiler-cli": "^22.1.0", "@angular/compiler-cli": "^22.1.7",
"@vitest/coverage-v8": "^4.1.11", "@vitest/coverage-v8": "^4.1.11",
"jsdom": "^28.0.0", "jsdom": "^30.1.0",
"prettier": "^3.8.1", "prettier": "^3.9.8",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vitest": "^4.0.8" "vitest": "^4.1.11"
} }
} }
-18
View File
@@ -1,18 +0,0 @@
sonar.projectKey=ProjetPiscine_EnerVision
sonar.organization=groupe3-ener-vision
sonar.sourceEncoding=UTF-8
# Dossier contenant le code source
sonar.sources=apps/frontend/src,apps/backend/app
sonar.tests=apps/backend/tests
# Liste des fichiers et dossiers à exclure de l'analyse
# Liste des fichiers et dossiers à exclure de l'analyse
sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js,github,db,ml,docker-compose.yml,**/**/Dockerfile,**/**/proxy.conf.json,**/**/package.json,**/**/angular.json
# Chemin vers le rapport de couverture de code
# Fichier généré par Vitest
# Chemin vers le rapport de couverture de code
# Fichier généré par Vitest
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info
sonar.python.coverage.reportPaths=apps/backend/cov.info
@@ -43,6 +43,31 @@ describe('AuthService', () => {
expect(service.isAuthenticated()).toBe(true); expect(service.isAuthenticated()).toBe(true);
}); });
it('garde le mot de passe provisoire pour un seul changement quand il doit être changé', () => {
service.login({ email: 'a@a.com', password: 'Provisoire' }).subscribe();
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush({
...tokenResponse,
principal: { ...tokenResponse.principal, must_change_password: true },
});
expect(service.takeProvisionalPassword()).toBe('Provisoire');
expect(service.takeProvisionalPassword()).toBeNull();
});
it('ne garde aucun mot de passe quand il est déjà définitif, ni après la fin de session', () => {
service.login({ email: 'a@a.com', password: 'Definitif' }).subscribe();
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse);
expect(service.takeProvisionalPassword()).toBeNull();
service.login({ email: 'a@a.com', password: 'Provisoire' }).subscribe();
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush({
...tokenResponse,
principal: { ...tokenResponse.principal, must_change_password: true },
});
service.clearSession();
expect(service.takeProvisionalPassword()).toBeNull();
});
it('efface la session au logout', () => { it('efface la session au logout', () => {
service.login({ email: 'a@a.com', password: 'secret' }).subscribe(); service.login({ email: 'a@a.com', password: 'secret' }).subscribe();
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse); httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse);
@@ -19,6 +19,9 @@ export class AuthService {
// mémoire. Un rechargement de page le perd, c'est voulu par le contrat. // mémoire. Un rechargement de page le perd, c'est voulu par le contrat.
private accessTokenSignal = signal<string | null>(null); private accessTokenSignal = signal<string | null>(null);
private principalSignal = signal<Principal | null>(null); private principalSignal = signal<Principal | null>(null);
// Pourquoi : redemander le mot de passe provisoire qu'on vient de vérifier laisse un gestionnaire
// de mots de passe y coller un ancien mot de passe du site, et `/auth/password` répond 401.
private provisionalPassword: string | null = null;
readonly principal = this.principalSignal.asReadonly(); readonly principal = this.principalSignal.asReadonly();
readonly isAuthenticated = computed(() => this.principalSignal() !== null); readonly isAuthenticated = computed(() => this.principalSignal() !== null);
@@ -37,12 +40,26 @@ export class AuthService {
clearSession(): void { clearSession(): void {
this.accessTokenSignal.set(null); this.accessTokenSignal.set(null);
this.principalSignal.set(null); this.principalSignal.set(null);
this.provisionalPassword = null;
} }
login(credentials: LoginRequest): Observable<TokenResponse> { login(credentials: LoginRequest): Observable<TokenResponse> {
return this.http return this.http
.post<TokenResponse>(`${environment.apiUrl}/auth/login`, credentials, { withCredentials: true }) .post<TokenResponse>(`${environment.apiUrl}/auth/login`, credentials, { withCredentials: true })
.pipe(tap((response) => this.setSession(response))); .pipe(
tap((response) => {
this.setSession(response);
this.provisionalPassword = response.principal.must_change_password
? credentials.password
: null;
})
);
}
takeProvisionalPassword(): string | null {
const password = this.provisionalPassword;
this.provisionalPassword = null;
return password;
} }
// Un seul rafraîchissement en vol à la fois, partagé entre tous les // Un seul rafraîchissement en vol à la fois, partagé entre tous les
@@ -7,6 +7,9 @@
Votre mot de passe est provisoire, vous devez le modifier avant de continuer Votre mot de passe est provisoire, vous devez le modifier avant de continuer
</p> </p>
<input hidden type="email" autocomplete="username" [value]="email" readonly />
@if (asksCurrentPassword()) {
<label class="form-label" for="current_password">Mot de passe actuel</label> <label class="form-label" for="current_password">Mot de passe actuel</label>
<input <input
id="current_password" id="current_password"
@@ -15,6 +18,7 @@
formControlName="current_password" formControlName="current_password"
autocomplete="current-password" autocomplete="current-password"
/> />
}
<label class="form-label" for="new_password">Nouveau mot de passe</label> <label class="form-label" for="new_password">Nouveau mot de passe</label>
<input <input
@@ -24,7 +28,7 @@
formControlName="new_password" formControlName="new_password"
autocomplete="new-password" autocomplete="new-password"
/> />
<span class="form-hint">{{ passwordHint }}</span> <app-password-requirements [password]="newPassword()" />
@if (errorMessage()) { @if (errorMessage()) {
<ev-alert severity="danger">{{ errorMessage() }}</ev-alert> <ev-alert severity="danger">{{ errorMessage() }}</ev-alert>
@@ -1,17 +1,29 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { signal } from '@angular/core';
import { of, throwError } from 'rxjs'; import { of, throwError } from 'rxjs';
import { vi } from 'vitest'; import { vi } from 'vitest';
import { ChangePassword } from './change-password'; import { ChangePassword } from './change-password';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../core/services/auth.service';
const NOUVEAU = 'Un-nouveau-mot-de-passe1!';
describe('ChangePassword', () => { describe('ChangePassword', () => {
let authMock: { changePassword: ReturnType<typeof vi.fn> }; let authMock: {
changePassword: ReturnType<typeof vi.fn>;
takeProvisionalPassword: ReturnType<typeof vi.fn>;
principal: ReturnType<typeof signal>;
};
let routerMock: { navigate: ReturnType<typeof vi.fn> }; let routerMock: { navigate: ReturnType<typeof vi.fn> };
beforeEach(async () => { beforeEach(async () => {
authMock = { changePassword: vi.fn() }; authMock = {
changePassword: vi.fn(),
takeProvisionalPassword: vi.fn().mockReturnValue(null),
principal: signal({ email: 'johan@enervision.fr' }),
};
routerMock = { navigate: vi.fn() }; routerMock = { navigate: vi.fn() };
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
@@ -23,6 +35,10 @@ describe('ChangePassword', () => {
}).compileComponents(); }).compileComponents();
}); });
function champActuel(fixture: { nativeElement: HTMLElement }): HTMLInputElement | null {
return fixture.nativeElement.querySelector('#current_password');
}
it('ne soumet pas si le formulaire est invalide (mot de passe trop court)', () => { it('ne soumet pas si le formulaire est invalide (mot de passe trop court)', () => {
const fixture = TestBed.createComponent(ChangePassword); const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance; const component = fixture.componentInstance;
@@ -44,7 +60,7 @@ describe('ChangePassword', () => {
it('redirige vers /dashboard après un changement réussi', () => { it('redirige vers /dashboard après un changement réussi', () => {
const fixture = TestBed.createComponent(ChangePassword); const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance; const component = fixture.componentInstance;
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' }); component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU });
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } })); authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
@@ -52,19 +68,68 @@ describe('ChangePassword', () => {
expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']); expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']);
}); });
it("affiche un message d'erreur si le mot de passe actuel est incorrect", () => { it("demande le mot de passe actuel quand la connexion ne l'a pas transmis (page rechargée)", () => {
const fixture = TestBed.createComponent(ChangePassword);
fixture.detectChanges();
expect(champActuel(fixture)).not.toBeNull();
});
it('réutilise le mot de passe provisoire de la connexion sans le redemander', () => {
authMock.takeProvisionalPassword.mockReturnValue('Provisoire-24-caracteres');
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
const fixture = TestBed.createComponent(ChangePassword); const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance; const component = fixture.componentInstance;
component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' }); fixture.detectChanges();
authMock.changePassword.mockReturnValue(throwError(() => new Error('401'))); expect(champActuel(fixture)).toBeNull();
component.form.controls.new_password.setValue(NOUVEAU);
component.onSubmit();
expect(authMock.changePassword).toHaveBeenCalledWith({
current_password: 'Provisoire-24-caracteres',
new_password: NOUVEAU,
});
});
it('associe le formulaire au compte connecté pour les gestionnaires de mots de passe', () => {
const fixture = TestBed.createComponent(ChangePassword);
fixture.detectChanges();
const identifiant = fixture.nativeElement.querySelector('input[autocomplete="username"]');
expect(identifiant.value).toBe('johan@enervision.fr');
});
it('sur un 401, dit que le mot de passe actuel est faux et le redemande', () => {
authMock.takeProvisionalPassword.mockReturnValue('Provisoire-perime');
authMock.changePassword.mockReturnValue(
throwError(() => new HttpErrorResponse({ status: 401 })),
);
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.controls.new_password.setValue(NOUVEAU);
component.onSubmit(); component.onSubmit();
fixture.detectChanges(); // rend le bloc @if (errorMessage()) fixture.detectChanges();
expect(component.errorMessage()).toContain('incorrect'); expect(component.errorMessage()).toContain('Mot de passe actuel incorrect');
const errorEl = fixture.nativeElement.querySelector('.ev-alert'); expect(fixture.nativeElement.querySelector('.ev-alert')?.textContent).toContain('incorrect');
expect(errorEl?.textContent).toContain('incorrect'); expect(champActuel(fixture)).not.toBeNull();
expect(component.form.controls.current_password.value).toBe('');
});
it('sur un 422, dit que le nouveau mot de passe ne respecte pas la politique', () => {
authMock.changePassword.mockReturnValue(
throwError(() => new HttpErrorResponse({ status: 422 })),
);
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU });
component.onSubmit();
expect(component.errorMessage()).toContain('Nouveau mot de passe refusé');
expect(component.form.controls.current_password.value).toBe('ancien-mot-de-passe');
}); });
it('désactive le bouton tant que le formulaire est invalide', () => { it('désactive le bouton tant que le formulaire est invalide', () => {
@@ -79,7 +144,7 @@ describe('ChangePassword', () => {
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
const fixture = TestBed.createComponent(ChangePassword); const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance; const component = fixture.componentInstance;
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' }); component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU });
fixture.detectChanges(); fixture.detectChanges();
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } })); authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
@@ -90,8 +155,7 @@ describe('ChangePassword', () => {
expect(authMock.changePassword).toHaveBeenCalledWith({ expect(authMock.changePassword).toHaveBeenCalledWith({
current_password: 'ancien-mot-de-passe', current_password: 'ancien-mot-de-passe',
new_password: 'Un-nouveau-mot-de-passe1!', new_password: NOUVEAU,
}); });
}); });
}); });
@@ -1,17 +1,20 @@
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../core/services/auth.service';
import { Button } from '../../../shared/components/ui/button/button'; import { Button } from '../../../shared/components/ui/button/button';
import { Card } from '../../../shared/components/ui/card/card'; import { Card } from '../../../shared/components/ui/card/card';
import { Alert } from '../../../shared/components/ui/alert/alert'; import { Alert } from '../../../shared/components/ui/alert/alert';
import { Brand } from '../../../shared/components/ui/brand/brand'; import { Brand } from '../../../shared/components/ui/brand/brand';
import { PasswordRequirementsChecklist } from '../../../shared/components/password-requirements/password-requirements';
import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator'; import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator';
@Component({ @Component({
selector: 'app-change-password', selector: 'app-change-password',
standalone: true, standalone: true,
imports: [ReactiveFormsModule, Button, Card, Alert, Brand], imports: [ReactiveFormsModule, Button, Card, Alert, Brand, PasswordRequirementsChecklist],
templateUrl: './change-password.html', templateUrl: './change-password.html',
styleUrl: './change-password.scss', styleUrl: './change-password.scss',
}) })
@@ -20,30 +23,47 @@ export class ChangePassword {
private auth = inject(AuthService); private auth = inject(AuthService);
private router = inject(Router); private router = inject(Router);
private provisionalPassword = this.auth.takeProvisionalPassword();
errorMessage = signal<string | null>(null); errorMessage = signal<string | null>(null);
isLoading = signal(false); isLoading = signal(false);
passwordHint = PASSWORD_HINT; asksCurrentPassword = signal(this.provisionalPassword === null);
email = this.auth.principal()?.email ?? '';
form = this.fb.nonNullable.group({ form = this.fb.nonNullable.group({
current_password: ['', Validators.required], current_password: [this.provisionalPassword ?? '', Validators.required],
new_password: ['', passwordValidators], new_password: ['', passwordValidators],
}); });
newPassword = toSignal(this.form.controls.new_password.valueChanges, { initialValue: '' });
onSubmit(): void { onSubmit(): void {
if (this.form.invalid) return; if (this.form.invalid) return;
this.isLoading.set(true); this.isLoading.set(true);
this.errorMessage.set(null); this.errorMessage.set(null);
this.auth.changePassword(this.form.getRawValue()).subscribe({ this.auth.changePassword(this.form.getRawValue()).subscribe({
next: (response) => { next: () => {
this.router.navigate(['/dashboard']); this.router.navigate(['/dashboard']);
}, },
error: () => { error: (error: HttpErrorResponse) => {
this.isLoading.set(false); this.isLoading.set(false);
this.errorMessage.set( this.errorMessage.set(this.explique(error));
`Mot de passe actuel incorrect, ou nouveau mot de passe invalide (${this.passwordHint}).`, if (error.status === 401) {
); this.form.controls.current_password.reset('');
this.asksCurrentPassword.set(true);
}
}, },
}); });
} }
private explique(error: HttpErrorResponse): string {
if (error.status === 401) {
return 'Mot de passe actuel incorrect : saisissez le mot de passe provisoire qui vous a été transmis.';
}
if (error.status === 422) {
return `Nouveau mot de passe refusé (${PASSWORD_HINT}).`;
}
return 'Le changement de mot de passe a échoué, réessayez dans un instant.';
}
} }
@@ -20,7 +20,7 @@
@if (data(); as d) { @if (data(); as d) {
<div class="sites-grid"> <div class="sites-grid">
@for (site of d.sites; track site.site_id) { @for (site of d.sites; track site.site_id) {
<ev-card class="site-card"> <ev-card class="site-card" data-testid="site-card">
<div class="site-card__header"> <div class="site-card__header">
<span class="site-card__name">{{ site.site_name }}</span> <span class="site-card__name">{{ site.site_name }}</span>
<ev-badge [tone]="badgeToneForOverall(site.overall)">{{ site.overall }}</ev-badge> <ev-badge [tone]="badgeToneForOverall(site.overall)">{{ site.overall }}</ev-badge>
+6 -1
View File
@@ -5,7 +5,12 @@ PostgreSQL 17 avec l'extension TimescaleDB, servie en local par le service `db`
- `init` : scripts de bootstrap joues au premier demarrage du conteneur. - `init` : scripts de bootstrap joues au premier demarrage du conteneur.
- `migrations` : migrations SQL versionnees. - `migrations` : migrations SQL versionnees.
- `seeds` : jeux de donnees de reference. - `seeds` : jeux de donnees de reference. `demo.sql` seme trois sites `demo-*`, 72 heures de
releves, des alertes et des rapports de derive pour la CI, l'e2e et les tirs de charge. Base
jetable seulement.
- `roles` : roles PostgreSQL hors schema applicatif. `supervision.sql` pose le role en lecture
seule de Grafana et de postgres-exporter, rejoue par `make db-ensure-supervision` (et par
`make stack-up` quand la supervision est active) plutot que par `init`, qui ne rejoue jamais.
Les migrations du schema applicatif expose par l'API vivent dans Les migrations du schema applicatif expose par l'API vivent dans
`apps/backend/alembic`, pas ici. `apps/backend/alembic`, pas ici.
+1 -1
View File
@@ -1,4 +1,4 @@
-- Base de metadonnees Airflow (webserver + scheduler, LocalExecutor). Separee de la base -- Base de metadonnees Airflow (api-server + scheduler + dag-processor, LocalExecutor). Separee de la base
-- applicative : les tables internes d'Airflow (dag_run, task_instance, ...) n'ont rien a faire -- applicative : les tables internes d'Airflow (dag_run, task_instance, ...) n'ont rien a faire
-- dans le schema metier. Meme conteneur Postgres que `enervision`/`enervision_test` plutot qu'un -- dans le schema metier. Meme conteneur Postgres que `enervision`/`enervision_test` plutot qu'un
-- service dedie, pour ne pas ajouter un conteneur de plus (issue #115). -- service dedie, pour ne pas ajouter un conteneur de plus (issue #115).
+15
View File
@@ -0,0 +1,15 @@
-- Contrainte : rôle en lecture seule de la supervision (Grafana, postgres-exporter) -
-- supervision.sql. Ses droits ne portent que sur les tables métier : jamais `app_user`, les
-- jetons ni le journal d'audit. `pg_monitor` donne à l'exportateur les vues de statistiques.
-- Rejoué par `make db-ensure-supervision`, qui passe `mot_de_passe` et `base` en variables psql :
-- crée le rôle au besoin, puis réaligne à chaque passage mot de passe et droits.
-- Piège : les tables doivent exister, d'où l'appel après `alembic upgrade head` dans `stack-up`.
SELECT 'CREATE ROLE supervision LOGIN'
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supervision') \gexec
ALTER ROLE supervision WITH LOGIN PASSWORD :'mot_de_passe';
GRANT pg_monitor TO supervision;
GRANT CONNECT ON DATABASE :"base" TO supervision;
GRANT USAGE ON SCHEMA public TO supervision;
GRANT SELECT ON site, reading, alert, prediction, recommendation, drift_report TO supervision;
+101
View File
@@ -0,0 +1,101 @@
-- Contrainte : jeu de démonstration pour une base JETABLE (CI, e2e, charge, DAST) - demo.sql.
-- Rejouable : identifiants fixes et `ON CONFLICT DO NOTHING`, ou `NOT EXISTS` là où aucune
-- contrainte d'unicité ne protège la table.
-- Pourquoi : les horodatages suivent `now()`. La fenêtre par défaut de `GET /readings` couvre les
-- 24 dernières heures, et un jeu figé dans le passé laisserait le tableau de bord vide.
-- Piège : `demo-ecole` finit sur une lecture `partial` sans humidité, pour que la supervision des
-- capteurs montre un site dégradé ; au-delà de dix alertes, le fil affiche « Afficher plus ».
BEGIN;
INSERT INTO site (site_id, site_name, site_type, location, capacity_kw, status)
VALUES
('demo-siege', 'Siège Part-Dieu', 'office', 'Lyon', 450, 'actif'),
('demo-usine', 'Usine de Vénissieux', 'factory', 'Vénissieux', 900, 'actif'),
('demo-ecole', 'Groupe scolaire Gratte-Ciel', 'school', 'Villeurbanne', 250, 'maintenance')
ON CONFLICT (site_id) DO NOTHING;
WITH profil (site_id, base_kw, amplitude_kw) AS (
VALUES ('demo-siege', 180.0, 120.0), ('demo-usine', 520.0, 260.0), ('demo-ecole', 70.0, 60.0)
),
heures AS (
SELECT date_trunc('hour', now()) - make_interval(hours => n) AS horodatage, n
FROM generate_series(0, 71) AS n
),
lectures AS (
SELECT
p.site_id,
h.horodatage,
h.n,
round((p.base_kw + p.amplitude_kw * greatest(0, sin(pi() * (extract(hour FROM h.horodatage) - 6) / 14)))::numeric, 2)::double precision AS kw,
extract(isodow FROM h.horodatage) < 6 AND extract(hour FROM h.horodatage) BETWEEN 8 AND 18 AS ouvre
FROM profil AS p
CROSS JOIN heures AS h
)
INSERT INTO reading (
site_id, timestamp, source, consumption_kw, consumption_kwh, consumption_euros,
voltage_v, current_a, power_factor, temperature_celsius, humidity_percent,
is_working_hours, data_quality, null_reasons, raw_data
)
SELECT
site_id,
horodatage,
'api_current',
kw,
kw,
round((kw * 0.19)::numeric, 2),
230.0,
round((kw * 1000 / (230.0 * 3 * 0.95))::numeric, 1)::double precision,
0.95,
19.5 + 3 * sin(pi() * extract(hour FROM horodatage) / 12),
CASE WHEN site_id = 'demo-ecole' AND n = 0 THEN NULL ELSE 45.0 END,
ouvre,
CASE WHEN site_id = 'demo-ecole' AND n = 0 THEN 'partial' ELSE 'good' END,
CASE WHEN site_id = 'demo-ecole' AND n = 0 THEN ARRAY['humidity_sensor_failure'] END,
'{}'::jsonb
FROM lectures
ON CONFLICT DO NOTHING;
INSERT INTO prediction (site_id, target_at, target_metric, period_minutes, predicted_value, model_reference, status)
SELECT s.site_id, date_trunc('hour', now()) + interval '1 hour', 'consumption_kwh', 60, s.valeur, 'demo-seed', 'available'
FROM (VALUES ('demo-siege', 214.0), ('demo-usine', 610.5), ('demo-ecole', 88.2)) AS s (site_id, valeur)
WHERE NOT EXISTS (
SELECT 1 FROM prediction AS p
WHERE p.site_id = s.site_id
AND p.model_reference = 'demo-seed'
AND p.target_at = date_trunc('hour', now()) + interval '1 hour'
);
INSERT INTO alert (source_alert_id, site_id, source, timestamp, type, severity, message, value, threshold, metric, raw_data)
SELECT a.id, a.site_id, 'enervision', now() - make_interval(hours => a.age_h), a.type, a.severite, a.message, a.valeur, a.seuil, a.metrique, '{}'::jsonb
FROM (
VALUES
('demo-01', 'demo-siege', 1, 'spike', 'high', 'Pic de consommation à 312 kW', 312.0, 250.0, 'consumption_kw'),
('demo-02', 'demo-siege', 3, 'threshold', 'medium', 'Seuil de 80 % de la capacité franchi', 372.0, 360.0, 'consumption_kw'),
('demo-03', 'demo-siege', 6, 'anomaly', 'low', 'Consommation nocturne inhabituelle', 205.0, NULL, 'consumption_kw'),
('demo-04', 'demo-siege', 9, 'sensor', 'medium', 'Capteur de température muet', NULL, NULL, 'temperature_celsius'),
('demo-05', 'demo-siege', 20, 'outage', 'critical', 'Coupure de courant de 2 heures', 0.0, NULL, 'consumption_kw'),
('demo-06', 'demo-usine', 2, 'spike', 'critical', 'Pic de consommation à 1 020 kW', 1020.0, 850.0, 'consumption_kw'),
('demo-07', 'demo-usine', 4, 'threshold', 'high', 'Seuil de 90 % de la capacité franchi', 830.0, 810.0, 'consumption_kw'),
('demo-08', 'demo-usine', 8, 'anomaly', 'medium', 'Facteur de puissance dégradé', 0.71, 0.85, 'power_factor'),
('demo-09', 'demo-usine', 14, 'outage', 'high', 'Perte de mesure sur la ligne principale', NULL, NULL, 'consumption_kw'),
('demo-10', 'demo-usine', 30, 'sensor', 'low', 'Capteur électrique intermittent', NULL, NULL, 'voltage_v'),
('demo-11', 'demo-ecole', 1, 'sensor', 'high', 'Capteur d''humidité muet', NULL, NULL, 'humidity_percent'),
('demo-12', 'demo-ecole', 5, 'anomaly', 'low', 'Chauffage actif hors des heures d''ouverture', 96.0, NULL, 'consumption_kw'),
('demo-13', 'demo-ecole', 12, 'threshold', 'medium', 'Seuil de 60 % de la capacité franchi', 158.0, 150.0, 'consumption_kw'),
('demo-14', 'demo-ecole', 40, 'spike', 'medium', 'Pic de consommation à 190 kW', 190.0, 160.0, 'consumption_kw')
) AS a (id, site_id, age_h, type, severite, message, valeur, seuil, metrique)
ON CONFLICT DO NOTHING;
INSERT INTO drift_report (window_start, window_end, n_observations, mae, mape, bias, reference_mae, coverage_ratio, insufficient_data_ratio, model_references, status, reason, site_id)
SELECT date_trunc('day', now()) - interval '7 days', date_trunc('day', now()), d.n, d.mae, d.mape, d.biais, 11.0, 0.98, 0.0, ARRAY['demo-seed'], d.statut, d.raison, d.site_id
FROM (
VALUES
('demo-siege', 168, 9.4, 0.052, -1.2, 'stable', NULL),
('demo-usine', 168, 31.8, 0.061, 14.5, 'derive', 'MAE supérieure à 1,5 fois la référence'),
('demo-ecole', 120, 6.1, 0.083, 0.4, 'stable', NULL),
(NULL, 456, 16.9, 0.064, 5.1, 'stable', NULL)
) AS d (site_id, n, mae, mape, biais, statut, raison)
ON CONFLICT DO NOTHING;
COMMIT;
+6 -6
View File
@@ -5,8 +5,8 @@
# moyen de dépublier 8000 et 3000 : sans lui, l'API resterait joignable en clair à côté du proxy. # moyen de dépublier 8000 et 3000 : sans lui, l'API resterait joignable en clair à côté du proxy.
# Piège : pas de `:?` sur `PUBLIC_HOST`. Compose interpole tout le fichier, y compris pour # Piège : pas de `:?` sur `PUBLIC_HOST`. Compose interpole tout le fichier, y compris pour
# `stop` et `logs` : la garde vit dans `make stack-up`, qui la compare au certificat servi. # `stop` et `logs` : la garde vit dans `make stack-up`, qui la compare au certificat servi.
# Pourquoi : ports du proxy et origine publique en variables, pour que deux environnements # Pourquoi : ports du proxy et origine publique en variables, pour que trois environnements
# cohabitent sur la même machine, chacun dans son projet Compose (ADR 0009). # cohabitent sur la même machine, chacun dans son projet Compose (ADR 0009, 0017).
name: enervision name: enervision
@@ -22,11 +22,9 @@ services:
ports: !override ports: !override
- "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025" - "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025"
airflow-webserver: airflow-apiserver:
ports: !override ports: !override
- "127.0.0.1:${AIRFLOW_PORT:-8080}:8080" - "127.0.0.1:${AIRFLOW_PORT:-8080}:8080"
environment:
AIRFLOW__WEBSERVER__WORKERS: ${AIRFLOW_WEBSERVER_WORKERS:-2}
backend: backend:
ports: !reset null ports: !reset null
@@ -51,7 +49,7 @@ services:
ports: !reset null ports: !reset null
proxy: proxy:
image: nginx:1.28-alpine image: nginx:1.31-alpine
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
@@ -60,6 +58,8 @@ services:
ports: ports:
- "${PROXY_HTTP_PORT:-80}:80" - "${PROXY_HTTP_PORT:-80}:80"
- "${PROXY_HTTPS_PORT:-443}:443" - "${PROXY_HTTPS_PORT:-443}:443"
# Vide : port aléatoire sur la boucle locale, pour que deux stacks sans frontal cohabitent.
- "${PROXY_FRONT_PORT:-127.0.0.1:}:4443"
volumes: volumes:
- ./infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro - ./infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro
- ./infra/proxy/conf.d:/etc/nginx/conf.d:ro - ./infra/proxy/conf.d:/etc/nginx/conf.d:ro
+221 -17
View File
@@ -5,9 +5,9 @@
name: enervision name: enervision
# Piege : LocalExecutor fait tourner les taches comme sous-processus du scheduler, jamais du # Piege : LocalExecutor fait tourner les taches comme sous-processus du scheduler, jamais de
# webserver. `airflow_ml_state` (modele entraine, magasin MLflow) n'a donc besoin d'etre monte # l'api-server. `airflow_ml_state` (modele entraine, magasin MLflow) n'a donc besoin d'etre monte
# que sur `airflow-scheduler` en pratique, mais reste partage avec le webserver pour que ce # que sur `airflow-scheduler` en pratique, mais reste partage avec l'api-server pour que ce
# dernier puisse au besoin l'inspecter sans en devenir dependant. # dernier puisse au besoin l'inspecter sans en devenir dependant.
x-airflow-common: &airflow-common x-airflow-common: &airflow-common
build: build:
@@ -19,20 +19,29 @@ x-airflow-common: &airflow-common
# Piege : pas de `:?` sur les secrets Airflow. Compose interpole le fichier entier avant de # Piege : pas de `:?` sur les secrets Airflow. Compose interpole le fichier entier avant de
# filtrer les services : une variable requise manquante casserait aussi `make db-up`, # filtrer les services : une variable requise manquante casserait aussi `make db-up`,
# `make dev`... pour quiconque n'a pas encore complete son `.env`. Le refus est porte par # `make dev`... pour quiconque n'a pas encore complete son `.env`. Le refus est porte par
# `airflow-init` (ci-dessous), dont `webserver` et `scheduler` dependent. # `airflow-init` (ci-dessous), dont `api-server`, `dag-processor` et `scheduler` dependent.
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-} AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-}
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_WEBSERVER_SECRET_KEY:-} AIRFLOW__API__SECRET_KEY: ${AIRFLOW_API_SECRET_KEY:-}
# Signe les jetons entre scheduler, tâches et api-server. Conteneurs distincts : un secret
# généré au démarrage ne serait pas partagé, il doit venir du .env.
AIRFLOW__API_AUTH__JWT_SECRET: ${AIRFLOW_JWT_SECRET:-}
AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-apiserver:8080/execution/
# FabAuthManager plutôt que le SimpleAuthManager par défaut d'Airflow 3 : seul le provider
# FAB sait créer le compte admin que `airflow-init` pose via `_AIRFLOW_WWW_USER_*`.
AIRFLOW__CORE__AUTH_MANAGER: airflow.providers.fab.auth_manager.fab_auth_manager.FabAuthManager
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/airflow AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/airflow
# Role `enervision_ml` dedie pas encore provisionne (dette assumee, cf. ADR 0003) : # Role `enervision_ml` dedie pas encore provisionne (dette assumee, cf. ADR 0003) :
# 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
@@ -73,6 +82,37 @@ services:
- "${MAILPIT_UI_PORT:-8025}:8025" - "${MAILPIT_UI_PORT:-8025}:8025"
restart: unless-stopped restart: unless-stopped
# Piège : image `FROM scratch`, sans shell : healthcheck en forme exec, et aucune garde shell sur
# les secrets. Un GARAGE_RPC_SECRET vide ou non hexadécimal fait échouer Garage lui-même, message
# explicite dans ses journaux ; `make services-up` et `make stack-up` vérifient le .env avant.
# Piège : GARAGE_SECRET_KEY ne se change pas sur un volume `garage_meta` déjà peuplé, Garage
# refuse alors de démarrer. Rotation par `garage key` ou par recréation du volume (ADR 0019).
garage:
image: dxflrs/garage:v2.4.1
command: ["/garage", "server", "--single-node", "--default-bucket"]
environment:
GARAGE_RPC_SECRET: ${GARAGE_RPC_SECRET:-}
GARAGE_ADMIN_TOKEN: ${GARAGE_ADMIN_TOKEN:-}
GARAGE_METRICS_TOKEN: ${GARAGE_METRICS_TOKEN:-}
GARAGE_DEFAULT_ACCESS_KEY: ${GARAGE_ACCESS_KEY:-}
GARAGE_DEFAULT_SECRET_KEY: ${GARAGE_SECRET_KEY:-}
GARAGE_DEFAULT_BUCKET: ${GARAGE_BUCKET:-enervision-archives}
volumes:
- ./infra/garage/garage.toml:/etc/garage.toml:ro
- garage_meta:/var/lib/garage/meta
- garage_data:/var/lib/garage/data
ports:
- "127.0.0.1:${GARAGE_S3_PORT:-3900}:3900"
- "127.0.0.1:${GARAGE_ADMIN_PORT:-3903}:3903"
healthcheck:
test: ["CMD", "/garage", "health", "-q"]
interval: 15s
timeout: 5s
retries: 6
start_period: 20s
mem_limit: 256m
restart: unless-stopped
backend: backend:
build: ./apps/backend build: ./apps/backend
depends_on: depends_on:
@@ -98,6 +138,7 @@ services:
APP_SMTP_PORT: "1025" APP_SMTP_PORT: "1025"
APP_SMTP_USE_TLS: "false" APP_SMTP_USE_TLS: "false"
APP_SMTP_FROM_ADDRESS: ${APP_SMTP_FROM_ADDRESS:-no-reply@enervision.fr} APP_SMTP_FROM_ADDRESS: ${APP_SMTP_FROM_ADDRESS:-no-reply@enervision.fr}
APP_METRICS_TOKEN: ${APP_METRICS_TOKEN:-}
ports: ports:
- "${BACKEND_PORT:-8000}:8000" - "${BACKEND_PORT:-8000}:8000"
restart: unless-stopped restart: unless-stopped
@@ -111,11 +152,11 @@ services:
# Conteneur unique, jamais redemarre. La migration et la creation du premier compte sont # Conteneur unique, jamais redemarre. La migration et la creation du premier compte sont
# portees par l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, `_AIRFLOW_WWW_USER_*`), qui porte # portees par l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, `_AIRFLOW_WWW_USER_*`), qui porte
# aussi leur code de sortie : une migration ratee (ex. base `airflow` absente sur un volume # aussi leur code de sortie : une migration ratee (ex. base `airflow` absente sur un volume
# `pgdata` deja peuple) fait echouer ce service, et `webserver`/`scheduler`, qui attendent son # `pgdata` deja peuple) fait echouer ce service, et api-server, dag-processor et scheduler,
# succes, ne demarrent pas sur une base non migree. Le mot de passe passe par l'environnement, # qui attendent son succes, ne demarrent pas sur une base non migree. Le mot de passe passe
# jamais par `argv` (ni `ps`, ni `docker compose config`). # par l'environnement, jamais par `argv` (ni `ps`, ni `docker compose config`).
# Sans mot de passe, l'entrypoint refuse lui-meme de creer le compte ; la commande ci-dessous # Sans mot de passe, l'entrypoint refuse lui-meme de creer le compte ; la commande ci-dessous
# refuse en plus les deux cles de chiffrement vides. # refuse en plus les cles et secrets vides.
airflow-init: airflow-init:
<<: *airflow-common <<: *airflow-common
restart: "no" restart: "no"
@@ -135,13 +176,14 @@ services:
- | - |
set -euo pipefail set -euo pipefail
: "$${AIRFLOW__CORE__FERNET_KEY:?AIRFLOW_FERNET_KEY manquant dans .env}" : "$${AIRFLOW__CORE__FERNET_KEY:?AIRFLOW_FERNET_KEY manquant dans .env}"
: "$${AIRFLOW__WEBSERVER__SECRET_KEY:?AIRFLOW_WEBSERVER_SECRET_KEY manquant dans .env}" : "$${AIRFLOW__API__SECRET_KEY:?AIRFLOW_API_SECRET_KEY manquant dans .env}"
: "$${AIRFLOW__API_AUTH__JWT_SECRET:?AIRFLOW_JWT_SECRET manquant dans .env}"
: "$${APP_SECRET_KEY:?AIRFLOW_APP_SECRET_KEY manquant dans .env}" : "$${APP_SECRET_KEY:?AIRFLOW_APP_SECRET_KEY manquant dans .env}"
exec airflow version exec airflow version
airflow-webserver: airflow-apiserver:
<<: *airflow-common <<: *airflow-common
command: webserver command: api-server
ports: ports:
- "${AIRFLOW_PORT:-8080}:8080" - "${AIRFLOW_PORT:-8080}:8080"
depends_on: depends_on:
@@ -150,7 +192,7 @@ services:
airflow-init: airflow-init:
condition: service_completed_successfully condition: service_completed_successfully
healthcheck: healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"] test: ["CMD", "curl", "--fail", "http://localhost:8080/api/v2/monitor/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 5
@@ -159,13 +201,175 @@ services:
airflow-scheduler: airflow-scheduler:
<<: *airflow-common <<: *airflow-common
command: scheduler command: scheduler
environment:
<<: *airflow-common-env
# LocalExecutor exécute les tâches dans le scheduler : lui seul a besoin des
# identifiants de l'API Mock.
APP_MOCK_API_BASE_URL: ${APP_MOCK_API_BASE_URL:-https://api-mock.charlieandre.fr}
APP_MOCK_API_USERNAME: ${APP_MOCK_API_USERNAME:-}
APP_MOCK_API_PASSWORD: ${APP_MOCK_API_PASSWORD:-}
APP_MOCK_API_TIMEOUT_SECONDS: ${APP_MOCK_API_TIMEOUT_SECONDS:-10}
# Le DAG `retention` archive les chunks de `reading` sur le Garage du projet (ADR 0019),
# chiffrés par la clé SSE-C du .env (ADR 0020). Vides, `app.etl.reading_retention` refuse seul.
APP_S3_ENDPOINT_URL: http://garage:3900
APP_S3_REGION: garage
APP_S3_ACCESS_KEY: ${GARAGE_ACCESS_KEY:-}
APP_S3_SECRET_KEY: ${GARAGE_SECRET_KEY:-}
APP_S3_BUCKET: ${GARAGE_BUCKET:-enervision-archives}
APP_S3_SSE_KEY: ${GARAGE_SSE_KEY:-}
APP_READING_RETENTION_DAYS: ${READING_RETENTION_DAYS:-1095}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
airflow-init: airflow-init:
condition: service_completed_successfully condition: service_completed_successfully
# Obligatoire depuis Airflow 3 : le scheduler ne parse plus les fichiers de dags/ lui-même.
airflow-dag-processor:
<<: *airflow-common
command: dag-processor
depends_on:
db:
condition: service_healthy
airflow-init:
condition: service_completed_successfully
# Profil `monitoring` : actif en prod par COMPOSE_PROFILES, à la demande ailleurs (ADR 0016).
# Aucun `depends_on` : `make monitoring-up` démarre en `--no-deps`, sans jamais recréer `db`.
prometheus:
image: prom/prometheus:v3.14.0
profiles: ["monitoring"]
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=15d
- --storage.tsdb.retention.size=1GB
volumes:
- ./monitoring/prometheus:/etc/prometheus:ro
- prometheus_data:/prometheus
secrets:
- metrics_token
- garage_metrics_token
ports:
- "127.0.0.1:${PROMETHEUS_PORT:-9090}:9090"
mem_limit: 512m
restart: unless-stopped
alertmanager:
image: prom/alertmanager:v0.34.1
profiles: ["monitoring"]
command:
- --config.file=/etc/alertmanager/alertmanager.yml
- --storage.path=/alertmanager
volumes:
- ./monitoring/alertmanager:/etc/alertmanager:ro
- alertmanager_data:/alertmanager
ports:
- "127.0.0.1:${ALERTMANAGER_PORT:-9093}:9093"
mem_limit: 64m
restart: unless-stopped
# Sans mot de passe, Grafana créerait un compte admin/admin : le conteneur refuse de démarrer.
grafana:
image: grafana/grafana:13.2.2
profiles: ["monitoring"]
entrypoint:
- /bin/sh
- -c
- ': "$${GF_SECURITY_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD manquant dans .env}" && exec /run.sh'
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-}
GF_USERS_ALLOW_SIGN_UP: "false"
GF_AUTH_ANONYMOUS_ENABLED: "false"
GF_ANALYTICS_REPORTING_ENABLED: "false"
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES: "false"
GF_NEWS_NEWS_FEED_ENABLED: "false"
GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH: /etc/grafana/dashboards/api.json
POSTGRES_DB: ${POSTGRES_DB:-enervision}
SUPERVISION_DB_PASSWORD: ${SUPERVISION_DB_PASSWORD:-}
volumes:
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
- ./monitoring/grafana/dashboards:/etc/grafana/dashboards:ro
- grafana_data:/var/lib/grafana
ports:
- "127.0.0.1:${GRAFANA_PORT:-3001}:3000"
mem_limit: 256m
restart: unless-stopped
postgres-exporter:
image: prometheuscommunity/postgres-exporter:v0.20.1
profiles: ["monitoring"]
environment:
DATA_SOURCE_URI: db:5432/${POSTGRES_DB:-enervision}?sslmode=disable
DATA_SOURCE_USER: supervision
DATA_SOURCE_PASS: ${SUPERVISION_DB_PASSWORD:-}
mem_limit: 64m
restart: unless-stopped
node-exporter:
image: prom/node-exporter:v1.12.1
profiles: ["monitoring"]
command:
- --path.rootfs=/host
pid: host
volumes:
- /:/host:ro,rslave
mem_limit: 64m
restart: unless-stopped
# Contrainte : cAdvisor lit les cgroups de tous les conteneurs de l'hôte, d'où `privileged` et
# ses montages en lecture seule. Aucun port publié : seul Prometheus le joint.
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.55.1
profiles: ["monitoring"]
privileged: true
devices:
- /dev/kmsg
command:
- --docker_only=true
- --housekeeping_interval=30s
- --store_container_labels=false
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
mem_limit: 160m
restart: unless-stopped
# Pourquoi : sur le réseau du projet, k6 joint `backend:8000` sans passer par nginx, dont la
# limite par adresse (20 req/s) fausserait la mesure de l'API. `make load-*` le lance (ADR 0015).
k6:
image: grafana/k6:2.3.0
profiles: ["load"]
volumes:
- ./tests/load:/scripts:ro
- ./tests/load/results:/results
environment:
K6_BASE_URL: ${K6_BASE_URL:-http://backend:8000}
K6_PROXY_URL: ${K6_PROXY_URL:-https://proxy}
K6_EMAIL: ${K6_EMAIL:-}
K6_PASSWORD: ${K6_PASSWORD:-}
K6_RESUME: ${K6_RESUME:-}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes: volumes:
pgdata: pgdata:
garage_meta:
garage_data:
airflow_logs: airflow_logs:
airflow_ml_state: airflow_ml_state:
prometheus_data:
alertmanager_data:
grafana_data:
# Vide tant qu'APP_METRICS_TOKEN n'est pas posé : l'API n'exige alors aucun jeton.
secrets:
metrics_token:
environment: APP_METRICS_TOKEN
garage_metrics_token:
environment: GARAGE_METRICS_TOKEN
+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
+17 -1
View File
@@ -2,6 +2,11 @@
- `adr` : décisions d'architecture, une par fichier, numérotées et immuables. - `adr` : décisions d'architecture, une par fichier, numérotées et immuables.
- `architecture` : les vues du système. Point d'entrée : [architecture/README.md](architecture/README.md). - `architecture` : les vues du système. Point d'entrée : [architecture/README.md](architecture/README.md).
Le pilotage des traitements automatisés a son runbook :
[architecture/70-pilotage.md](architecture/70-pilotage.md).
- `livrables` : rapports de rendu, le rapport collectif EC02 et le rapport de sécurisation EC04
avec ses preuves.
- `dailies` : points d'avancement versionnés.
## Décisions en vigueur ## Décisions en vigueur
@@ -15,4 +20,15 @@
| [0006](adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` | | [0006](adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
| [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) | Un projet Compose par environnement sur la VM ENI, déployé par un runner auto-hébergé (deux environnements à l'origine, trois depuis l'ADR 0017) |
| [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 |
| [0014](adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md) | Un pipeline CI unique appelle les workflows de composant et conditionne le déploiement |
| [0015](adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md) | Les tests de bout en bout et de charge visent la stack Compose déployée |
| [0016](adr/0016-supervision-en-profil-compose.md) | La supervision vit dans un profil Compose, active en prod |
| [0017](adr/0017-environnement-dev-a-la-demande.md) | Un troisième environnement, `dev`, déployé à la demande depuis n'importe quelle branche |
| [0018](adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md) | Noms publics, certificats Let's Encrypt par DNS-01 et frontal SNI sans port |
| [0019](adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md) | Stockage objet Garage par environnement, et cycle de vie des mesures : export puis suppression |
| [0020](adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md) | Chiffrement au repos : coffre LUKS des volumes Docker et SSE-C des archives |
+6 -6
View File
@@ -13,7 +13,7 @@ contraintes non négociables cadrent le choix, discutées dans l'issue #89 :
1. **EC06** (grille de notation individuelle) exige un modèle **entraîné, versionné avec 1. **EC06** (grille de notation individuelle) exige un modèle **entraîné, versionné avec
MLflow**, exposé via un endpoint fonctionnel, avec **surveillance du drift** en production. MLflow**, exposé via un endpoint fonctionnel, avec **surveillance du drift** en production.
2. **Aucun GPU dédié** : l'infra tourne on-premise sur une VM à 4 CPU / 8 Gio RAM (ou 2. **Aucun GPU dédié** : l'infra tourne on-premise sur une VM à 4 CPU / 8 Gio RAM (ou
`Standard_B2s`/`B2ms` côté Azure, 2 vCPU max) — Azure Machine Learning est de toute façon `Standard_B2s`/`B2ms` côté Azure, 2 vCPU max) ; Azure Machine Learning est de toute façon
bloqué par la politique Azure du projet. bloqué par la politique Azure du projet.
3. **Délai serré** : le jalon J3 arrive à échéance le lendemain de la décision, J4 concentre déjà 3. **Délai serré** : le jalon J3 arrive à échéance le lendemain de la décision, J4 concentre déjà
26 issues sur 4 jours. Un modèle long à mettre en œuvre retarde la chaîne complète (service de 26 issues sur 4 jours. Un modèle long à mettre en œuvre retarde la chaîne complète (service de
@@ -32,7 +32,7 @@ déjà dérivées.
| Régresseurs exogènes | Oui, mais doivent être connus dans le futur au moment de la prédiction | Oui, via lags/moyennes glissantes sur le passé | Oui, natif | Difficile en multivarié | Aucun support | Contexte de prompt seulement, non appris | | Régresseurs exogènes | Oui, mais doivent être connus dans le futur au moment de la prédiction | Oui, via lags/moyennes glissantes sur le passé | Oui, natif | Difficile en multivarié | Aucun support | Contexte de prompt seulement, non appris |
| Coût de calcul (VM sans GPU) | Faible | Faible | Élevé (deep learning) | Faible | Faible | Élevé à prohibitif | | Coût de calcul (VM sans GPU) | Faible | Faible | Élevé (deep learning) | Faible | Faible | Élevé à prohibitif |
| Versionnable MLflow | Oui, nativement | Oui, nativement | Pas de support direct | Oui, générique | Pas de support direct | Rien à versionner (pas un modèle entraîné) | | Versionnable MLflow | Oui, nativement | Oui, nativement | Pas de support direct | Oui, générique | Pas de support direct | Rien à versionner (pas un modèle entraîné) |
| Granularité | Un modèle par site (ou par site × métrique) | Un seul modèle global sur tous les sites | Un par site | Un par site | Un par site | — | | Granularité | Un modèle par site (ou par site × métrique) | Un seul modèle global sur tous les sites | Un par site | Un par site | Un par site | - |
| Effort avant l'échéance | Faible | Moyen (feature engineering) | Élevé | Moyen à élevé | Faible en soi | Élevé, ou factice | | Effort avant l'échéance | Faible | Moyen (feature engineering) | Élevé | Moyen à élevé | Faible en soi | Élevé, ou factice |
## Décision ## Décision
@@ -40,7 +40,7 @@ déjà dérivées.
**LightGBM, un seul modèle global** couvrant tous les sites, plutôt qu'un modèle par site **LightGBM, un seul modèle global** couvrant tous les sites, plutôt qu'un modèle par site
(Prophet) ou par famille de site. Cible : `consumption_kwh`, avec `period_minutes` comme feature (Prophet) ou par famille de site. Cible : `consumption_kwh`, avec `period_minutes` comme feature
d'entrée plutôt que comme étape d'agrégation post-prédiction. Suivi et versioning via **MLflow** d'entrée plutôt que comme étape d'agrégation post-prédiction. Suivi et versioning via **MLflow**
(tracking + registre de modèles), sur le magasin local par défaut dans un premier temps — (tracking + registre de modèles), sur le magasin local par défaut dans un premier temps ;
l'hébergement sur l'infra k3s reste une question ouverte, non bloquante pour démarrer. l'hébergement sur l'infra k3s reste une question ouverte, non bloquante pour démarrer.
Raisons retenues, au-delà du tableau ci-dessus : Raisons retenues, au-delà du tableau ci-dessus :
@@ -53,7 +53,7 @@ Raisons retenues, au-delà du tableau ci-dessus :
`humidity_percent` et `solar_irradiance_wm2` sont des mesures passées, pas des prévisions, et `humidity_percent` et `solar_irradiance_wm2` sont des mesures passées, pas des prévisions, et
aucune source de prévision météo n'existe dans le projet. LightGBM s'en sort avec des features aucune source de prévision météo n'existe dans le projet. LightGBM s'en sort avec des features
de lag/moyenne glissante calculées sur l'historique déjà présent dans `reading`, cf. de lag/moyenne glissante calculées sur l'historique déjà présent dans `reading`, cf.
`ml/enervision_ml/features.py` — un choix qui vaut aussi bien à l'entraînement qu'au futur `ml/enervision_ml/features.py`, un choix qui vaut aussi bien à l'entraînement qu'au futur
scoring. scoring.
- **Apprentissage direct sur `consumption_kwh`** avec `period_minutes` en feature, sans étape - **Apprentissage direct sur `consumption_kwh`** avec `period_minutes` en feature, sans étape
d'agrégation intermédiaire que la sortie continue de Prophet aurait demandée. d'agrégation intermédiaire que la sortie continue de Prophet aurait demandée.
@@ -92,9 +92,9 @@ ValentinDeFaria), actée en réunion d'équipe du 2026-09-17 et validée par l'e
- **SARIMA** : ne gère pas nativement plusieurs régresseurs exogènes ; réglage (p,d,q,P,D,Q) plus - **SARIMA** : ne gère pas nativement plusieurs régresseurs exogènes ; réglage (p,d,q,P,D,Q) plus
long que le délai disponible. long que le délai disponible.
- **NeuralProphet** : fait tout ce que fait Prophet et apprend en plus des motifs autorégressifs, - **NeuralProphet** : fait tout ce que fait Prophet et apprend en plus des motifs autorégressifs,
mais coûte plus cher en calcul (pas de GPU disponible) et n'a pas d'outil MLflow direct — piste mais coûte plus cher en calcul (pas de GPU disponible) et n'a pas d'outil MLflow direct : piste
d'évolution possible, non engageante à ce stade. d'évolution possible, non engageante à ce stade.
- **Holt-Winters** : écarté d'entrée, pas seulement différé — aucun support de régresseurs - **Holt-Winters** : écarté d'entrée, pas seulement différé : aucun support de régresseurs
exogènes, alors que la météo et l'irradiance sont nécessaires ici. exogènes, alors que la météo et l'irradiance sont nécessaires ici.
- **CatBoost** : même famille que LightGBM, gère nativement les colonnes catégorielles (comme - **CatBoost** : même famille que LightGBM, gère nativement les colonnes catégorielles (comme
`site_type`) sans encodage manuel. Non rejeté, différé : candidat à comparer si LightGBM `site_type`) sans encodage manuel. Non rejeté, différé : candidat à comparer si LightGBM
@@ -2,6 +2,8 @@
- Statut : accepté - Statut : accepté
- Date : 2026-09-21 - Date : 2026-09-21
- Complété par : [ADR 0017](0017-environnement-dev-a-la-demande.md), troisième environnement `dev`
- Note du 24/09 : l'approbation annoncée avant la production n'a jamais été activée. L'environnement GitHub `prod` n'accepte que `main`, sans relecteur requis.
## Contexte ## Contexte
@@ -63,8 +65,9 @@ secret.
- Deux TimescaleDB sur une machine de 8 Go : sans réglage, chacune se réserverait 25 % de la - Deux TimescaleDB sur une machine de 8 Go : sans réglage, chacune se réserverait 25 % de la
RAM au premier démarrage. L'overlay fixe `TS_TUNE_MEMORY` à 2 Go et `TS_TUNE_NUM_CPUS` à 2 par RAM au premier démarrage. L'overlay fixe `TS_TUNE_MEMORY` à 2 Go et `TS_TUNE_NUM_CPUS` à 2 par
base, et 2 workers gunicorn par webserver Airflow. La montée à 32 Go prévue par les base. Airflow 3 n'a rien à régler de ce côté : son api-server lance un seul worker par défaut,
consignes est à demander. là où le webserver d'Airflow 2 en lançait quatre. La montée à 32 Go prévue par les consignes
est à demander.
- Un runner auto-hébergé sur un dépôt public exécute le code qu'on lui envoie. `deploy.yml` ne - Un runner auto-hébergé sur un dépôt public exécute le code qu'on lui envoie. `deploy.yml` ne
se déclenche jamais sur `pull_request`, le runner tourne sous un utilisateur dédié, et le se déclenche jamais sur `pull_request`, le runner tourne sous un utilisateur dédié, et le
dépôt doit exiger une approbation pour les workflows des PR externes. dépôt doit exiger une approbation pour les workflows des PR externes.
@@ -75,6 +78,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 `<IP-VM-G3>` : `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@<IP-VM-G3>`.
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 = "<IP-VM-G3>"`, `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` :
```
<IP-VM-G3> 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` (`<IP-VM-G3>`) 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 `<IP-VM-G3>`. 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@<IP-VM-G3>`, 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=<IP-VM-G3> 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.
@@ -0,0 +1,80 @@
# 0014 - Un pipeline CI unique appelle les workflows de composant et conditionne le déploiement
- Statut : accepté
- Date : 2026-09-23
- Note du 24/09 : au gel, les règles de branche ne sont pas posées : `prod` n'accepte que `main` mais sans relecteur, `rec` et `dev` n'ont aucune règle, aucune branche n'est protégée. L'approbation des workflows externes n'est pas lisible avec les droits d'un membre.
- Complète : [0009](0009-deux-environnements-compose-sur-la-vm-eni.md), qui reste en vigueur
## Contexte
Au 22/09, huit workflows se déclenchaient chacun de leur côté, et l'audit y a relevé :
- **Double exécution.** Chaque workflow partait sur `push` (toutes branches) **et** sur
`pull_request`. Un commit poussé sur une branche de PR jouait donc toute la CI deux fois,
à la même minute (constaté dans l'historique des runs de `test/integration-api-db-ml`).
- **Sonar refaisait tout.** `sonarqube.yml` reconstruisait le frontend et retestait frontend,
backend et ML pour produire ses rapports de couverture, en double exact de `frontend.yml`,
`backend.yml` et `ml.yml`. Son test backend tournait sans `uv sync`. Il n'avait ni
`permissions` ni `concurrency`.
- **Déploiement non conditionné.** `deploy.yml` partait à chaque push sur `dev` ou `main`,
que la CI du commit soit verte ou non, et déployait la pointe de branche du moment plutôt
que le commit poussé.
- **Erreurs silencieuses et hygiène.**
- `npm test --watch=false --code-coverage` : npm garde ces options pour lui, `ng test` ne
les reçoit jamais, et la CI ne tenait que par les réglages d'`angular.json`.
- `uv sync --frozen` ne vérifie pas que `uv.lock` suit `pyproject.toml`.
- Plusieurs actions tierces étaient épinglées par tag, contrairement à la règle Sonar
`githubactions:S7637`.
- Aucun job n'avait de `timeout-minutes` (360 minutes par défaut).
## Décision
**`ci.yml` est le seul workflow déclenché par `pull_request` et par les push sur `dev` et
`main`.** Les workflows de composant (`backend`, `frontend`, `ml`, `airflow`, `infra`, `e2e`)
passent en `workflow_call` et n'ont plus de déclencheur propre.
1. **`changes`.** Un job initial calcule, par `dorny/paths-filter` épinglé sur un SHA, les
composants touchés par la PR, et chaque composant n'est appelé que si son filtre vaut vrai.
Sur un push vers `dev` ou `main`, tous les filtres valent vrai : l'analyse Sonar reste
complète sur les branches longues, et paths-filter ne compare pas à la base de fusion avec
`main`, qui a 80 commits de retard.
2. **`sonar`.** Il ne reconstruit ni ne reteste plus rien : il télécharge, dans le même run, les
couvertures versées par les jobs `verification` des composants.
3. **`CI ok`.** Le job agrège le résultat de tous les autres. Il tourne toujours (`if:
always()`) et échoue dès qu'un job est en `failure` ou `cancelled`. **C'est le seul check à
exiger dans les règles de branche** : un composant sauté par son filtre ne publie aucun check
interne, qui resterait « en attente » s'il était exigé.
4. **`deploy`.** Il appelle `deploy.yml`, sur les seuls push, et seulement si `CI ok` a réussi.
`deploy.yml` aligne le dossier de l'environnement sur `GITHUB_SHA`, le commit testé, sauf
si ce commit précède celui déjà déployé : les CI de deux push peuvent finir dans le désordre,
et un environnement ne recule jamais. Les déploiements d'un même environnement passent un par
un sous un verrou `flock` sur la VM, et non dans un groupe `concurrency`, où GitHub ne garde
qu'un job en attente et annule le précédent quand un troisième arrive.
`deploy.yml` n'a toujours **aucun déclencheur `pull_request`** : il n'accepte que
`workflow_call` et `workflow_dispatch`, dans l'esprit de l'ADR 0009.
## Alternatives écartées
| Écartée | Raison |
|---|---|
| Garder huit workflows et restreindre seulement `push` à `dev` et `main` | Supprime la double exécution, pas le doublon Sonar : il faudrait toujours rejouer les tests pour que Sonar ait ses couvertures, les artefacts ne passant pas d'un workflow à l'autre. Et rien n'empêche un déploiement rouge. |
| Déclencher le déploiement par `workflow_run` | `workflow_run` joue toujours le fichier de la branche par défaut, `main`, en retard de 80 commits : la recette ne se serait plus déployée avant la prochaine remontée vers `main`, sans erreur visible. |
| `alls-green` ou une action tierce d'agrégation | Dix lignes de shell sur `toJSON(needs.*.result)` font le même travail, sans dépendance de plus à épingler. |
| Cache de couches Docker (`bake-action`, `type=gha`) pour l'e2e | Quatre pièges (noms d'image, cibles Compose, buildx, `load`) pour deux à quatre minutes gagnées. Reporté après le rendu. |
## Conséquences
- Une PR ne joue que ce qu'elle touche. Une PR de documentation ne joue que `changes` et
`CI ok`.
- Les checks s'appellent désormais « Backend / Lint, typage et tests », etc. Au 23/09, ni `dev`
ni `main` n'ont de règle de protection : à la première, exiger **« CI ok »** et rien d'autre.
- Modifier `ci.yml` rejoue toute la CI sur la PR (filtre `ci`).
- Le job `deploy` reste en file tant que le runner `eni-g3` n'est pas enregistré sur la VM,
comme avant. Le groupe de concurrence par SHA des push l'empêche de bloquer les runs suivants.
- La sécurité du runner auto-hébergé ne repose pas sur l'absence de `pull_request` dans
`deploy.yml`. Une PR de fork peut ajouter son propre workflow. Ce qui protège le runner :
- l'approbation obligatoire des workflows de tous les contributeurs externes ;
- les règles de branche des environnements `rec` (`dev`) et `prod` (`main` et un relecteur).
Ces deux réglages restent à poser par l'administratrice du dépôt.
@@ -0,0 +1,70 @@
# 0015 - Les tests de bout en bout et de charge visent la stack Compose déployée
- Statut : accepté
- Date : 2026-09-23
## Contexte
Les issues #46 (Playwright) et #47 (k6) demandent des preuves de robustesse pour EC03 et EC04.
Rien ne vérifiait un parcours utilisateur complet : les tests du frontend simulent l'API, ceux
du backend n'ouvrent pas de navigateur. Rien ne mesurait non plus l'API sous charge, et le
dépôt ne chiffre aucun temps de réponse ni aucun volume d'utilisateurs.
Trois contraintes du système pèsent sur la manière de tester :
- **La session tient dans un cookie de refresh HttpOnly qui tourne à chaque usage.** Rejouer un
cookie déjà servi révoque toute la famille de session (ADR 0002).
- **Le cookie n'est `__Secure-` et `Secure` que hors `local`, derrière le proxy TLS.** Tester
contre `ng serve` ne dit rien de ce que voit un navigateur en prod (ADR 0007).
- **nginx limite chaque adresse IP** à 20 req/s sur l'API, avec une rafale de 40, et à 30
connexions par minute, avec une rafale de 20 (ADR 0007). Tout le trafic d'un tir parti d'une
seule machine partage la même adresse.
## Décision
**Playwright joue contre la stack de prod** (`docker-compose.yml` et
`docker-compose.prod.yml`), sur `https://localhost` avec un certificat auto-signé.
- **En CI**, le workflow `e2e.yml` démarre `db`, `mailpit`, `backend`, `frontend` et `proxy`,
sème `db/seeds/demo.sql` et crée les comptes par `scripts/comptes-test.sh`.
- **Sur le poste**, la même suite vise `make dev` (`http://localhost:4200`).
- **Écriture des tests**, imposée par la rotation du refresh et par la zone `auth` :
- un seul worker ;
- une session par fichier, sans `storageState` partagé ;
- chaque parcours qui consomme un compte le crée lui-même.
**k6 tourne en service Compose (profil `load`) sur le réseau du projet et vise `backend:8000`**,
pour mesurer l'API et non la limite de nginx. Un seul scénario, `limitation-debit.js`, passe par
`https://proxy`, pour vérifier que la limite tient : des 429, jamais de 5xx.
**Hypothèses et seuils**, faute d'exigence chiffrée :
| Hypothèse ou seuil | Valeur |
|---|---|
| Utilisateurs simultanés | 50 : 40 sur le tableau de bord, qui interroge `/stats/summary` toutes les 10 s et `/alerts` toutes les 60 s ; 10 qui explorent les sites |
| Lectures, p95 | < 500 ms |
| Lectures, p99 | < 1 s |
| Échecs HTTP | < 1 % |
| Vérifications réussies | > 99 % |
**En CI de PR** : Playwright, le tir `smoke` (une minute) et `limitation-debit`. La charge
nominale et le stress se lancent à la main (`make load-test`, `make load-stress`), en recette,
parce que rec et prod partagent la VM (ADR 0009).
## Alternatives écartées
| Écartée | Raison |
|---|---|
| Playwright contre `ng serve` en CI | Pas de TLS, pas de cookie `__Secure-`, pas de CSP ni de limitation : le parcours testé ne serait pas celui des utilisateurs. |
| `storageState` partagé entre fichiers | Chaque fichier rejouerait le même cookie de refresh ; le second usage révoque la famille, et la suite échoue de façon intermittente selon l'ordre. |
| k6 depuis le runner, à travers le proxy | Au-delà de 20 req/s, on mesure nginx. Relever la limite pour le tir, ce serait tester une configuration qui n'est pas celle de la prod. |
| Tir de charge complet à chaque PR | Huit minutes de plus par PR, sur un runner partagé dont les performances varient d'un run à l'autre : un seuil franchi n'y voudrait rien dire. |
| Un workflow k6 en `workflow_dispatch` contre la recette | La recette partage la VM avec la prod ; un tir déclenché d'un clic ralentirait la prod sans que personne soit prévenu. La cible Makefile, lancée sur la VM, garde un humain dans la boucle. |
## Conséquences
- La CI construit enfin les images backend et frontend avant le déploiement, par le job E2E.
- `db/seeds/demo.sql` et `scripts/comptes-test.sh` deviennent le jeu commun de la CI, repris par
le DAST. Les deux sont réservés aux bases jetables.
- Les seuils de k6 sont des hypothèses de l'équipe : à réviser dès qu'un besoin chiffré existe.
- L'API expose des seaux de latence fins autour de 500 ms, pour que Grafana lise le même seuil
que k6 (ADR 0016).
@@ -0,0 +1,61 @@
# 0016 - La supervision vit dans un profil Compose, active en prod
- Statut : accepté
- Date : 2026-09-23
## Contexte
L'API expose `/metrics` au format Prometheus depuis le début, et `monitoring/` ne contenait que
des `.gitkeep` : aucun collecteur, aucun tableau de bord, aucune alerte (issue #26). La VM ENI
porte la recette et la prod, deux piles complètes, sur 8 Go de mémoire (ADR 0009).
## Décision
**Prometheus, Alertmanager, Grafana et trois exporteurs** sont des services de
`docker-compose.yml` sous le profil `monitoring` : postgres-exporter, node-exporter et cAdvisor.
- **En prod**, `COMPOSE_PROFILES=monitoring` dans le `.env` : `make stack-up`, donc chaque
déploiement, les démarre avec le reste.
- **En recette et sur le poste**, ils se lancent à la demande (`make monitoring-up`, en
`--no-deps`). La recette ne paie rien tant qu'on ne les lance pas.
- **Mémoire.** Chaque service a un `mem_limit`, pour environ 700 Mo au total.
- **Accès.** Les interfaces n'écoutent que sur `127.0.0.1` et se consultent par tunnel SSH,
comme Airflow. Rien ne passe par le proxy : Grafana derrière nginx exigerait sa propre
authentification forte, et rendrait `/metrics` joignable à un routage près (ADR 0007).
- **Sécurité.**
- **Jeton.** Prometheus présente sur `/metrics` le jeton `APP_METRICS_TOKEN`, passé en
secret Compose. Il est exigé dès que la supervision tourne.
- **Lecture de la base.** Grafana et l'exportateur lisent la base par un rôle `supervision`
en lecture seule, limité aux tables métier (`db/roles/supervision.sql`). Ils n'ont
jamais accès à `app_user`, aux jetons ni à l'audit.
- **Alertes.**
- Neuf règles couvrent l'API, la base, l'hôte et les cibles, chacune avec un cas de test
joué par `promtool test rules` en CI.
- Alertmanager les envoie par courriel à Mailpit, le seul SMTP de la stack.
- **Dérive du modèle.** Elle s'affiche dans Grafana par une lecture SQL de `drift_report`.
L'ADR 0013 a écarté une jauge Prometheus calculée par un traitement par lot, pas la lecture
de sa table.
## Alternatives écartées
| Écartée | Raison |
|---|---|
| Supervision démarrée dans les deux environnements | Double la mémoire consommée sur une VM déjà serrée, pour des tableaux de recette que personne ne regarde. |
| Une pile de supervision partagée, troisième projet Compose | Elle devrait rejoindre les réseaux des deux projets, par des réseaux externes à déclarer sur la VM : plus de pièces, et un couplage entre environnements que l'ADR 0009 évite. |
| Publier Grafana derrière le proxy | Une interface d'administration de plus exposée au réseau de l'école, et un pas de plus vers une publication accidentelle de `/metrics`. |
| Grafana avec le compte applicatif de la base | Le compte applicatif écrit partout, y compris dans `app_user`. Une requête libre dans Grafana y aurait accès. |
| Une jauge de fraîcheur des relevés calculée par l'API au moment du scrape | Une requête SQL dans un collecteur synchrone, à chaque scrape. La même information se lit directement dans TimescaleDB depuis Grafana. |
## Conséquences
- **Secrets.** `.env.example` gagne `COMPOSE_PROFILES`, `APP_METRICS_TOKEN`,
`GRAFANA_ADMIN_PASSWORD`, `SUPERVISION_DB_PASSWORD` et les ports.
`scripts/provision-host.sh` génère ces secrets pour un nouvel environnement. Le `.env` d'un
environnement déjà provisionné n'est jamais réécrit : il faut les y ajouter à la main.
`make stack-up` refuse de démarrer si le profil est actif et qu'un secret manque.
- **Instrumentation.** L'API ne compte plus les sondes de santé dans ses métriques, et chaque
application a son propre registre Prometheus.
- **cAdvisor tourne en `privileged`**, avec des montages en lecture seule et sans port publié.
C'est le prix de la mémoire par conteneur, l'indicateur qui compte le plus sur une VM partagée.
- **Données non couvertes.** L'ingestion et les DAG Airflow n'ont pas encore de métriques
(StatsD ou OpenTelemetry). Le tableau « Données » les supplée en lisant `reading`.
@@ -0,0 +1,57 @@
# 0017 - Un troisième environnement, `dev`, déployé à la demande depuis n'importe quelle branche
- Statut : accepté
- Date : 2026-09-23
## Contexte
L'[ADR 0009](0009-deux-environnements-compose-sur-la-vm-eni.md) a posé deux environnements sur
la VM ENI : la recette suit `dev`, la production suit `main`. Les environnements GitHub en
comptent trois, `dev`, `rec` et `prod`, et le troisième ne déployait rien.
Il manque un endroit où montrer une branche de travail avant son merge : la recette ne doit
porter que ce qui est intégré à `dev`, sinon elle cesse d'être une recette. Un
`workflow_dispatch` sur une branche de travail envoyait d'ailleurs cette branche dans la
recette, puisque tout ce qui n'était pas `main` y partait.
La VM est passée à 32 Go : une troisième TimescaleDB, réglée à 2 Go comme les deux autres,
tient sans peine.
## Décision
**Un troisième projet Compose, `enervision-dev`, dans `/srv/enervision/dev`**, bâti exactement
comme les deux autres : son clone, son `.env`, son certificat, préparés par
`scripts/provision-host.sh`.
**Déployé à la demande, jamais sur un push.** `deploy.yml` envoie `main` en prod, `dev` en
recette, et toute autre branche lancée depuis l'onglet Actions dans `dev`. Seul un membre ayant
le droit d'écriture sur le dépôt peut lancer un workflow.
**Ports décalés d'un cran de plus** : HTTPS `9443`, et sur `127.0.0.1` la redirection HTTP
`8083`, PostgreSQL `5435`, Mailpit `8027`, Airflow `8084`. Nom d'hôte `dev.enervision.local`,
pour la même raison de cookie que la recette.
**Le verrou de déploiement suit l'environnement** (un `flock` sur son dossier, ADR 0014), et non
plus la branche : deux branches lancées coup sur coup écriraient sinon dans le même dossier en
même temps.
## Alternatives écartées
- **`dev` suit la branche `dev` à chaque push, la recette devient manuelle** : la recette
offrirait une version figée au jury, mais la doc CI/CD, l'ADR 0009 et l'habitude de l'équipe
basculeraient à deux jours du rendu.
- **Un environnement par branche de travail** : un projet Compose et une TimescaleDB par
branche, sans mécanisme de nettoyage. La machine ne le porterait pas longtemps.
- **Garder `dev` sur les postes seulement** : rien à montrer d'une branche non mergée sans
passer par la recette.
## Conséquences
- Une branche de travail créée avant ce changement porte l'ancien `deploy.yml` : lancée à la
main, elle part encore dans la recette. Limiter l'environnement GitHub `rec` à la branche
`dev` ferme ce chemin, réglage que seul un administrateur du dépôt peut poser.
- `dev` ne garde aucune donnée d'une branche à l'autre au-delà de ce que ses migrations
acceptent : une branche dont les migrations divergent de `dev` peut laisser la base dans un
état que la suivante refuse. Recréer le volume, `docker compose down -v`, est alors le remède.
- Trois environnements construisent leurs images séparément : l'écart de l'ADR 0009, un même
commit construit deux fois, reste ouvert jusqu'au passage à GHCR.
@@ -0,0 +1,92 @@
# 0018 - Noms publics, certificats Let's Encrypt par DNS-01 et frontal SNI sans port
- Statut : accepté
- Date : 2026-09-23
## Contexte
Les trois environnements de la VM ENI ([ADR 0009](0009-deux-environnements-compose-sur-la-vm-eni.md),
[ADR 0017](0017-environnement-dev-a-la-demande.md)) répondaient sur `enervision.local`,
`rec.enervision.local:8443` et `dev.enervision.local:9443`, avec des certificats auto-signés.
Chaque poste devait éditer son `/etc/hosts` et accepter trois avertissements du navigateur :
rien de présentable à un jury, et rien d'utilisable par quelqu'un qui n'a pas la main sur son
poste.
Contraintes : la VM n'a qu'une IP privée, `<IP-VM-G3>`, que ni Internet ni Let's Encrypt ne
joignent, et le réseau de l'école ne doit pas être touché. Vérifications faites le 23/09 : les
résolveurs de l'école rendent bien une adresse privée pour un nom public, la VM sort en HTTPS
vers Let's Encrypt et vers l'API de dynv6, mais le filtrage de l'école bloque duckdns.org, site
et API, depuis les postes comme depuis la VM.
## Décision
**Des noms publics qui visent l'IP privée.** Dans `enervision-g3.dynv6.net`, zone gratuite de
dynv6, trois enregistrements A portent `prod.`, `rec.` et `dev.`. `provision-host.sh` les publie
par l'API dynv6 : le DNS est décrit par le code comme le reste. La prod n'est pas à la racine de
la zone : dynv6 y sert mal un TXT `_acme-challenge`, que l'API ne liste ni ne supprime et qu'un
seul de ses trois serveurs renvoie (constaté le 23/09), si bien que son défi DNS-01 échoue. Tout
poste du réseau de l'école les résout sans configuration ; hors de ce réseau, l'IP ne mène
nulle part.
**Des certificats Let's Encrypt par défi DNS-01.** Le défi passe par l'API dynv6, qui pose
l'enregistrement TXT : Let's Encrypt n'a jamais à joindre la VM. `make tls-dns01` (acme.sh
épinglé) le joue dans chaque stack ; il ne renouvelle qu'à échéance, d'où son rejeu à chaque
déploiement et chaque nuit par cron. `--dnssleep 90` laisse aux trois serveurs de dynv6 le temps
de servir le TXT avant que Let's Encrypt ne le cherche depuis plusieurs réseaux. Un certificat par environnement plutôt qu'un joker : chaque
stack garde le sien, et la clé de la prod n'est pas lisible depuis le clone de dev.
**Un frontal SNI sur 443, le seul composant exposé.** `infra/front`, un nginx sur le réseau de
l'hôte, lit le nom demandé dans le ClientHello et relaie le flux TLS intact vers la stack visée,
publiée sur la boucle locale. Il ne détient aucun certificat. Le port 80 y redirige vers
HTTPS. Les URL perdent leur port.
**Le PROXY protocol entre frontal et stacks.** Relayé tel quel, le flux arriverait avec l'IP du
frontal : `limit_req` et `get_client_ip()` compteraient tous les postes comme un seul, et un
utilisateur bloquerait la connexion de tous. Chaque proxy de stack reçoit donc le frontal sur un
écouteur dédié, 4443, qui exige l'en-tête PROXY protocol et en tire l'IP du client. Le 443 de
la stack reste sans PROXY protocol, pour les postes de développement et la sonde du déploiement.
**Le fournisseur est un paramètre.** `DNS01_API` et `DNS01_JETON_VAR` nomment le greffon acme.sh,
le jeton vit dans `dns.token` quel que soit le fournisseur : passer à un domaine acheté chez
Cloudflare ou OVH ne demande que ces deux variables et `domaine`, plus `publier_dns()`.
**`scripts/provision-host.sh` fait foi pour l'adressage et les secrets.** Un `.env` existant
garde ses secrets, reçoit ceux qui lui manquent et voit hôte, ports et profils réalignés sur le
tableau du script. C'est ce qui permet de migrer trois `.env` nés avant ce changement, et le
clone de la prod, en retard sur `main`, sans dépendre de son `.env.example`.
## Alternatives écartées
- **Garder `/etc/hosts` et l'auto-signé** : trois manipulations par poste et trois
avertissements, précisément ce qu'il fallait supprimer.
- **DuckDNS** : premier choix, inscription en un clic, mais bloqué par le filtrage de l'école :
sans son API, pas de défi DNS-01.
- **deSEC (`dedyn.io`)** : joignable et associatif, mais les inscriptions de nouveaux domaines
`dedyn.io` étaient fermées le 23/09 ; il reste le bon choix pour un domaine acheté.
- **nip.io ou sslip.io** : résolution sans compte, mais aucun moyen d'y obtenir un certificat.
- **Services à certificat joker public (traefik.me, local-ip.co)** : leur clé privée est publiée
par conception, n'importe qui peut usurper ces noms.
- **Tunnel vers Internet (Cloudflare Tunnel, Tailscale Funnel)** : accès depuis l'extérieur,
mais l'application serait exposée hors de l'école, décision refusée.
- **Autorité de certification interne (mkcert, step-ca)** : chaque poste devrait l'installer.
- **Terminaison TLS au frontal** : un seul endroit pour les certificats, mais les stacks
recevraient du HTTP clair que leur proxy redirige vers HTTPS, et en-têtes de sécurité comme
limitation de débit seraient à déplacer. Le relais SNI ne touche à rien de tout cela.
- **Domaine acheté** : plus présentable, mais un achat et un compte de plus pour un bénéfice nul
sur l'accès. Seule `DOMAINE` changerait.
## Conséquences
- L'objection de l'ADR 0009 à un proxy frontal, qui aurait dû joindre plusieurs réseaux Compose
aux services homonymes, tombe : le frontal ne joint que des ports de la boucle locale.
- Sans le frontal, plus rien n'est joignable sur la VM. `deploy.yml` le relance à chaque
déploiement de la prod, et son `restart: unless-stopped` le ramène après un redémarrage.
- Le jeton dynv6 vit dans `/srv/enervision/dns.token`, jamais dans git, GitHub ni le state
Terraform ; acme.sh en garde une copie dans `infra/proxy/acme/`, retirée à la lecture des
autres comptes. Qui le détient peut repointer les trois noms.
- dynv6 devient une dépendance : s'il tombe, les noms cessent de résoudre et les
renouvellements échouent. Les certificats valent 90 jours, la marge est large.
- Un filtrage de l'école qui viendrait à bloquer dynv6 arrêterait les renouvellements, pas les
noms : la résolution passe par les serveurs DNS de l'école, pas par le site.
- Les noms sont publics mais ne mènent qu'à une IP privée : ils révèlent l'existence de la VM,
pas son contenu.
@@ -0,0 +1,96 @@
# 0019 - Stockage objet Garage par environnement, et cycle de vie des mesures : export puis suppression
- Statut : accepté
- Date : 2026-09-24
## Contexte
Les issues #24 « Déployer MinIO » et #36 « Politique de rétention + export vers MinIO » datent du
cadrage du 14/09. Au 24/09, la hypertable `reading` grossit d'une lecture par site et par heure
sans qu'aucune politique ne la borne, et `docs/architecture/40-data.md` classe rétention et
compression parmi les cibles non faites. Aucun stockage objet ne tourne.
La PR 164 a posé une amorce : un projet Compose à part dans `garage/`, l'image `dxflrs/garage:v1.0.1`,
des secrets dans un `garage.toml` gitignoré et des tests de fumée boto3 que rien ne jouait. Rien
n'était branché sur les trois environnements de la VM ([ADR 0009](0009-deux-environnements-compose-sur-la-vm-eni.md),
[ADR 0017](0017-environnement-dev-a-la-demande.md)), ni sur la CI, ni sur la supervision.
Contrainte propre au projet : le jeu historique s'arrête au 31/12/2024 et `make demo-data` s'y
ancre. Une rétention sous vingt-et-un mois effacerait la démonstration.
## Décision
**Garage plutôt que MinIO**, en `v2.4.1`. Un binaire statique de quelques dizaines de Mo, une
API S3 suffisante pour boto3, des métriques Prometheus natives, et depuis la `v2.3.0` un mode
`--single-node --default-bucket` qui crée layout, clé et bucket au premier démarrage à partir de
trois variables d'environnement : aucun conteneur d'initialisation, aucune séquence CLI à rejouer.
**Un Garage par projet Compose.** Le service `garage` vit dans `docker-compose.yml`, comme `db`
et `mailpit`. Chaque environnement a le sien, ses volumes `garage_meta` et `garage_data`, ses
secrets et ses ports sur `127.0.0.1` : S3 `3900`, `3910`, `3920` et admin `3903`, `3913`, `3923`
pour prod, recette et dev. Le RPC n'est pas publié. Rien ne passe par le proxy.
**`infra/garage/garage.toml` est versionné sans secret.** `GARAGE_RPC_SECRET` (32 octets
hexadécimaux), `GARAGE_ADMIN_TOKEN` et `GARAGE_METRICS_TOKEN` arrivent par l'environnement, comme
les autres secrets du `.env`, générés par `scripts/provision-host.sh`. L'image est `FROM scratch`,
sans shell : la garde sur les secrets vit dans le `Makefile` (`garage-garde`, appelée par
`services-up` et `stack-up`), et le healthcheck est `garage health -q`.
**Nœud unique assumé.** `replication_factor = 1` et moteur `sqlite`, avec un instantané des
métadonnées toutes les six heures. La documentation de Garage réserve ce facteur aux
déploiements de test : ici la machine est unique, la redondance n'existe pour aucun autre service,
et le chiffrement au repos est traité à part ([ADR 0020](0020-chiffrement-au-repos-coffre-luks-et-sse-c.md)). LMDB, le moteur par défaut, se corrompt à l'arrêt brutal et rien ne le reconstruirait.
**La rétention de `reading` est un traitement du backend, ordonnancé par Airflow.** Le DAG
`retention` lance chaque nuit `app.etl.reading_retention` ([ADR 0008](0008-airflow-execute-le-code-du-backend.md)),
qui, pour chaque chunk entièrement plus vieux que `READING_RETENTION_DAYS` (1095 jours par défaut) :
1. lit ses lignes par la hypertable (`WHERE timestamp >= range_start AND timestamp < range_end`) ;
2. les sérialise en CSV gzip reproductible, les colonnes `jsonb` et `text[]` en JSON ;
3. les dépose sur Garage sous `reading/<annee>/reading_<debut>_<fin>.csv.gz`, chiffrées par SSE-C,
avec le sha256 et le nombre de lignes en métadonnées ; un objet déjà présent avec le même sha
n'est pas réécrit ;
4. relit l'objet et compare son sha256 ;
5. supprime ce seul chunk par `drop_chunks(older_than => range_end, newer_than => range_start)`,
dans une transaction dédiée et courte.
`add_retention_policy` de TimescaleDB est écartée : son travail de fond supprimerait sans avoir
exporté. `db/migrations/` reste vide pour la même raison.
**Supervision.** Prometheus scrute `garage:3903/metrics` avec `GARAGE_METRICS_TOKEN` passé en
secret Compose. `CibleInjoignable` couvre son indisponibilité, aucune règle nouvelle.
**CI.** Le job « Validation des fichiers Compose et de la supervision » démarre le vrai conteneur
avec des secrets générés, attend son healthcheck et joue `tests/garage/test_smoke.py` : bucket
présent, aller-retour, suppression effective, et lecture refusée sans clé SSE-C.
## Alternatives écartées
| Écartée | Raison |
|---|---|
| MinIO | Plus lourd, licence AGPL, orientation vers l'offre commerciale ; l'équipe préfère un composant qu'elle peut lire en entier. Le titre des issues date du cadrage, la décision a changé depuis. |
| Un Garage partagé entre les trois environnements | Un troisième projet Compose et des réseaux externes à déclarer, le couplage que l'ADR 0009 évite. |
| `add_retention_policy` TimescaleDB, plus un export séparé | Deux horloges indépendantes : un export en retard d'une semaine perd les données que la politique a déjà supprimées. |
| Export Parquet | Une dépendance binaire de plus (`pyarrow`) dans l'image Airflow et le backend, pour un gain nul sur 120 000 lignes ; le CSV gzip est le format d'origine du jeu historique. |
| Commande de restauration | Hors périmètre du J6. La procédure manuelle tient en trois commandes : `get_object` avec la clé SSE-C, `gunzip`, `COPY reading FROM STDIN CSV HEADER` ; `uq_reading_source` refuse les doublons. |
| Compression TimescaleDB des chunks chauds | Autre chantier, sans lien avec l'export. |
## Conséquences
- **Premier passage en prod** (24/09/2026, borne à trois ans) : les chunks de janvier à septembre
2023 sont archivés puis supprimés, environ quarante objets. La démonstration ancrée fin 2024
et l'entraînement du modèle (quinze mois d'historique plus 2026) ne sont pas touchés.
- **`drop_chunks` verrouille `site` et `dataset`** en exclusif jusqu'au COMMIT : le DAG tourne à
03h20, entre `alertes` (:15) et `derive` (05h30), et chaque suppression est une transaction
propre.
- **Secrets.** `.env.example` gagne `GARAGE_RPC_SECRET`, `GARAGE_ADMIN_TOKEN`,
`GARAGE_METRICS_TOKEN`, `GARAGE_ACCESS_KEY`, `GARAGE_SECRET_KEY`, `GARAGE_BUCKET`,
`GARAGE_S3_PORT`, `GARAGE_ADMIN_PORT`, `GARAGE_SSE_KEY` et `READING_RETENTION_DAYS`.
`provision-host.sh` les génère et réaligne les `.env` de la VM : il doit être rejoué avant le
premier déploiement qui suit ce changement, sinon `make stack-up` s'arrête sur la garde.
- **Rotation.** `GARAGE_SECRET_KEY` ne se change pas sur un volume peuplé : Garage refuse de
démarrer. Passer par `garage key` en CLI, ou recréer le volume d'un environnement jetable.
- **Perte de `GARAGE_SSE_KEY` = archives illisibles.** La clé est sauvegardée hors de la VM.
- **Postes de développement.** `make dev` exige désormais les clés `GARAGE_*` dans le `.env`,
comme il exigeait déjà les clés Airflow.
- **Métriques Garage** visibles dans Prometheus ; aucun tableau Grafana dédié pour l'instant.
@@ -0,0 +1,109 @@
# 0020 - Chiffrement au repos : coffre LUKS des volumes Docker et SSE-C des archives
- Statut : accepté
- Date : 2026-09-24
## Contexte
L'issue #42 demande que les données de la plateforme soient chiffrées au repos. Tout ce que la
plateforme persiste vit dans les volumes Docker nommés des trois projets Compose de la VM ENI
([ADR 0009](0009-deux-environnements-compose-sur-la-vm-eni.md),
[ADR 0017](0017-environnement-dev-a-la-demande.md)), sous `/var/lib/docker/volumes` : la base
TimescaleDB (`pgdata`, relevés, comptes, audit), les métadonnées et les objets de Garage
(`garage_meta`, `garage_data`, les archives des chunks de `reading` exportées par le DAG
`retention`, [ADR 0019](0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)), les journaux et l'état ML d'Airflow, les séries de Prometheus et la base
de Grafana.
Aucun des deux dépôts de données ne chiffre lui-même : Garage n'a pas de chiffrement côté serveur
et sa documentation renvoie à un volume LUKS sous ses données ; PostgreSQL communautaire n'a pas de
chiffrement transparent des données (TDE), et l'image `timescaledb-ha` n'en ajoute pas. La VM est
unique, sur un seul disque virtuel, sans partition libre, sans TPM, et personne n'est devant sa
console au démarrage : tout redémarrage doit aboutir sans saisie.
## Décision
**Constat du 24/09, qui borne tout ce qui suit.** La machine `eadl-2025-nantes-g3` n'est pas une
machine virtuelle mais un conteneur LXC Ubuntu 24.04 sur un hôte Proxmox (`systemd-detect-virt`
répond `lxc`, aucun `/dev/mapper/control`, aucun périphérique loop, pas de `/dev/fuse`, module
`dm_crypt` inaccessible). LUKS, comme tout chiffrement au niveau bloc ou FUSE, y est impossible.
Le chiffrement au repos du disque de ce conteneur ne peut se faire que sur l'hôte Proxmox
(volume LUKS ou ZFS chiffré sous le conteneur), par l'administrateur de l'école : la demande
lui est adressée, et jusqu'à sa réponse la base et les métadonnées Garage sont en clair sur ce
disque. `scripts/coffre-luks.sh` détecte ce cas et refuse de démarrer. Ce qui suit reste la
décision pour toute machine où le device-mapper est disponible (la cible k3s de `10-infra.md`,
ou une vraie VM), et le SSE-C des archives est en place dès aujourd'hui.
**Un coffre LUKS2 sous tous les volumes Docker, posé par `scripts/coffre-luks.sh`.**
- Le coffre est un **fichier image creux** (`/srv/enervision/coffre.img`, 30 Go par défaut)
formaté en **LUKS2**, ouvert par une **clé de 64 octets tirée de `/dev/urandom`**, lisible par
root seulement (`/root/enervision-coffre.key`, `0400`). Un fichier plutôt qu'une partition : la
VM n'en a pas de libre, et l'image se déplace ou se sauvegarde comme un fichier.
- Le mapper `enervision-coffre` porte un ext4 monté sur `/srv/enervision/coffre`, et
`/var/lib/docker/volumes` est **bind-monté** depuis `/srv/enervision/coffre/docker-volumes`.
Docker ne voit qu'un dossier ordinaire : ni `data-root`, ni les fichiers Compose, ni les noms
de volumes ne changent, et les trois environnements sont couverts d'un coup.
- L'ouverture et les montages sont déclarés dans **`/etc/crypttab` et `/etc/fstab`, avec
`nofail`** sur les trois lignes : un coffre absent ne doit jamais envoyer la machine en mode
urgence, où SSH ne répond plus. Sur Debian 13 le générateur crypttab est dans le paquet
`systemd-cryptsetup`, installé par le script s'il existe dans apt.
- Un **drop-in `RequiresMountsFor=/var/lib/docker/volumes`** sur `docker.service` fait la
barrière : sans le bind, Docker ne démarre pas, plutôt que de recréer des volumes vides en
clair et de laisser trois stacks se lever sur des bases neuves.
- Le script est **rejouable** : clé, image, formatage, système de fichiers, crypttab, fstab et
drop-in ne sont posés que s'ils manquent, et il sort sans rien toucher si
`/var/lib/docker/volumes` est déjà servi par le coffre. La **migration à froid** des volumes
existants n'a lieu qu'avec `COFFRE_MIGRER=1` : refus si `live-restore` est actif, arrêt de
`docker.socket` et `docker.service`, `rsync -aHAX --numeric-ids`, comparaison du nombre et de la
taille des fichiers, puis bascule du dossier et redémarrage de Docker. L'ancien dossier reste en
`/var/lib/docker/volumes.avant-coffre` jusqu'à validation par un redémarrage.
- Terraform peut le jouer : `null_resource.coffre`, activé par `coffre_taille` non vide,
s'exécute après Docker et avant `provision-host.sh`. La ressource est optionnelle et absente du
plan tant que la variable est vide.
**SSE-C sur les archives exportées vers Garage.** Le module d'export du DAG `retention` envoie
chaque archive avec une clé client (`GARAGE_SSE_KEY`, générée dans le `.env` par
`provision-host.sh`) ; Garage la chiffre en AES-256-GCM et n'en garde que l'empreinte. Les objets
sont donc chiffrés une seconde fois, avec une clé distincte de celle du coffre, dans le seul
dépôt que l'on pourrait un jour sortir de la VM.
## Alternatives écartées
| Écartée | Raison |
|---|---|
| `pgcrypto`, chiffrement par colonne | Ne couvre ni les index, ni les journaux WAL, ni Garage, ni Airflow ; la clé serait dans l'application, à côté des données, pour un coût de développement et de requête sur chaque lecture d'hypertable. |
| Déplacer le `data-root` de Docker dans le coffre | Chiffre aussi les images et les couches, sans valeur, et impose de recopier tout `/var/lib/docker` : plus long, plus de place, et le démon doit être reconfiguré. Seuls les volumes portent des données. |
| Chiffrer côté client dans le module d'export | Couvre les archives et rien d'autre, avec une bibliothèque cryptographique à porter dans le code métier alors que Garage offre SSE-C. Retenu seulement sous cette forme, en complément du coffre. |
| Volume Docker chiffré par un plugin | Un plugin tiers par volume nommé, à installer et suivre sur la machine, pour huit volumes par environnement ; le coffre les couvre tous d'un bind. |
| Disque ou partition dédiée | La VM n'a qu'un disque virtuel, sans partition libre, et son redimensionnement n'est pas dans les mains de l'équipe. |
| Clé saisie au démarrage | Personne devant la console ; un redémarrage de la VM par l'école laisserait la plateforme arrêtée jusqu'à intervention. |
| Clé scellée dans un TPM | La VM n'en expose pas. |
## Conséquences
- **Ce que le coffre protège, et ce qu'il ne protège pas.** La clé et l'image vivent sur le même
disque. Le coffre protège une copie isolée de l'image ou du disque : snapshot, sauvegarde,
décommissionnement du disque virtuel. Il ne protège ni du vol du disque entier, où la clé se
trouve aussi, ni d'un root sur l'hôte allumé, qui lit le montage en clair. La copie
`.avant-coffre`, supprimée après validation, n'est pas effaçable physiquement sur un disque
virtuel. La clé SSE-C transite en clair sur le réseau Compose interne, entre `airflow-scheduler`
et Garage, à chaque objet envoyé.
- **Perte de la clé, perte de tout.** Sans `/root/enervision-coffre.key`, l'image est illisible
et les trois bases avec elle. La clé est à sauvegarder hors de la VM tout de suite après la
pose, dans un emplacement que seuls les administrateurs lisent.
- **Coupure lors de la migration.** La copie des volumes se fait Docker arrêté : les trois
environnements sont indisponibles une à trois minutes, et le disque doit porter deux fois la
taille des volumes jusqu'à la suppression de `.avant-coffre`.
- **Redémarrage de test obligatoire.** L'ordonnancement crypttab, fstab, drop-in ne se vérifie
qu'en redémarrant : `findmnt /var/lib/docker/volumes` et `docker ps` après le reboot, avant de
supprimer la copie en clair.
- **Docker dépend du coffre.** Si l'image ou la clé disparaît, Docker refuse de démarrer
(`dependency failed`) et la machine reste joignable par SSH ; c'est voulu. Retirer la ligne
fstab du bind retire cette protection sans message.
- **Rotation.** La clé LUKS se change par `cryptsetup luksChangeKey` sans réécrire les données.
`GARAGE_SSE_KEY` ne se change pas sans réécrire chaque objet : Garage n'a pas de re-chiffrement
côté serveur, et un objet écrit avec l'ancienne clé ne se lit qu'avec elle.
- **Terraform interrompt la stack, une fois.** La première pose avec `coffre_taille` est la seule
ressource de `vm-eni` qui arrête Docker, en contradiction assumée avec
l'[ADR 0010](0010-terraform-provisionne-github-actions-deploie.md) pour cette seule occasion ;
les `apply` suivants trouvent le coffre en place et n'y touchent pas.
+78 -42
View File
@@ -19,22 +19,22 @@ parce qu'ils disent ce que le projet doit prouver, et donc à quoi sert chaque d
## Contexte ## Contexte
Statut : `Cible`. Les acteurs et les sources de mesures ne sont pas arrêtés, c'est l'objet du Statut : `Fait`. Les sources de mesures ont été arrêtées au jalon J2 : le dataset historique
jalon J2. fourni par l'école (CSV et métadonnées JSON, 2023 et 2024) et l'API Mock, interrogée chaque heure.
```mermaid ```mermaid
flowchart LR flowchart LR
exploitant["Exploitant<br/>consulte les courbes"] exploitant["Exploitant<br/>consulte les courbes"]
admin["Administrateur<br/>exploite la plateforme"] admin["Administrateur<br/>exploite la plateforme"]
sources["Sources de mesures<br/>à définir en J2"] sources["Sources de mesures<br/>dataset CSV et API Mock"]
subgraph systeme["EnerVision"] subgraph systeme["EnerVision"]
plateforme["Collecte, stockage,<br/>analyse et restitution<br/>de séries temporelles"] plateforme["Collecte, stockage,<br/>analyse et restitution<br/>de séries temporelles"]
end end
sources -.-> plateforme sources --> plateforme
exploitant -.-> plateforme exploitant --> plateforme
admin -.-> plateforme admin --> plateforme
``` ```
## Conteneurs ## Conteneurs
@@ -46,58 +46,76 @@ flowchart TB
navigateur["Navigateur"] navigateur["Navigateur"]
subgraph machine["Machine on-premise"] subgraph machine["Machine on-premise"]
proxy["Reverse proxy Nginx<br/>:80 et :443"] frontal["Frontal SNI<br/>:80 et :443"]
proxy["Reverse proxy Nginx<br/>un par environnement"]
front["Frontend Angular 22<br/>apps/frontend"] front["Frontend Angular 22<br/>apps/frontend"]
api["API FastAPI<br/>apps/backend"] api["API FastAPI<br/>apps/backend"]
db[("PostgreSQL 17<br/>TimescaleDB")] db[("PostgreSQL 17<br/>TimescaleDB")]
airflow["Airflow<br/>etl/airflow"] airflow["Airflow<br/>etl/airflow"]
prom["Prometheus"] prom["Prometheus<br/>profil monitoring"]
grafana["Grafana"] grafana["Grafana<br/>profil monitoring"]
end end
navigateur --> proxy navigateur --> frontal
frontal --> proxy
proxy --> front proxy --> front
proxy --> api proxy --> api
front -.-> api front --> api
api --> db api --> db
airflow --> db airflow --> db
prom -.-> api prom --> api
grafana -.-> db grafana --> db
grafana -.-> prom grafana --> prom
``` ```
Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API, mais un Le lien `front --> api` est en trait plein : les fixtures sont coupées (`useMockFixtures: false`
intercepteur répond à sa place tant que les endpoints n'existent pas. Voir dans les deux fichiers d'environnement Angular), et chaque service HTTP du frontend interroge
[30-frontend.md](30-frontend.md). l'API réelle. Voir [30-frontend.md](30-frontend.md). Sur la machine, un frontal SNI reçoit les
ports 80 et 443 et aiguille chaque nom vers le proxy de son environnement
([ADR 0018](../adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md)).
Le lien `airflow --> db` est maintenant en trait plein : quatre DAGs tournent, deux pour Le lien `airflow --> db` est maintenant en trait plein : sept DAGs tournent, deux pour
l'entraînement et le scoring du modèle ML (issue #115), un pour la détection d'alertes et la l'entraînement et le scoring du modèle ML (issue #115), un pour la détection d'alertes et la
génération des recommandations (issue #116), et `historical_import` pour l'ingestion du dataset génération des recommandations (issue #116), un pour la surveillance de dérive (issue #45),
historique (issue #119). L'orchestration de l'import API Mock et la réconciliation globale des `historical_import` pour le dataset historique (issue #119), `mock_api_import` pour l'ingestion
deux sources restent à compléter dans l'issue #15. horaire de l'API Mock (issue #15) et `retention`, qui exporte les chunks anciens de `reading`
vers Garage avant de les supprimer (issue #36). La réconciliation entre les deux sources de lectures (issue
#15) est tranchée : le trou entre la fin de l'historique (31/12/2024) et le début de l'ingestion
API Mock est accepté comme définitivement perdu, aucune mesure réelle n'existant pour cette
période. `mock_api_import` refuse toute fenêtre qui recouvrirait des lectures déjà importées du
CSV plutôt que de laisser les deux sources dupliquer silencieusement un même instant, et le
pipeline ML déduplique par construction (`DISTINCT ON`, source `csv` préférée) au cas où un
recouvrement se produirait malgré tout, voir [40-data.md](40-data.md).
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun Les liens de la supervision sont en trait plein depuis le 23/09 (issue #26) : Prometheus scrute
collecteur ne vient le lire. `/metrics` avec un jeton, Grafana lit Prometheus et, par un rôle en lecture seule, les tables
métier de TimescaleDB. Ils tournent en prod sous le profil Compose `monitoring`, à la demande
ailleurs ([ADR 0016](../adr/0016-supervision-en-profil-compose.md),
[60-observabilite.md](60-observabilite.md)).
## État de la stack ## État de la stack
| Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement | | Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement |
|---|---|---|---|---| |---|---|---|---|---|
| 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 26 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), huit services HTTP, graphiques Chart.js, vues sites, recommandations et supervision des capteurs. Tous branchés sur l'API réelle (`useMockFixtures: false`) ; l'intercepteur de fixtures ne sert plus qu'au développement hors ligne |
| 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 en service sur la machine, un proxy par environnement derrière un frontal SNI ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md), [ADR 0018](../adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md)). Provisionnement de la VM par Terraform, appliqué : Docker installé, trois environnements préparés, runner enregistré ; le coffre LUKS n'est pas appliqué, la machine étant un conteneur LXC ([ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md), [ADR 0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API | | Stockage objet | Garage, S3 | `infra/garage`, `docker-compose.yml` | `Fait` | Un Garage par environnement, `--single-node --default-bucket`, secrets par l'environnement, ports sur `127.0.0.1`, fumée S3 et SSE-C en CI. Reçoit les archives CSV gzip du DAG `retention`, chiffrées SSE-C, avant `drop_chunks` ([ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md), [ADR 0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md)) |
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. 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 | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Fait` | Profil Compose `monitoring`, actif en prod : Prometheus et trois exporteurs (PostgreSQL, hôte, conteneurs), neuf règles d'alerte testées par `promtool`, Alertmanager vers Mailpit, trois tableaux de bord Grafana provisionnés. Voir [60-observabilite.md](60-observabilite.md) |
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 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) | | ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver et scheduler avec LocalExecutor via Docker Compose, sur une base PostgreSQL dédiée. Sept DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes`, `historical_import`, `mock_api_import`, `derive` (quotidien, surveillance de dérive) et `retention` (quotidien, export vers Garage puis suppression). L'import historique reste manuel et l'import API Mock s'exécute chaque heure. Réconciliation entre les deux sources (issue #15) : trou temporel accepté, recouvrement refusé à l'ingestion et dédupliqué en défense côté ML, voir [40-data.md](40-data.md). |
| CI/CD | GitHub Actions | `.github/workflows` | `Fait` | Un orchestrateur `ci.yml` qui n'appelle que les composants modifiés ([ADR 0014](../adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md)) : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow, Terraform, Compose et supervision, parcours Playwright et tirs k6 contre la stack de prod ([ADR 0015](../adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md)). Déploiement vers la VM ENI par `deploy.yml`, appelé une fois « CI ok » vert, `dev` en recette et `main` en production ([ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)), et toute autre branche à la demande dans `dev` ([ADR 0017](../adr/0017-environnement-dev-a-la-demande.md)). Sept déploiements de production réussis, le dernier sur le commit gelé ; l'environnement `prod` n'exige aucun relecteur (relevé du 24/09). 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 entre les deux sources de lectures (issue #15) est close : voir
[40-data.md](40-data.md) pour le détail du garde-fou d'ingestion et de la déduplication ML.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
@@ -109,7 +127,6 @@ sequenceDiagram
S->>A: mesures horodatées S->>A: mesures horodatées
A->>T: insertion dans l'hypertable A->>T: insertion dans l'hypertable
T->>T: rafraîchissement de l'agrégat continu
U->>API: GET /api/v1/... U->>API: GET /api/v1/...
API->>T: agrégation sur la fenêtre demandée API->>T: agrégation sur la fenêtre demandée
T-->>API: lignes T-->>API: lignes
@@ -128,8 +145,14 @@ consolidée.
Argon2id, RBAC à trois rôles. Détail dans [20-backend.md](20-backend.md), décisions dans les Argon2id, RBAC à trois rôles. Détail dans [20-backend.md](20-backend.md), décisions dans les
[ADR 0002](../adr/0002-authentification-jwt-et-refresh-opaque.md) et [ADR 0002](../adr/0002-authentification-jwt-et-refresh-opaque.md) et
[0003](../adr/0003-autorisation-rbac-a-trois-roles.md). [0003](../adr/0003-autorisation-rbac-a-trois-roles.md).
- **Interdire par défaut.** Toute route exige un jeton, sauf quatre exceptions listées dans un - **Chiffrement au repos.** Les archives de mesures déposées sur Garage sont chiffrées par clé
fichier de test qui interroge réellement chaque route sans identifiant. client (SSE-C). Le coffre LUKS des volumes Docker (`scripts/coffre-luks.sh`) est prêt pour une
vraie VM, mais la machine ENI est un conteneur LXC sans device-mapper : le chiffrement de son
disque relève de l'hôte Proxmox, demandé à l'école. Ce qui est couvert et ce qui ne l'est pas :
[ADR 0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md).
- **Interdire par défaut.** Toute route exige un jeton, sauf huit routes publiques listées
nommément dans `tests/api/acces.py`, et un test interroge réellement chaque route sans
identifiant.
- **Révocation immédiate.** Le compte est relu en base à chaque requête : une désactivation ou un - **Révocation immédiate.** Le compte est relu en base à chaque requête : une désactivation ou un
changement de rôle prend effet à la requête suivante, pas au bout de 15 minutes. changement de rôle prend effet à la requête suivante, pas au bout de 15 minutes.
- **Limitation de débit à fenêtre glissante** sur trois clés, évaluée avant le hachage. Pas de - **Limitation de débit à fenêtre glissante** sur trois clés, évaluée avant le hachage. Pas de
@@ -146,10 +169,12 @@ consolidée.
- **Caviardage des journaux** : jetons, empreintes Argon2, mots de passe et cookies sont - **Caviardage des journaux** : jetons, empreintes Argon2, mots de passe et cookies sont
expurgés avant écriture. expurgés avant écriture.
- **Documentation interactive fermée** en préproduction et en production, `/metrics` derrière un - **Documentation interactive fermée** en préproduction et en production, `/metrics` derrière un
jeton facultatif, sonde de disponibilité qui ne publie plus la version de TimescaleDB. jeton, exigé dès que la supervision tourne, sonde de disponibilité qui ne publie plus la version de TimescaleDB.
- **CI backend bloquante** : format, lint, typage strict et tests avec seuil de couverture. - **CI backend bloquante** : format, lint, typage strict et tests avec seuil de couverture.
- **Conteneur backend non-root**, déclaré dans `apps/backend/Dockerfile`. - **Conteneur backend non-root**, déclaré dans `apps/backend/Dockerfile`.
- **Terminaison TLS au frontal** : un reverse proxy Nginx est le seul service publié, il redirige - **Terminaison TLS au proxy de chaque environnement**, derrière un frontal SNI qui est le seul
composant publié sur la machine et aiguille sans déchiffrer. Certificats Let's Encrypt par
DNS-01 ([ADR 0018](../adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md)). Le proxy redirige
80 vers 443, sert le SPA et l'API sous la même origine, pose **HSTS** et **CSP** que 80 vers 443, sert le SPA et l'API sous la même origine, pose **HSTS** et **CSP** que
l'application refuse délibérément de poser, et ajoute une **limitation de débit au frontal** l'application refuse délibérément de poser, et ajoute une **limitation de débit au frontal**
distincte de celle de l'application. Voir distincte de celle de l'application. Voir
@@ -167,9 +192,9 @@ consolidée.
arrêteraient une application compromise. Même raison de report. arrêteraient une application compromise. Même raison de report.
- **Portée par site** dans l'autorisation : les rôles sont globaux, un opérateur du site A peut - **Portée par site** dans l'autorisation : les rôles sont globaux, un opérateur du site A peut
agir sur le site B. C'est la limite connue du modèle. agir sur le site B. C'est la limite connue du modèle.
- **Certificat reconnu** : aucun nom de domaine public ne résout vers la machine, donc le défi - **Approbation humaine avant la production** : l'environnement GitHub `prod` n'accepte que
HTTP-01 de Let's Encrypt ne peut pas aboutir. Le certificat servi est auto-signé, le chemin ACME `main` mais n'exige aucun relecteur, et aucune branche n'est protégée. Réglage réservé à
est livré et documenté mais pas exercé. l'administratrice du dépôt.
- **Analyse des images de conteneur** dans la CI. Celle des dépendances, elle, est en place - **Analyse des images de conteneur** dans la CI. Celle des dépendances, elle, est en place
(`pip-audit`, `npm audit`, Dependabot sur 5 écosystèmes), de même que le SAST Bandit. Voir (`pip-audit`, `npm audit`, Dependabot sur 5 écosystèmes), de même que le SAST Bandit. Voir
[50-cicd.md](50-cicd.md). [50-cicd.md](50-cicd.md).
@@ -188,4 +213,15 @@ Elles vivent dans `../adr/`, pas ici.
| [0006](../adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` | | [0006](../adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
| [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) | Un projet Compose par environnement sur la VM ENI, déployé par un runner auto-hébergé (deux environnements à l'origine, trois depuis l'ADR 0017) |
| [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 |
| [0014](../adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md) | Un pipeline CI unique appelle les workflows de composant et conditionne le déploiement |
| [0015](../adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md) | Les tests de bout en bout et de charge visent la stack Compose déployée |
| [0016](../adr/0016-supervision-en-profil-compose.md) | La supervision vit dans un profil Compose, active en prod |
| [0017](../adr/0017-environnement-dev-a-la-demande.md) | Un troisième environnement, `dev`, déployé à la demande depuis n'importe quelle branche |
| [0018](../adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md) | Noms publics, certificats Let's Encrypt par DNS-01 et frontal SNI sans port |
| [0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md) | Stockage objet Garage par environnement ; les chunks anciens de `reading` sont exportés en CSV gzip puis supprimés |
| [0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md) | Chiffrement au repos : coffre LUKS des volumes Docker de la VM, SSE-C des archives |
+176 -44
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` | | Trois projets Compose sur la VM ENI, dev, recette et production | Déploiement continu depuis GitHub | `Fait` |
| Provisionnement Terraform de la VM | Préparer la machine et enregistrer le runner | `Fait` |
| 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
@@ -35,6 +37,9 @@ flowchart TB
|---|---|---| |---|---|---|
| `db` | `timescale/timescaledb-ha:pg17` | Publié sur **5433** côté hôte, 5432 souvent déjà pris. `healthcheck` `pg_isready`, 12 tentatives, `start_period` 40s | | `db` | `timescale/timescaledb-ha:pg17` | Publié sur **5433** côté hôte, 5432 souvent déjà pris. `healthcheck` `pg_isready`, 12 tentatives, `start_period` 40s |
| `backend` | Construite depuis `apps/backend` | `depends_on: db, condition: service_healthy`. **N'embarque pas le source** : toute modification impose `docker compose up -d --build backend` | | `backend` | Construite depuis `apps/backend` | `depends_on: db, condition: service_healthy`. **N'embarque pas le source** : toute modification impose `docker compose up -d --build backend` |
| `garage` | `dxflrs/garage:v2.4.1` | S3 en `127.0.0.1:3900`, admin et `/metrics` en `127.0.0.1:3903`. `--single-node --default-bucket` : clé et bucket créés au premier démarrage, secrets par l'environnement (`GARAGE_*` du `.env`, garde dans le Makefile), `garage.toml` versionné sans secret dans `infra/garage`. Reçoit les archives du DAG `retention` ([ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)) |
| `prometheus`, `alertmanager`, `grafana`, exporteurs | Images épinglées par tag | Profil `monitoring`, jamais démarrés par `make dev`. `make monitoring-up` les lance en `--no-deps`. Voir [60-observabilite.md](60-observabilite.md) |
| `k6` | `grafana/k6` | Profil `load`, lancé par `make load-*` le temps d'un tir, sur le réseau du projet. Voir [`tests/load/README.md`](../../tests/load/README.md) |
**La boucle de développement n'utilise pas le service `backend`.** `make db-up` puis `make dev` : **La boucle de développement n'utilise pas le service `backend`.** `make db-up` puis `make dev` :
seule la base tourne en conteneur, l'API et `ng serve` tournent sur le poste avec le rechargement seule la base tourne en conteneur, l'API et `ng serve` tournent sur le poste avec le rechargement
@@ -49,20 +54,28 @@ Trois pièges sont documentés en tête du `docker-compose.yml`, ils ne se devin
- `db/init` est monté **fichier par fichier**. Monter le dossier masquerait les scripts d'init de - `db/init` est monté **fichier par fichier**. Monter le dossier masquerait les scripts d'init de
l'image, dont `timescaledb-tune`. Ajouter un fichier dans `db/init/` impose donc une ligne dans l'image, dont `timescaledb-tune`. Ajouter un fichier dans `db/init/` impose donc une ligne dans
le compose. Voir [`db/README.md`](../../db/README.md). le compose. Voir [`db/README.md`](../../db/README.md).
- `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais du webserver : - `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)
Trois services, `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`) :
| Service | Rôle | Points notables | | Service | Rôle | Points notables |
|---|---|---| |---|---|---|
| `airflow-init` | Migre la base de métadonnées, crée le compte admin | Conteneur jetable (`restart: "no"`), ne redémarre jamais. `webserver`/`scheduler` attendent qu'il se termine avec succès | | `airflow-init` | Migre la base de métadonnées, crée le compte admin | Conteneur jetable (`restart: "no"`), ne redémarre jamais. `api-server`, `dag-processor` et `scheduler` attendent qu'il se termine avec succès |
| `airflow-webserver` | UI, port `8080` | `LocalExecutor` : n'exécute aucune tâche lui-même | | `airflow-apiserver` | UI et API REST (`/api/v2`), port `8080` | `LocalExecutor` : n'exécute aucune tâche lui-même. Sert aussi l'Execution API que les tâches appellent, d'où le secret JWT partagé |
| `airflow-dag-processor` | Parse `dags/` et publie les DAGs sérialisés | Composant à part entière depuis Airflow 3 : le scheduler ne lit plus les fichiers de DAG |
| `airflow-scheduler` | Planifie et **exécute** les tâches (`LocalExecutor`) | Les DAGs y tournent en sous-processus (`uv run --no-sync python -m ...`), c'est lui qui a besoin du volume `airflow_ml_state` | | `airflow-scheduler` | Planifie et **exécute** les tâches (`LocalExecutor`) | Les DAGs y tournent en sous-processus (`uv run --no-sync python -m ...`), c'est lui qui a besoin du volume `airflow_ml_state` |
Airflow 3 impose deux choses que le compose reflète : les tâches ne touchent plus la base de
métadonnées et passent par l'Execution API de l'`api-server`, avec un jeton signé par
`AIRFLOW_JWT_SECRET` (secret partagé entre conteneurs, jamais celui généré au démarrage) ; et
l'authentification par défaut (`SimpleAuthManager`) ne sait pas créer de compte, d'où le
`FabAuthManager` qui garde le compte admin posé par `airflow-init`. Pas de `triggerer` : aucun
opérateur déférable dans les DAGs.
Construits depuis `etl/airflow/Dockerfile`, contexte `.` (racine du repo, pas `etl/airflow/`) : Construits depuis `etl/airflow/Dockerfile`, contexte `.` (racine du repo, pas `etl/airflow/`) :
l'image doit pouvoir `COPY` les sources de `ml/` **et** de `apps/backend/` pour se synchroniser l'image doit pouvoir `COPY` les sources de `ml/` **et** de `apps/backend/` pour se synchroniser
deux environnements Python **3.14** (`/opt/ml/.venv` et `/opt/backend/.venv`, `uv sync --locked` à deux environnements Python **3.14** (`/opt/ml/.venv` et `/opt/backend/.venv`, `uv sync --locked` à
@@ -77,12 +90,28 @@ l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md).
| `ml_score` | `0 * * * *` | `enervision_ml.score`, dans `/opt/ml/.venv` | | `ml_score` | `0 * * * *` | `enervision_ml.score`, dans `/opt/ml/.venv` |
| `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations`, dans `/opt/backend/.venv` | | `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations`, dans `/opt/backend/.venv` |
| `historical_import` | manuelle | `app.etl.historical_import`, dans `/opt/backend/.venv` ; les fichiers de `data/raw` sont montés en lecture seule dans `/opt/data/raw` | | `historical_import` | manuelle | `app.etl.historical_import`, dans `/opt/backend/.venv` ; les fichiers de `data/raw` sont montés en lecture seule dans `/opt/data/raw` |
| `mock_api_import` | `45 * * * *` | `app.etl.mock_api_import`, dans `/opt/backend/.venv` ; importe depuis l'API Mock la mesure de l'heure pile précédant son déclenchement |
| `derive` | `30 5 * * *` | `app.monitoring.drift`, dans `/opt/backend/.venv` ; quotidien parce que sa fenêtre couvre 168 h, et sans reprise parce qu'une dérive n'est pas une panne passagère |
| `retention` | `20 3 * * *` | `app.etl.reading_retention`, dans `/opt/backend/.venv` ; exporte vers Garage (CSV gzip, SSE-C) chaque chunk de `reading` plus vieux que `READING_RETENTION_DAYS` puis le supprime par `drop_chunks` ; la nuit parce que la suppression verrouille `site` et `dataset` jusqu'au COMMIT |
Le DAG `historical_import` réutilise le pipeline historique existant sans dupliquer sa logique. Le DAG `historical_import` réutilise le pipeline historique existant sans dupliquer sa logique.
Il reste manuel, car le dataset sert à initialiser l'environnement. Le montage Il reste manuel, car le dataset sert à initialiser l'environnement. Le montage
`./data/raw:/opt/data/raw:ro` permet au scheduler de lire les fichiers CSV/JSON sans pouvoir les `./data/raw:/opt/data/raw:ro` permet au scheduler de lire les fichiers CSV/JSON sans pouvoir les
modifier. modifier.
Le DAG `mock_api_import` exécute le pipeline API Mock toutes les heures, à la minute `:45`.
Un `CronTriggerTimetable` explicite lui attribue un intervalle d'une heure, y compris lors d'un
déclenchement manuel, mais la fenêtre transmise au script backend part de l'heure pile qui
précède le déclenchement (pas de l'intervalle Airflow tel quel), pour que la mesure importée
tombe à :00 et non à :45, voir [40-data.md](40-data.md). Le pipeline charge la mesure dans les
tables communes `site` et `reading`. Le décalage à `:45` laisse quinze minutes avant le
scoring exécuté à l'heure pile, puis quinze minutes supplémentaires avant les alertes à `:15`.
`max_active_runs=1` empêche deux exécutions du DAG de se chevaucher.
Le DAG conserve `catchup=False` pour éviter un rattrapage massif depuis sa date de démarrage.
Une interruption du scheduler peut donc créer un intervalle manquant, qui devra être rejoué
explicitement par une opération de backfill.
**Pourquoi `alertes` tourne à la quinzième minute.** Sa règle `anomaly` compare une lecture à la **Pourquoi `alertes` tourne à la quinzième minute.** Sa règle `anomaly` compare une lecture à la
`prediction` du même instant, que `ml_score` écrit à l'heure pile. Le décalage laisse le scoring `prediction` du même instant, que `ml_score` écrit à l'heure pile. Le décalage laisse le scoring
finir. Aucune dépendance n'est déclarée entre les deux DAGs pour autant, ni `ExternalTaskSensor` ni finir. Aucune dépendance n'est déclarée entre les deux DAGs pour autant, ni `ExternalTaskSensor` ni
@@ -102,14 +131,14 @@ rend contraignant.
`airflow-init` s'appuie sur l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, `airflow-init` s'appuie sur l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`,
`_AIRFLOW_WWW_USER_*`) plutôt que sur un script maison : l'entrypoint porte le code de sortie, une `_AIRFLOW_WWW_USER_*`) plutôt que sur un script maison : l'entrypoint porte le code de sortie, une
migration ratée (typiquement la base `airflow` absente, cf. ci-dessous) fait échouer le service et migration ratée (typiquement la base `airflow` absente, cf. ci-dessous) fait échouer le service et
`webserver`/`scheduler` ne démarrent pas sur une base non migrée. Le mot de passe du compte admin `api-server`, `dag-processor` et `scheduler` ne démarrent pas sur une base non migrée. Le mot de passe du compte admin
passe par l'environnement, jamais par `argv` (ni `ps`, ni `docker compose config`). passe par l'environnement, jamais par `argv` (ni `ps`, ni `docker compose config`).
Les variables `AIRFLOW_*` ne sont volontairement pas en `${VAR:?}` : Compose interpole le fichier Les variables `AIRFLOW_*` ne sont volontairement pas en `${VAR:?}` : Compose interpole le fichier
entier avant de filtrer les services, une variable requise manquante casserait `make db-up`, entier avant de filtrer les services, une variable requise manquante casserait `make db-up`,
`make dev`... pour tout poste dont le `.env` est antérieur. Elles valent `${VAR:-}` et c'est `make dev`... pour tout poste dont le `.env` est antérieur. Elles valent `${VAR:-}` et c'est
`airflow-init` qui refuse de démarrer (clé Fernet, clé Flask, mot de passe ou `airflow-init` qui refuse de démarrer (clé Fernet, clé de session de l'API, secret JWT, mot de
`AIRFLOW_APP_SECRET_KEY` vides). passe ou `AIRFLOW_APP_SECRET_KEY` vides).
Le conteneur reçoit deux variables du backend en plus de `ML_DATABASE_URL` : `DATABASE_URL`, en Le conteneur reçoit deux variables du backend en plus de `ML_DATABASE_URL` : `DATABASE_URL`, en
dialecte asyncpg, et `APP_SECRET_KEY`, alimentée par `AIRFLOW_APP_SECRET_KEY`. Cette dernière est dialecte asyncpg, et `APP_SECRET_KEY`, alimentée par `AIRFLOW_APP_SECRET_KEY`. Cette dernière est
@@ -139,22 +168,51 @@ 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 trois
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
`docker-compose.yml`. Écrit et validé sur le poste, **jamais encore lancé sur le serveur de `docker-compose.yml`. En service sur la machine du groupe, une stack par environnement (sept
l'école**. Décision et motifs dans l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md). déploiements de production entre le 23/09 et le 24/09). Décision et motifs dans l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
```mermaid ```mermaid
flowchart LR flowchart LR
navigateur["Navigateur"] navigateur["Navigateur"]
subgraph machine["Machine on-premise"] subgraph machine["Machine on-premise"]
proxy["service proxy<br/>nginx:1.28-alpine<br/>:80 et :443"] proxy["service proxy<br/>nginx:1.31-alpine<br/>:80 et :443"]
front["service frontend<br/>nginx statique :3000"] front["service frontend<br/>nginx statique :3000"]
api["service backend<br/>uvicorn :8000"] api["service backend<br/>uvicorn :8000"]
db[("service db<br/>:5432")] db[("service db<br/>:5432")]
mail["service mailpit"] mail["service mailpit"]
sup["profil monitoring<br/>Prometheus, Alertmanager, Grafana"]
end end
navigateur -->|"HTTPS"| proxy navigateur -->|"HTTPS"| proxy
@@ -162,10 +220,14 @@ flowchart LR
proxy -->|"/api/"| api proxy -->|"/api/"| api
api --> db api --> db
api --> mail api --> mail
sup -->|"/metrics, jeton"| api
sup -->|"rôle supervision, lecture seule"| db
sup -->|"alertes par courriel"| mail
``` ```
Le proxy est **le seul service à publier des ports** sur le réseau. Backend et frontend ne sont Sur le poste, le proxy est **le seul service à publier des ports** sur le réseau ; sur la
plus publiés du tout, la base et l'interface Mailpit sont ramenées sur `127.0.0.1`, donc joignables machine, même lui n'écoute que sur `127.0.0.1`, derrière le frontal SNI (section suivante).
Backend et frontend ne sont plus publiés du tout, la base et l'interface Mailpit sont ramenées sur `127.0.0.1`, donc joignables
par tunnel SSH et pas autrement. Le détail du routage, les deux modes d'obtention du certificat et par tunnel SSH et pas autrement. Le détail du routage, les deux modes d'obtention du certificat et
la commande de validation hors exécution sont dans [`infra/proxy/README.md`](../../infra/proxy/README.md). la commande de validation hors exécution sont dans [`infra/proxy/README.md`](../../infra/proxy/README.md).
@@ -176,35 +238,104 @@ Deux conséquences se propagent jusqu'à l'application, et elles ne se devinent
- `APP_TRUST_PROXY_HEADERS` passe à vrai en même temps, sinon la limitation de débit par IP - `APP_TRUST_PROXY_HEADERS` passe à vrai en même temps, sinon la limitation de débit par IP
compte sur l'IP du proxy et devient globale. compte sur l'IP du proxy et devient globale.
### Deux environnements sur la même machine ### Trois environnements sur la même machine
Statut : `En cours`, la machine n'étant pas encore provisionnée. Décision et motifs dans Statut : `Fait`. Décision et motifs dans
l'[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md). l'[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md), étendue à un troisième
La VM `eadl-2025-nantes-g3` portera la recette et la production, chacune dans son clone du dépôt, environnement par l'[ADR 0017](../adr/0017-environnement-dev-a-la-demande.md) ; noms,
son `.env` et son projet Compose. Le nom de projet préfixe volumes, réseau et conteneurs : rien certificats et frontal sans port dans l'[ADR 0018](../adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md).
n'est partagé. `scripts/provision-host.sh` prépare les deux dossiers, génère les secrets et les La VM `eadl-2025-nantes-g3` porte le développement, la recette et la production, chacun dans son
certificats, et ne démarre rien. clone du dépôt, son `.env` et son projet Compose. Le nom de projet préfixe volumes, réseau et
conteneurs : rien n'est partagé. `scripts/provision-host.sh` prépare les trois dossiers, génère
les secrets et les certificats, et ne démarre rien.
| | Recette | Production | | | Développement | Recette | Production |
|---|---|---| |---|---|---|---|
| Branche, environnement GitHub | `dev`, `rec` | `main`, `prod` | | Branche, environnement GitHub | toute branche lancée à la main, `dev` | `dev`, `rec` | `main`, `prod` |
| Dossier, projet Compose | `/srv/enervision/rec`, `enervision-rec` | `/srv/enervision/prod`, `enervision-prod` | | Dossier, projet Compose | `/srv/enervision/dev`, `enervision-dev` | `/srv/enervision/rec`, `enervision-rec` | `/srv/enervision/prod`, `enervision-prod` |
| URL | `https://rec.enervision.local:8443` | `https://enervision.local` | | URL | `https://dev.enervision-g3.dynv6.net` | `https://rec.enervision-g3.dynv6.net` | `https://prod.enervision-g3.dynv6.net` |
| Proxy HTTP, HTTPS | `127.0.0.1:8081`, `8443` | `80`, `443` | | Proxy HTTP, HTTPS, PROXY protocol, sur `127.0.0.1` | `8083`, `9443`, `9444` | `8081`, `8443`, `8444` | `10080`, `10443`, `10444` |
| PostgreSQL, Mailpit, Airflow, sur `127.0.0.1` | `5434`, `8026`, `8082` | `5433`, `8025`, `8080` | | PostgreSQL, Mailpit, Airflow, sur `127.0.0.1` | `5435`, `8027`, `8084` | `5434`, `8026`, `8082` | `5433`, `8025`, `8080` |
| Garage S3, admin, sur `127.0.0.1` | `3920`, `3923` | `3910`, `3913` | `3900`, `3903` |
| Supervision (profil `monitoring`) | à la demande, `make monitoring-up` | à la demande, `make monitoring-up` | active, `COMPOSE_PROFILES=monitoring` |
| Grafana, Prometheus, Alertmanager, sur `127.0.0.1` | `3003`, `9092`, `9095` | `3002`, `9091`, `9094` | `3001`, `9090`, `9093` |
Les deux noms d'hôte visent la même IP, à déclarer dans le `/etc/hosts` des postes. Deux noms Les trois noms sont publics chez dynv6 et visent l'IP privée de la VM : rien à déclarer sur
distincts sont nécessaires : le cookie `__Secure-ev_refresh` est posé par hôte, pas par port. les postes du réseau de l'école, et rien n'est joignable hors de ce réseau. Trois noms distincts
La redirection HTTP de la recette est ramenée sur la boucle locale parce que la configuration sont nécessaires : le cookie `__Secure-ev_refresh` est posé par hôte, pas par port.
Nginx renvoie vers `https://$host` sans port, c'est-à-dire vers la production.
Aucune stack ne publie hors de la boucle locale. Le frontal `infra/front`, sur le réseau de
l'hôte, écoute 80 et 443 : il redirige le premier, et aiguille le second d'après le nom demandé
(SNI) vers l'écouteur PROXY protocol de la stack visée, sans déchiffrer le TLS. Chaque stack
garde son certificat Let's Encrypt, obtenu par défi DNS-01 (`make tls-dns01`) et renouvelé à
chaque déploiement ainsi que chaque nuit par `/etc/cron.d/enervision-tls`.
Le déploiement est décrit dans [50-cicd.md](50-cicd.md) : un runner GitHub Actions installé sur Le déploiement est décrit dans [50-cicd.md](50-cicd.md) : un runner GitHub Actions installé sur
la VM aligne le dossier sur la branche poussée et lance `make stack-up`. la VM aligne le dossier sur la branche poussée et lance `make stack-up`.
### Provisionnement de la machine
Statut : `Fait`. Appliqué : le state local porte Docker, les trois environnements et le runner ;
le coffre LUKS n'est pas appliqué, la machine étant un conteneur LXC
([ADR 0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md)). 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: trois clones, trois .env, trois 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.
### Coffre LUKS des volumes Docker (issue #42)
Statut : `Bloqué par la plateforme`. La machine ENI est un conteneur LXC sur Proxmox, sans
device-mapper ni loop : `scripts/coffre-luks.sh` s'y arrête sur sa garde, et le chiffrement du
disque de ce conteneur relève de l'hôte Proxmox, demandé à l'administrateur de l'école. Ce qui
est en place aujourd'hui : le SSE-C des archives déposées sur Garage. Le runbook ci-dessous vaut
pour une vraie VM (cible k3s, ou remplacement du conteneur). Décision et modèle de menace dans
l'[ADR 0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md). Un fichier image LUKS2
(`/srv/enervision/coffre.img`, clé `/root/enervision-coffre.key`) est monté sur
`/srv/enervision/coffre`, et `/var/lib/docker/volumes` est bind-monté depuis ce coffre : les
volumes des trois environnements sont chiffrés au repos sans qu'un fichier Compose change.
`scripts/coffre-luks.sh` pose tout, rejouable ; Terraform le joue aussi quand `coffre_taille` est
renseignée. Prérequis : deux fois la taille actuelle des volumes libre sur le disque, le temps de
la migration. Runbook, joué en root sur la VM, coupure des trois environnements d'une à trois
minutes :
```bash
scp scripts/coffre-luks.sh root@<IP-VM-G3>:/tmp/
ssh root@<IP-VM-G3> 'COFFRE_TAILLE=30G COFFRE_MIGRER=1 bash /tmp/coffre-luks.sh'
ssh root@<IP-VM-G3> 'findmnt /var/lib/docker/volumes && lsblk /dev/mapper/enervision-coffre && docker ps'
ssh root@<IP-VM-G3> 'curl -k https://localhost:10443/api/v1/health/ready'
```
Ensuite, dans cet ordre : sauvegarder `/root/enervision-coffre.key` hors de la VM (sans elle, les
trois bases sont perdues) ; redémarrer la machine et rejouer les deux vérifications, ce qui valide
l'ordonnancement crypttab, fstab et drop-in Docker ; alors seulement supprimer la copie en clair,
`rm -rf /var/lib/docker/volumes.avant-coffre`. Le script affiche ces trois étapes à la fin et
n'exécute jamais la suppression.
## Cible à terme, k3s ## Cible à terme, k3s
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. 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
@@ -252,13 +383,15 @@ 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` | | Certificats Let's Encrypt par défi DNS-01 sur la machine, auto-signé sur le poste | La machine n'a qu'une adresse privée : le défi HTTP-01 ne peut pas aboutir, le défi DNS-01 ne demande qu'un enregistrement TXT dans la zone publique | `make tls-dns01`, `scripts/provision-host.sh`, `scripts/tls-selfsigned.sh`, [ADR 0018](../adr/0018-noms-publics-certificats-dns01-et-frontal-sni.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) | | 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) |
| Runner GitHub Actions auto-hébergé sur la VM | Les runners hébergés par GitHub ne joignent pas une adresse privée d'école | `.github/workflows/deploy.yml` | | Runner GitHub Actions auto-hébergé sur la VM | Les runners hébergés par GitHub ne joignent pas une adresse privée d'école | `.github/workflows/deploy.yml` |
| Secrets dans le `.env` de chaque environnement, sur la machine | Ni dans git, ni dans GitHub : le runner n'a rien à recevoir | `scripts/provision-host.sh` | | Secrets dans le `.env` de chaque environnement, sur la machine | Ni dans git, ni dans GitHub : le runner n'a rien à recevoir | `scripts/provision-host.sh` |
@@ -272,12 +405,14 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
| API | `8000` | Identique en conteneur et hors conteneur | | API | `8000` | Identique en conteneur et hors conteneur |
| Frontend, `ng serve` | `4200` | Boucle de développement. Valeur par défaut d'`APP_CORS_ORIGINS` | | Frontend, `ng serve` | `4200` | Boucle de développement. Valeur par défaut d'`APP_CORS_ORIGINS` |
| Frontend en conteneur | `3000` | Ce qu'écoute le nginx de l'image, en conteneur comme côté hôte | | Frontend en conteneur | `3000` | Ce qu'écoute le nginx de l'image, en conteneur comme côté hôte |
| Reverse proxy | `80` et `443` | Les seuls ports publiés par `docker-compose.prod.yml`, via `PROXY_HTTP_PORT` et `PROXY_HTTPS_PORT`. 80 ne sert que la redirection et le défi ACME. La recette publie `8443` et `127.0.0.1:8081` | | Reverse proxy | `80` et `443`, plus `4443` | Les seuls ports publiés par `docker-compose.prod.yml`, via `PROXY_HTTP_PORT`, `PROXY_HTTPS_PORT` et `PROXY_FRONT_PORT`. 80 ne sert que la redirection et le défi ACME ; 4443 n'accepte que le PROXY protocol du frontal. Sur la VM, tous sur `127.0.0.1` |
| Frontal SNI de la VM | `80` et `443` de l'hôte | `infra/front`, seul composant exposé sur le réseau de l'école (ADR 0018) |
| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable | | SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable |
| Base applicative | `enervision` | Variable `POSTGRES_DB` | | Base applicative | `enervision` | Variable `POSTGRES_DB` |
| Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` | | Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` |
| Base de métadonnées Airflow | `airflow` | Créée par `db/init/120-airflow-database.sql`, même conteneur `db` | | Base de métadonnées Airflow | `airflow` | Créée par `db/init/120-airflow-database.sql`, même conteneur `db` |
| Webserver Airflow | `8080` | `make airflow-up`. Scheduler et webserver ne publient que ce port ; les tâches (`LocalExecutor`) tournent côté scheduler, sans port propre | | Grafana, Prometheus, Alertmanager | `3001`, `9090`, `9093` | Sur `127.0.0.1` seulement, profil `monitoring`. `GRAFANA_PORT`, `PROMETHEUS_PORT`, `ALERTMANAGER_PORT`. 3000 est pris par le frontend |
| API server Airflow | `8080` | `make airflow-up`. Api-server, scheduler et dag-processor ne publient que ce port ; les tâches (`LocalExecutor`) tournent côté scheduler, sans port propre |
## Le trou vers k3s ## Le trou vers k3s
@@ -290,10 +425,7 @@ question à trancher, avant toute ressource Kubernetes.
- **Quel ingress** remplace Traefik le jour de la bascule k3s. Qui termine le TLS est tranché par - **Quel ingress** remplace Traefik le jour de la bascule k3s. Qui termine le TLS est tranché par
l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md), mais la réponse vaut pour la l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md), mais la réponse vaut pour la
topologie Compose, pas pour Kubernetes. topologie Compose, pas pour Kubernetes.
- **Quel nom de domaine public**, sans lequel Let's Encrypt reste hors d'atteinte et le certificat
reste auto-signé.
- **Quel registre d'images**, et comment il est alimenté sans CI. - **Quel registre d'images**, et comment il est alimenté sans CI.
- **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`.
+60 -8
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"]
@@ -103,7 +103,17 @@ démarre ne prouve rien sur la base, la première connexion réelle a lieu au pr
| `APP_LOGIN_MAX_FAILURES_PER_IDENTIFIER` | `50` | Signature d'une attaque distribuée | | `APP_LOGIN_MAX_FAILURES_PER_IDENTIFIER` | `50` | Signature d'une attaque distribuée |
| `APP_TRUST_PROXY_HEADERS` | `false` | À vrai derrière un proxy, sinon le compteur par IP devient global | | `APP_TRUST_PROXY_HEADERS` | `false` | À vrai derrière un proxy, sinon le compteur par IP devient global |
| `APP_EXPOSE_API_DOCS` | déduit | Faux en `staging` et `prod` si non renseigné | | `APP_EXPOSE_API_DOCS` | déduit | Faux en `staging` et `prod` si non renseigné |
| `APP_METRICS_TOKEN` | absent | Si présent, `/metrics` exige `Authorization: Bearer` | | `APP_METRICS_TOKEN` | absent | Si présent et non vide, `/metrics` exige `Authorization: Bearer`. Vide vaut absent |
| `APP_S3_ENDPOINT_URL` | absent | Endpoint S3 des archives ; `http://garage:3900` posé par Compose sur `airflow-scheduler`. Vide vaut absent |
| `APP_S3_REGION` | `garage` | Région déclarée au client S3 |
| `APP_S3_ACCESS_KEY` | absent | Identifiant de la clé Garage. Vide vaut absent |
| `APP_S3_SECRET_KEY` | absent | Secret de la clé Garage, `SecretStr`. Vide vaut absent |
| `APP_S3_BUCKET` | absent | Bucket des archives, `enervision-archives` en Compose. Vide vaut absent |
| `APP_S3_SSE_KEY` | absent | Base64 de 32 octets, clé SSE-C des archives, `SecretStr`. Vide vaut absent |
| `APP_READING_RETENTION_DAYS` | `1095` | Profondeur de `reading` en base chaude, 30 jours minimum |
L'API n'exige aucun des réglages `APP_S3_*` ni `APP_READING_RETENTION_DAYS` : seul
`app.etl.reading_retention` les réclame, et refuse de partir sans endpoint, clés et bucket.
Cinq gardes refusent de démarrer plutôt que de laisser passer une erreur silencieuse : Cinq gardes refusent de démarrer plutôt que de laisser passer une erreur silencieuse :
secret de moins de 32 caractères ou laissé à sa valeur d'exemple, `debug` en `staging` ou secret de moins de 32 caractères ou laissé à sa valeur d'exemple, `debug` en `staging` ou
@@ -151,6 +161,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 +234,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 +366,10 @@ pas prise :
| `license_info` | Aucune licence n'est choisie | | `license_info` | Aucune licence n'est choisie |
| `contact` | Aucun canal de support n'existe | | `contact` | Aucun canal de support n'existe |
Deux schémas de sécurité sont déclarés : `Jeton d'accès` pour le porteur JWT, et Deux schémas de sécurité sont déclarés : `JetonAcces` pour le porteur JWT, et
`Cookie de rafraîchissement` pour `/auth/refresh` et `/auth/logout`. **Le second est purement `CookieRafraichissement` pour `/auth/refresh` et `/auth/logout`, des noms ASCII délibérés (issue
#41 : un outillage tiers comme ZAP peut mal analyser un nom de schéma accentué dans le contrat).
**Le second est purement
documentaire** : son `auto_error=False` garantit qu'il ne décide d'aucun refus. Le passer à vrai documentaire** : son `auto_error=False` garantit qu'il ne décide d'aucun refus. Le passer à vrai
ferait répondre 403 avant d'atteindre `lit_le_cookie()`, et `/auth/refresh` cesserait de rendre le ferait répondre 403 avant d'atteindre `lit_le_cookie()`, et `/auth/refresh` cesserait de rendre le
401 sur lequel le frontend déclenche sa déconnexion. 401 sur lequel le frontend déclenche sa déconnexion.
@@ -396,8 +433,13 @@ Le reste, par ordre de surface :
écriture des journaux. C'est la troisième ligne de défense : la première est de ne rien passer écriture des journaux. C'est la troisième ligne de défense : la première est de ne rien passer
de secret au logger, la deuxième de ne jamais mettre un jeton dans une URL. de secret au logger, la deuxième de ne jamais mettre un jeton dans une URL.
- En-têtes posés par l'application : `X-Content-Type-Options`, `X-Frame-Options`, - En-têtes posés par l'application : `X-Content-Type-Options`, `X-Frame-Options`,
`Referrer-Policy`, plus `Cache-Control: no-store` sur `/auth/*`. HSTS et CSP appartiennent au `Referrer-Policy`, `Cross-Origin-Resource-Policy: same-origin`, plus `Cache-Control: no-store`
terminateur TLS, que l'application ne connaît pas : le reverse proxy les pose sur `/auth/*`. Le CORP est fixé à `same-origin` parce qu'aucun client légitime ne charge l'API
en `no-cors` (image, script, média) depuis une autre origine : le frontend l'appelle en relatif
(`/api/v1`), sur sa propre origine, via `proxy.conf.json` en dev et le reverse proxy nginx
(`infra/proxy/conf.d/enervision.conf`) en recette et en production. Les appels `HttpClient`, en
mode `cors`, n'y sont de toute façon pas soumis. HSTS et CSP appartiennent au terminateur TLS, que
l'application ne connaît pas : le reverse proxy les pose
([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)).
- Le conteneur tourne en utilisateur non-root, avec un `HEALTHCHECK` sur `/api/v1/health/live`. - Le conteneur tourne en utilisateur non-root, avec un `HEALTHCHECK` sur `/api/v1/health/live`.
- TLS, limitation de débit au frontal et journal d'accès sont portés par le reverse proxy. - TLS, limitation de débit au frontal et journal d'accès sont portés par le reverse proxy.
@@ -408,7 +450,17 @@ Le reste, par ordre de surface :
- Journalisation par `dictConfig` : format console en développement, JSON dès `APP_ENV=prod`. - Journalisation par `dictConfig` : format console en développement, JSON dès `APP_ENV=prod`.
`sqlalchemy.engine` est forcé à `WARNING` pour ne pas noyer les journaux. `sqlalchemy.engine` est forcé à `WARNING` pour ne pas noyer les journaux.
- `/metrics` au format Prometheus. **Aucun collecteur ne le lit** : `monitoring/` est vide. - `/metrics` au format Prometheus (`prometheus-fastapi-instrumentator`), scruté toutes les 15 s
par Prometheus sous le profil `monitoring` ([60-observabilite.md](60-observabilite.md)).
- **Séries publiées.** `http_requests_total` par route, méthode et classe de statut, et
`http_request_duration_seconds` par route, avec des seaux de 50 ms à 2,5 s autour du seuil
de charge de 500 ms (ADR 0015). Aussi `http_request_duration_highr_seconds`, fin mais sans
libellé de route, et les métriques du processus.
- **Exclusions.** Les sondes `/health/*` et `/metrics` lui-même sont exclus : la sonde Docker
de 30 s fausserait débit et latences.
- **Un registre par application** (`_registre_de_metriques()` dans `main.py`). Le registre
global de `prometheus_client` n'accepte chaque métrique qu'une fois : toute application créée
après la première, dans les tests notamment, ne mesurait rien.
## Tests ## Tests
@@ -436,7 +488,7 @@ Quatre fichiers méritent d'être connus avant de toucher à l'authentification
- **Rôles PostgreSQL cantonnés** pour l'ETL et le travail d'apprentissage, plus le `REVOKE` sur - **Rôles PostgreSQL cantonnés** pour l'ETL et le travail d'apprentissage, plus le `REVOKE` sur
`audit_log`. Dette assumée, décrite dans les ADR 0003 et 0004. `audit_log`. Dette assumée, décrite dans les ADR 0003 et 0004.
- **Pagination et fenêtrage** : posés sur `GET /readings` (fenêtre plafonnée à 90 jours, - **Pagination et fenêtrage** : posés sur `GET /readings` (fenêtre plafonnée à 90 jours,
`limit`/`offset` plafonné à 2000), mais toujours en `limit`/`offset` simple — pas de curseur ni `limit`/`offset` plafonné à 2000), mais toujours en `limit`/`offset` simple : pas de curseur ni
de plan de secours si un `offset` élevé sur une fenêtre dense devient lent en pratique. de plan de secours si un `offset` élevé sur une fenêtre dense devient lent en pratique.
`statement_timeout` reste absent au niveau de la connexion, donc rien n'empêche une requête `statement_timeout` reste absent au niveau de la connexion, donc rien n'empêche une requête
individuelle de tourner longtemps si les plafonds au-dessus d'elle s'avéraient insuffisants. individuelle de tourner longtemps si les plafonds au-dessus d'elle s'avéraient insuffisants.
+6 -4
View File
@@ -4,8 +4,9 @@ Application Angular 22, 100 % standalone, testée avec Vitest. Source dans `apps
## État actuel ## État actuel
Statut : `En cours`. L'application sert le tableau de bord, la liste et le détail des sites, la Statut : `En cours`. L'application sert le tableau de bord, la liste et le détail des sites, les
supervision des capteurs (admin) et le flux des alertes actives, tous branchés sur l'API réelle. recommandations, la supervision des capteurs (admin) et le flux des alertes actives, tous
branchés sur l'API réelle.
Ce qui est en place : Ce qui est en place :
@@ -13,10 +14,11 @@ Ce qui est en place :
- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et - `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et
`provideHttpClient(withInterceptors([authInterceptor, mockApiInterceptor]))`. `provideHttpClient(withInterceptors([authInterceptor, mockApiInterceptor]))`.
- Des routes en composants différés (`/dashboard`, `/sites`, `/sites/:siteId`, - Des routes en composants différés (`/dashboard`, `/sites`, `/sites/:siteId`,
`/monitoring/sensors` réservée au rôle `admin`) et une redirection depuis la racine. `/recommendations`, `/monitoring/sensors` réservée au rôle `admin`, et les pages
d'authentification) et une redirection depuis la racine.
- `core/services` porte un service HTTP par domaine (`StatsService`, `AlertsService` avec ses - `core/services` porte un service HTTP par domaine (`StatsService`, `AlertsService` avec ses
filtres `site_id` et `severity`, `PredictionsService`, `SitesService`, `ReadingsService`, filtres `site_id` et `severity`, `PredictionsService`, `SitesService`, `ReadingsService`,
`SensorsService`, `AuthService`), `core/interceptors` l'intercepteur de fixtures et l'intercepteur `RecommendationsService`, `SensorsService`, `AuthService`), `core/interceptors` l'intercepteur de fixtures et l'intercepteur
d'authentification (jeton porteur, rafraîchissement sur 401), `core/guards` la garde `authGuard`. d'authentification (jeton porteur, rafraîchissement sur 401), `core/guards` la garde `authGuard`.
- `features/` porte une page par domaine. `shared/components` porte la jauge de consommation et - `features/` porte une page par domaine. `shared/components` porte la jauge de consommation et
les graphiques Chart.js, le widget `app-alert-feed` (flux d'alertes filtrable par site et les graphiques Chart.js, le widget `app-alert-feed` (flux d'alertes filtrable par site et
@@ -136,8 +136,10 @@ origine, en HTTPS**. C'est cela, et rien d'autre, qui rend le cookie `__Secure-e
utilisable : servi en HTTP simple ou depuis une autre origine, il n'est jamais posé et utilisable : servi en HTTP simple ou depuis une autre origine, il n'est jamais posé et
l'authentification ne survit pas à un rechargement de page. l'authentification ne survit pas à un rechargement de page.
Ce qui reste à surveiller : le certificat est auto-signé tant qu'aucun domaine public ne résout Sur la machine, le certificat vient de Let's Encrypt par DNS-01
vers la machine. Un navigateur qui refuse l'exception refusera aussi le cookie. ([ADR 0018](../adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md)) : le navigateur n'a
aucune exception à accepter. Sur le poste, il reste auto-signé, et un navigateur qui refuse
l'exception refusera aussi le cookie.
Et au moins une fois avant la soutenance, lancer le front **sans le proxy**, en cross-origin Et au moins une fois avant la soutenance, lancer le front **sans le proxy**, en cross-origin
réel : c'est le seul moyen d'exercer le préflight CORS et `SameSite`, que la même origine masque. réel : c'est le seul moyen d'exercer le préflight CORS et `SameSite`, que la même origine masque.
+138 -36
View File
@@ -16,8 +16,9 @@ L'ingestion des **mesures** est implémentée pour les deux sources du MVP, le d
l'API Mock. Celle des **alertes** de l'API Mock, `/alerts`, reste à faire : voir l'API Mock. Celle des **alertes** de l'API Mock, `/alerts`, reste à faire : voir
l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md). Les alertes `source='enervision'`, l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md). Les alertes `source='enervision'`,
elles, sont produites par la détection interne, désormais ordonnancée par le DAG Airflow `alertes` elles, sont produites par la détection interne, désormais ordonnancée par le DAG Airflow `alertes`
(issue #116). L'orchestration de l'ingestion, les agrégats continus, la compression et la (issue #116). L'orchestration de l'ingestion, les agrégats continus et la compression restent
rétention restent des cibles. des cibles. La rétention de `reading` est faite : chaque chunk plus vieux que la borne est exporté
vers Garage puis supprimé (issue #36, section « Rétention et archivage » ci-dessous).
## Trois emplacements, trois rôles ## Trois emplacements, trois rôles
@@ -27,7 +28,7 @@ au mauvais endroit ne s'exécute jamais, ou s'exécute deux fois.
| Emplacement | Contenu | Quand ça s'exécute | | Emplacement | Contenu | Quand ça s'exécute |
|---|---|---| |---|---|---|
| `db/init/` | Extensions, bases annexes | **Une seule fois**, à la première initialisation du conteneur, quand `PGDATA` est vide. Ne rejoue jamais | | `db/init/` | Extensions, bases annexes | **Une seule fois**, à la première initialisation du conteneur, quand `PGDATA` est vide. Ne rejoue jamais |
| `db/migrations/` | SQL versionné qui ne découle pas du schéma applicatif : rétention, compression | À la main, aujourd'hui vide | | `db/migrations/` | SQL versionné qui ne découle pas du schéma applicatif : compression. La rétention de `reading` n'y est pas : une politique TimescaleDB ignorerait l'export, elle vit dans `apps/backend/app/etl/reading_retention.py`, ordonnancée par le DAG `retention` ([ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)) | À la main, aujourd'hui vide |
| `apps/backend/alembic/` | Le schéma exposé par l'API, et lui seul | `alembic upgrade head`, c'est `Base.metadata` qui fait foi | | `apps/backend/alembic/` | Le schéma exposé par l'API, et lui seul | `alembic upgrade head`, c'est `Base.metadata` qui fait foi |
Une hypertable relève des deux derniers : **Alembic crée la table, et le `create_hypertable()` Une hypertable relève des deux derniers : **Alembic crée la table, et le `create_hypertable()`
@@ -48,7 +49,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`.
@@ -72,10 +74,11 @@ Les mécanismes d'ingestion sont maintenant implémentés pour les deux sources
- le dataset historique CSV/JSON avec `historical_import.py` ; - le dataset historique CSV/JSON avec `historical_import.py` ;
- l'API Mock avec `mock_api_import.py`. - l'API Mock avec `mock_api_import.py`.
Les traitements sont actuellement exécutables directement depuis le backend. Les traitements restent exécutables directement depuis le backend, et Airflow les orchestre :
`historical_import` se lance à la demande, `mock_api_import` chaque heure à la minute 45.
L'orchestration avec Apache Airflow reste une cible, tout comme les agrégats continus, Les agrégats continus et la compression restent des cibles. La rétention est faite : le DAG `retention` exporte chaque chunk de `reading` plus
la compression et les politiques de rétention. vieux que `READING_RETENTION_DAYS` vers Garage, puis le supprime.
```mermaid ```mermaid
flowchart LR flowchart LR
@@ -85,12 +88,13 @@ flowchart LR
hist --> hy[("Hypertable reading")] hist --> hy[("Hypertable reading")]
api --> hy api --> hy
airflow["Airflow"] -.-> hist airflow["Airflow"] --> hist
airflow -.-> api airflow --> api
hy -.-> agg[("Agrégat continu")] hy -.-> agg[("Agrégat continu")]
hy -.-> comp["Compression"] hy -.-> comp["Compression"]
hy -.-> ret["Rétention"] hy --> ret["Rétention : export CSV gzip vers Garage, puis drop_chunks"]
ret --> garage[("Garage S3")]
agg -.-> backend["API FastAPI"] agg -.-> backend["API FastAPI"]
agg -.-> graf["Grafana"] agg -.-> graf["Grafana"]
@@ -103,6 +107,26 @@ Les flèches pointillées représentent les éléments encore prévus comme cibl
Les lectures de l'API et de Grafana viseront l'agrégat continu, pas la table brute : c'est tout Les lectures de l'API et de Grafana viseront l'agrégat continu, pas la table brute : c'est tout
l'intérêt de TimescaleDB, et cela doit rester vrai quand les volumes augmenteront. l'intérêt de TimescaleDB, et cela doit rester vrai quand les volumes augmenteront.
### Rétention et archivage (issue #36)
Statut : `Fait`.
`apps/backend/app/etl/reading_retention.py`, ordonnancé chaque nuit à 03:20 UTC par le DAG
`retention`, sélectionne dans `timescaledb_information.chunks` les chunks de `reading` dont
`range_end` est antérieur ou égal à `now() - READING_RETENTION_DAYS` : seul un chunk entièrement
plus vieux que la borne est éligible. Chaque chunk est lu via l'hypertable (`WHERE timestamp >=
range_start AND timestamp < range_end`), jamais via la table interne, sérialisé en CSV gzip
reproductible (jsonb et tableaux en JSON trié), puis écrit chiffré SSE-C sous la clé
`reading/<année>/reading_<début>_<fin>.csv.gz`, bornes UTC compactes. L'objet est relu et son
sha256 comparé à celui du corps envoyé ; en cas d'écart le chunk est conservé. Seulement alors
`drop_chunks('reading', older_than => range_end, newer_than => range_start)` supprime ce chunk et
lui seul, dans une transaction dédiée et courte : `drop_chunks` pose un verrou exclusif sur
`reading`, `site` et `dataset` jusqu'au COMMIT. Un objet déjà présent avec le même sha256 n'est pas
réécrit et un chunk supprimé n'est plus éligible : rejouer le DAG est sans effet, `--dry-run` liste
et mesure sans rien écrire. Le premier passage en production archive les chunks de janvier à
septembre 2023 ; la démo, ancrée au 31/12/2024, n'est pas touchée. Restauration manuelle :
télécharger l'objet avec la clé SSE-C, `gunzip`, `COPY` dans `reading` ; aucune commande fournie.
## Tables d'authentification ## Tables d'authentification
Statut : `Fait`. Statut : `Fait`.
@@ -238,8 +262,9 @@ colonne de temps : les index déclarés dans la révision le couvrent déjà.
devient ininterprétable dès le premier changement d'heure. devient ininterprétable dès le premier changement d'heure.
- **La colonne de partitionnement entre dans la clé primaire.** Dans `reading` elle s'appelle - **La colonne de partitionnement entre dans la clé primaire.** Dans `reading` elle s'appelle
`timestamp` : c'est un nom de colonne, son type reste `timestamptz`. `timestamp` : c'est un nom de colonne, son type reste `timestamptz`.
- **Les politiques de rétention et de compression** vont dans `db/migrations/`, pas dans Alembic : - **Les politiques de compression** vont dans `db/migrations/`, pas dans Alembic : elles ne
elles ne découlent pas du schéma applicatif. découlent pas du schéma applicatif. La rétention de `reading` est un traitement ETL
(`reading_retention.py`), pas une politique TimescaleDB : elle doit exporter avant de supprimer.
- **Tout modèle doit être importé dans `app/models/__init__.py`**, sans quoi - **Tout modèle doit être importé dans `app/models/__init__.py`**, sans quoi
`alembic revision --autogenerate` ne le voit pas et génère un `drop` de sa table. `alembic revision --autogenerate` ne le voit pas et génère un `drop` de sa table.
@@ -250,7 +275,9 @@ livrés : ce qui suit porte sur leur exploitation, plus sur leur forme.
- **Quelle granularité** conserver à long terme à l'ingestion : seconde, minute ou quart d'heure. - **Quelle granularité** conserver à long terme à l'ingestion : seconde, minute ou quart d'heure.
- **Quels agrégats continus** créer et sur quelles fenêtres. - **Quels agrégats continus** créer et sur quelles fenêtres.
- **Quelle profondeur de rétention** conserver en données brutes et à partir de quand compresser. - **Quelle profondeur de rétention** : répondu par l'issue #36. Trois ans en base chaude par
défaut (`READING_RETENTION_DAYS`, 1095 jours) ; au-delà, les chunks sont archivés en CSV gzip
sur Garage, chiffrés SSE-C, puis supprimés. Reste ouvert : à partir de quand compresser.
- **Multi-tenant ou non** : un site appartient-il à un client et faut-il cloisonner les lectures. - **Multi-tenant ou non** : un site appartient-il à un client et faut-il cloisonner les lectures.
## Modélisation détaillée des données ## Modélisation détaillée des données
@@ -261,8 +288,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 +313,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 +342,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
@@ -424,10 +463,29 @@ Les paramètres de ligne de commande disponibles pour l'import sont :
```text ```text
--start-time --start-time
--end-time --end-time
--limit
--dry-run --dry-run
``` ```
**Piège sur `limit`, corrigé dans le code plutôt que documenté** : l'API ne renvoie pas un flux à
un rythme naturel, elle répartit exactement `limit` lectures, espacées uniformément, sur toute la
fenêtre `[start_time, end_time)` demandée, la première au tout début de la fenêtre (vérifié
empiriquement en interrogeant directement l'API). Une fenêtre d'une heure avec `limit=1000`, le
réglage d'origine, renvoyait donc 1000 lectures espacées de 3,6 secondes à l'intérieur de cette
heure, pas une lecture horaire, incompatible avec les lags positionnels de `build_features`.
Plutôt que documenter la règle « `limit` = nombre d'heures de la fenêtre » et compter sur chaque
appelant pour la respecter, `limit_for_window()` la porte : `import_mock_api_history()` calcule
`limit` depuis la fenêtre reçue, refuse une fenêtre dont `start_time` ne tombe pas pile sur
l'heure (c'est elle qui ancre l'alignement), et refuse un intervalle de plus de 1000 heures (le
plafond `limit` de l'API). `--limit` n'existe donc plus côté CLI. Deux formes de fenêtre sont
gérées : un multiple entier d'heures (`limit` = ce nombre d'heures, une lecture par heure
espacée d'1h pile, chemin du backfill manuel) ou une fenêtre plus courte qu'une heure, ou qui
n'en est pas un multiple entier (`limit=1`, seule valeur qui reste alignée quand l'espacement
`durée / limit` ne peut valoir 1h pile). Le DAG `mock_api_import` est dans ce second cas : il
demande la fenêtre `[heure pile précédant le déclenchement, instant du déclenchement)`, plus
courte qu'une heure, plutôt que l'intervalle Airflow `[data_interval_start, data_interval_end)`
tel quel (`[:45, :45)`) qui aurait placé l'unique lecture à :45, hors de la grille horaire du
reste du schéma.
### Flux d'ingestion API Mock ### Flux d'ingestion API Mock
```text ```text
@@ -479,14 +537,15 @@ réponse est donc traitée comme une entrée hostile, conformément à API10 dan
[la traçabilité OWASP](owasp-traceabilite.md). Le risque premier n'est pas la fausse alerte, [la traçabilité OWASP](owasp-traceabilite.md). Le risque premier n'est pas la fausse alerte,
c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction.
Quatre garde-fous, tous dans `mock_api_import.py` : Cinq garde-fous, tous dans `mock_api_import.py` :
| Garde-fou | Mise en œuvre | | Garde-fou | Mise en œuvre |
|---|---| |---|---|
| Timeout | `APP_MOCK_API_TIMEOUT_SECONDS`, dix secondes par défaut | | Timeout | `APP_MOCK_API_TIMEOUT_SECONDS`, dix secondes par défaut |
| Taille de tableau plafonnée | `MAX_SITES` sites, et au plus `--limit` mesures par site | | Taille de tableau plafonnée | `MAX_SITES` sites, et au plus `limit` mesures par site, dérivé de la fenêtre par `limit_for_window()` |
| Bornes physiques | `PHYSICAL_BOUNDS`, une plage par grandeur | | Bornes physiques | `PHYSICAL_BOUNDS`, une plage par grandeur |
| Frontière d'anti-corruption | `build_site_row()` et `build_reading_row()`, qui ne recopient que les champs attendus | | Frontière d'anti-corruption | `build_site_row()` et `build_reading_row()`, qui ne recopient que les champs attendus |
| Refus de recouvrir l'historique | `refuse_if_overlaps_historical_dataset()`, voir ci-dessous |
Une valeur hors bornes, d'un type inattendu, `NaN` ou infinie devient `NULL`. Elle laisse sa Une valeur hors bornes, d'un type inattendu, `NaN` ou infinie devient `NULL`. Elle laisse sa
trace dans `null_reasons` sous la forme `out_of_physical_bounds:<colonne>`, et `data_quality` trace dans `null_reasons` sous la forme `out_of_physical_bounds:<colonne>`, et `data_quality`
@@ -497,6 +556,51 @@ d'origine intacte : rien n'est perdu, seule son exploitation est bornée.
Le plafond de taille s'applique après désérialisation de la réponse. Borner le corps HTTP Le plafond de taille s'applique après désérialisation de la réponse. Borner le corps HTTP
lui-même demanderait une lecture en flux, et reste à faire. lui-même demanderait une lecture en flux, et reste à faire.
### Réconciliation entre les deux sources (issue #15)
`historical_import` (source `csv`) et `mock_api_import` (source `api_history`) écrivent toutes
deux dans `reading`. Trois décisions ferment cette réconciliation :
- **Le trou temporel est accepté.** Le dataset historique s'arrête au 31/12/2024, et
`mock_api_import` n'importe que l'heure précédant chaque déclenchement : rien ne comble
automatiquement la période intermédiaire, et rien ne le pourra jamais, aucune mesure réelle
n'existe pour ces instants. Conséquence pour le ML, pas nouvelle mais que ce trou rend
définitive : `build_features()` calcule ses lags par `shift(n)` positionnel, et `train.py`
n'écarte que les lignes où `lag_168h` est `NaN`. Pour un site présent dans les deux sources, les
168 premières lectures `api_history` qui suivent le trou héritent donc de lags et de moyennes
glissantes calculés sur décembre 2024 (et tant que l'ingestion a moins de 7 jours, c'est le cas
de toutes les lectures). Même effet, plus ponctuel, pour chaque heure que le DAG manque
(`mock_api_import` en échec, Airflow arrêté). Aucun garde-fou ne détecte aujourd'hui un lag
calculé sur un écart réel différent de celui attendu ; issue de suivi à ouvrir.
- **Le recouvrement est refusé à l'ingestion.** `uq_reading_source` autorise deux lignes au même
`(site_id, timestamp)` dès que `source` diffère : rien dans le schéma n'empêche donc un import
Mock API manuel avec une fenêtre passée (le script accepte `--start-time`/`--end-time`
arbitraires) de dupliquer un point déjà couvert par le CSV. `import_mock_api_history()` appelle
`refuse_if_overlaps_historical_dataset()` avant toute écriture, y compris en `--dry-run` (le
contrôle est en lecture seule) et avant le moindre appel à l'API Mock : si la fenêtre demandée
recouvre au moins une lecture `source='csv'`, l'import est refusé (`ValueError`) plutôt que
d'écrire un doublon inter-source silencieux. Le contrôle ne porte que sur la fenêtre demandée,
pas sur les lectures reçues : `fetch_readings()` écarte donc toute lecture dont le `timestamp`
déborde de `[start_time, end_time)`, pour qu'une réponse hors fenêtre (bug du mock, ou hostile)
ne puisse pas le contourner. Ce contrôle compare des instants, pas des chaînes : `parse_datetime()`
pose `tzinfo=UTC` sur une entrée sans fuseau (même pattern que `_vers_utc()` dans
`app/services/reading.py`), sans quoi l'encodeur `timestamptz` d'asyncpg lirait un datetime naïf
dans le fuseau local du **processus**, correct dans le conteneur Airflow (UTC) mais décalé pour
un import manuel lancé depuis un poste en Europe/Paris.
- **Le pipeline ML déduplique en défense.** Le garde-fou ci-dessus protège l'ingestion, pas
la lecture : si un recouvrement se produisait malgré tout (import direct en base, contournement
du script), `ml/enervision_ml/data.py` ne doit pas casser silencieusement l'hypothèse de
`build_features` (« une ligne par `(site_id, timestamp)` »). `load_from_database()` et
`load_recent_from_database()` utilisent donc `SELECT DISTINCT ON (site_id, timestamp)`, `source
= 'csv'` gagnant sur `'api_history'` en cas d'égalité, l'historique étant une source vérifiée,
l'API Mock une entrée hostile (cf. ci-dessus). **Cette préférence est spécifique au chargeur
ML.** `GET /readings` renvoie les deux lignes sans les fusionner, et `DriftRepository` /
`ReadingRepository.latest_by_site()` / `.latest_for_site()` départagent par `reading_id` le plus
grand (en pratique la ligne insérée en dernier, pas forcément `csv`) : en cas de recouvrement, la
dérive comparerait alors une prévision à une valeur différente de celle sur laquelle le modèle a
appris. Pas d'incohérence aujourd'hui tant que le recouvrement reste refusé à l'ingestion ; à
aligner si ce garde-fou devait un jour être contourné.
### Qualité des données de l'API Mock ### Qualité des données de l'API Mock
Les valeurs `NULL` ne sont pas remplacées pendant l'ingestion. Les valeurs `NULL` ne sont pas remplacées pendant l'ingestion.
@@ -518,9 +622,10 @@ imputed_values = NULL
imputation_method = NULL imputation_method = NULL
``` ```
### Validation de l'import API Mock ### Validation initiale de l'import API Mock (18/09)
Un scénario de validation a été exécuté pour les 7 sites sur la période : Un premier scénario de validation a été exécuté le 18/09, avant la clôture de la réconciliation
(#15), pour les 7 sites sur la période :
```text ```text
15/06/2024 12:00 UTC 15/06/2024 12:00 UTC
@@ -542,6 +647,10 @@ Résultat :
420 lectures récupérées 420 lectures récupérées
``` ```
Ce scénario ne se rejoue plus tel quel depuis le 23/09 : la fenêtre recouvre le dataset
historique, donc `refuse_if_overlaps_historical_dataset()` la refuse, et `limit` n'est plus
fourni par l'appelant (une lecture par heure, voir plus haut).
Les données ont été chargées dans PostgreSQL/TimescaleDB puis contrôlées directement en base. Les données ont été chargées dans PostgreSQL/TimescaleDB puis contrôlées directement en base.
Les contrôles ont confirmé : Les contrôles ont confirmé :
@@ -569,9 +678,9 @@ Les tests automatisés couvrent également :
- la conservation des données sources ; - la conservation des données sources ;
- l'idempotence en base. - l'idempotence en base.
## Évolution prévue ## Orchestration par Airflow
La prochaine étape consiste à orchestrer les deux mécanismes d'ingestion avec Apache Airflow. Les deux mécanismes d'ingestion sont orchestrés par Apache Airflow (`etl/airflow/dags`) :
```text ```text
CSV / JSON ----------------+ CSV / JSON ----------------+
@@ -592,18 +701,11 @@ historical_import.py mock_api_import.py
PostgreSQL / TimescaleDB PostgreSQL / TimescaleDB
``` ```
Airflow servira à : `historical_import` n'a pas de planification (lancement à la demande) ; `mock_api_import` tourne
chaque heure à la minute 45. Airflow planifie, ordonne, suit l'état et remonte les erreurs ; il ne
remplace pas la logique ETL : les scripts Python restent responsables de l'extraction, de la
validation, de la transformation et du chargement, appelés tels quels par des `BashOperator`.
- planifier les traitements ; Le même Airflow porte la suite de la chaîne : entraînement et scoring du modèle (`ml_train`,
- définir leur ordre d'exécution ; `ml_score`), alertes et recommandations (`alertes`), dérive (`derive`) et rétention
- suivre leur état ; (`retention`), soit sept DAGs.
- gérer et remonter les erreurs ;
- faciliter les exécutions récurrentes.
Airflow ne remplacera pas la logique ETL déjà implémentée.
Les scripts Python resteront responsables de l'extraction, de la validation, de la transformation
et du chargement des données.
Le pipeline servira ensuite de base à la préparation des données nécessaires au modèle
de Machine Learning.
+300 -97
View File
@@ -6,132 +6,152 @@ vérifié, ce qui bloque, et ce qui ne l'est pas.
| Étage | Sert à | Statut | | Étage | Sert à | Statut |
|---|---|---| |---|---|---|
| Intégration continue | Interdire le merge d'un code qui casse la qualité, les tests ou la sécurité | `Fait` | | Intégration continue | Interdire le merge d'un code qui casse la qualité, les tests ou la sécurité | `Fait` |
| Livraison continue | Déployer chaque branche d'intégration sur son environnement de la VM ENI | `En cours` | | Livraison continue | Déployer chaque branche d'intégration sur son environnement de la VM ENI | `Fait` |
Le **D** de CI/CD est écrit depuis le 21/09 : `deploy.yml` déploie `dev` en recette et `main` en Le **D** de CI/CD est écrit depuis le 21/09 : `deploy.yml` déploie `dev` en recette et `main` en
production sur la VM de l'école, par un runner auto-hébergé (issue #21, production sur la VM de l'école, par un runner auto-hébergé (issue #21,
[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)). Il n'a encore rien [ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)). Depuis le 23/09, il ne part
déployé : la machine n'est pas provisionnée et le runner n'y est pas enregistré. Statut à plus qu'une fois la CI du commit verte ([ADR 0014](../adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md)).
basculer sur `Fait` au premier déploiement vert. Sa limite, nommée ici plutôt que découverte en Le runner est enregistré sur la machine (`null_resource.runner_github` dans le state
Terraform) et l'environnement `prod` compte sept déploiements entre le 23/09 11h37 et le 24/09
11h12, le dernier sur le commit gelé. Sa limite, nommée ici plutôt que découverte en
soutenance : les images sont construites sur la machine à chaque déploiement, aucun artefact soutenance : les images sont construites sur la machine à chaque déploiement, aucun artefact
n'est publié puis promu d'un environnement à l'autre. n'est publié puis promu d'un environnement à l'autre.
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
`ci.yml` est le seul point d'entrée des PR et des push sur `dev` et `main`. Il appelle les
workflows de composant, qui n'ont plus de déclencheur propre, selon les fichiers modifiés
([ADR 0014](../adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md)).
```mermaid ```mermaid
flowchart TB flowchart TB
push["push ou pull_request"] evt["pull_request, ou push sur dev et main"]
changes["changes<br/>paths-filter : composants touchés"]
subgraph back["Backend · .github/workflows/backend.yml"] subgraph comp["Workflows de composant (workflow_call)"]
bv["verification<br/>ruff, mypy, pytest --cov-fail-under=85"] back["backend.yml<br/>lint, typage, tests ≥ 85 %, intégration, pip-audit, bandit"]
bi["integration<br/>TimescaleDB réel + alembic upgrade head"] front["frontend.yml<br/>build et tests, npm audit"]
bd["security-audit<br/>uv export | pip-audit"] mlw["ml.yml<br/>lint, typage, tests, ML ↔ DB, chaîne ML → API, bandit"]
bs["sast<br/>bandit"] afw["airflow.yml<br/>intégrité des DAGs, image"]
infw["infra.yml<br/>Terraform, Compose et supervision, actionlint"]
e2e["e2e.yml<br/>stack de prod, Playwright, k6 smoke et limitation"]
end end
subgraph front["Frontend · frontend.yml"] sonar["sonar<br/>reprend les couvertures du run"]
fb["build<br/>npm ci, npm run build"] ok["CI ok<br/>seul check à exiger"]
ft["test<br/>couverture lcov"] dep["deploy.yml<br/>runner eni-g3, rec ou prod"]
fd["security-audit<br/>npm audit --audit-level=high"]
end
subgraph mlw["ML · ml.yml"] evt --> changes --> back & front & mlw & afw & infw & e2e
mv["verification<br/>ruff, mypy, pytest"] back & front & mlw --> sonar
ms["sast<br/>bandit"] back & front & mlw & afw & infw & e2e & sonar --> ok
end ok -->|"push sur dev ou main"| dep
subgraph afw["Airflow · airflow.yml"] planifie["chaque lundi 3h UTC, à la main,<br/>ou PR sur ses fichiers"]
av["verification<br/>ruff, intégrité des DAGs"] dast["dast.yml<br/>seed + scan actif OWASP ZAP"]
ab["image<br/>construction de l'image"] planifie --> dast
end
subgraph sq["SonarQube · sonarqube.yml"]
sb1["build-front / test-front"]
sb2["build-back / test-back"]
sb3["test-ml"]
sscan["sonarqube<br/>quality gate SonarCloud"]
end
push --> bv & bi & bd & bs
push --> fb --> ft
push --> fd
push --> mv & ms
push --> av & ab
push --> sb1 & sb2 --> sscan
subgraph cd["Déploiement · deploy.yml"]
dep["deploy<br/>runner eni-g3, environnement rec ou prod"]
end
push -->|"push sur dev ou main"| dep
``` ```
## Déclenchement ## Déclenchement
Les cinq workflows se déclenchent sur `push` **et** sur `pull_request`, filtrés par **chemin** : **Sur une PR**, le job `changes` lit la liste des fichiers modifiés par l'API GitHub
`backend.yml` sur `apps/backend/**`, `frontend.yml` sur `apps/frontend/**`, `ml.yml` sur `ml/**`, (`dorny/paths-filter`, épinglé sur un SHA) et chaque composant n'est appelé que si son filtre
`airflow.yml` sur `etl/airflow/**` **plus des chemins de `ml/` et de `apps/backend/`**, chacun vaut vrai. Une PR de documentation ne joue que `changes` et `CI ok`. Modifier `ci.yml` rejoue
incluant son propre fichier de workflow dans le filtre pour qu'une modification du pipeline tout.
déclenche le pipeline.
Le filtre d'`airflow.yml` mérite un mot : il inclut `ml/pyproject.toml`, `ml/uv.lock`, **Sur un push vers `dev` ou `main`**, tous les filtres valent vrai. C'est le moment où l'analyse
`ml/enervision_ml/**`, `apps/backend/pyproject.toml`, `apps/backend/uv.lock` et Sonar doit couvrir tout le dépôt, et paths-filter comparerait sinon le push à sa base de fusion
`apps/backend/app/**` parce que l'image Airflow copie le code et les dépendances des deux avec `main`, en retard de 80 commits. Une branche de travail ne déclenche plus rien par un push :
modules : celles du ML pour `ml_train`/`ml_score`, celles du backend depuis que le DAG `alertes` la CI part de sa PR, une seule fois par commit.
y exécute les commandes de détection ([ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)).
Une modification de l'un ou l'autre peut donc casser la construction de cette image, et le filtre
le voit.
**Piège à connaître** : il n'y a **aucun filtre de branche**. Une branche de travail déclenche la Deux filtres écoutent plus que leur dossier, parce que ce qu'ils testent dépend d'autres modules :
CI complète à chaque push, et un merge vers n'importe quelle branche la déclenche aussi. C'est
délibéré pendant le projet (retour au plus tôt, et la CI tournera sur `main` dès la remontée sans
rien changer), mais ce serait à borner sur un dépôt à forte fréquence de push.
`backend.yml`, `ml.yml` et `airflow.yml` déclarent en plus un groupe de concurrence par référence - `airflow` inclut `ml/pyproject.toml`, `ml/uv.lock`, `ml/enervision_ml/**`,
git avec `cancel-in-progress`, ce qui annule un run devenu obsolète par un push plus récent. `apps/backend/pyproject.toml`, `apps/backend/uv.lock` et `apps/backend/app/**`. L'image
Airflow copie le code et les dépendances des deux modules
([ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)), et une modification de l'un
ou de l'autre peut casser sa construction.
- `e2e` inclut le frontend, l'API, ses migrations et son Dockerfile, le proxy, les fichiers
Compose, `db/`, `tests/` et les scripts qu'il appelle : tout ce qui change un parcours.
**Piège de version** : `etl/airflow` tourne en **Python 3.12** et non 3.14, parce qu'Airflow 2.10 `dast.yml` reste hors de l'orchestrateur : un scan actif est trop long pour chaque PR. Il se
ne supporte pas encore 3.14. Le 3.14 du module ML ne vit, dans ce contexte, que dans l'image lance à la main, chaque lundi, et sur une PR qui modifie le scan, son jeu de données ou ses
Docker et son propre environnement. comptes.
Le groupe de concurrence de `ci.yml` annule le run d'une PR devenu obsolète par un push plus
récent. Pour un push sur `dev` ou `main`, le groupe est le SHA et rien n'est annulé : un run
coupé en plein `make stack-up` laisserait la stack à moitié redémarrée.
**Piège de version** : `etl/airflow` tourne en **Python 3.12** et non 3.14 : c'est l'interpréteur
de l'image `apache/airflow:3.3.2-python3.12` retenue, et les tests d'intégrité doivent tourner sur
le même. Le 3.14 du module ML ne vit, dans ce contexte, que dans l'image Docker et son propre
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 seul workflow qui ne tourne pas chez GitHub : il s'exécute sur un runner
un runner auto-hébergé installé sur la VM ENI, label `eni-g3`, parce que les runners hébergés ne auto-hébergé installé sur la VM ENI, label `eni-g3`, parce que les runners hébergés ne joignent
joignent pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port entrant
entrant n'est ouvert. n'est ouvert. Il n'a pas de déclencheur propre en dehors de `workflow_dispatch` : c'est le job
`deploy` de `ci.yml` qui l'appelle, sur un push, une fois « CI ok » vert.
| Événement | Environnement GitHub | Dossier sur la VM | Garde | | Événement | Environnement GitHub | Dossier sur la VM | Garde |
|---|---|---|---| |---|---|---|---|
| `push` sur `dev` | `rec` | `/srv/enervision/rec` | aucune : la recette suit `dev` | | `push` sur `dev`, « CI ok » vert | `rec` | `/srv/enervision/rec` | aucune de plus : la recette suit `dev` |
| `push` sur `main` | `prod` | `/srv/enervision/prod` | approbation d'un relecteur dans l'environnement `prod`, branche `main` seule autorisée | | `push` sur `main`, « CI ok » vert | `prod` | `/srv/enervision/prod` | branche `main` seule autorisée ; **aucun relecteur requis** (relevé par l'API le 24/09), l'approbation prévue n'est pas activée |
| `workflow_dispatch` sur toute autre branche | `dev` | `/srv/enervision/dev` | droit d'écriture sur le dépôt, seul à pouvoir lancer un workflow ([ADR 0017](../adr/0017-environnement-dev-a-la-demande.md)) |
Le job aligne le clone sur la branche (`fetch`, `checkout`, `reset --hard`), lance Le job aligne le clone sur **le commit testé** (`fetch`, `checkout`, `reset --hard $GITHUB_SHA`),
et non sur la pointe de branche du moment, qui a pu avancer pendant la CI. Il lance
`make stack-up`, qui reconstruit les images, redémarre les conteneurs puis applique les `make stack-up`, qui reconstruit les images, redémarre les conteneurs puis applique les
migrations Alembic dans le conteneur backend, et attend jusqu'à trois minutes que migrations Alembic dans le conteneur backend, et attend jusqu'à trois minutes que
`/api/v1/health/ready` réponde derrière le proxy. Cette sonde ne vérifie que la connexion à la `/api/v1/health/ready` réponde derrière le proxy. Cette sonde ne vérifie que la connexion à la
base et la présence de TimescaleDB : sans la migration, le déploiement serait vert sur une base base et la présence de TimescaleDB : sans la migration, le déploiement serait vert sur une base
sans schéma, et c'est pourquoi `make stack-up` la porte. Un groupe de concurrence par branche, sans schéma, et c'est pourquoi `make stack-up` la porte.
sans annulation, empêche deux déploiements simultanés du même environnement.
Les CI de deux push rapprochés peuvent finir dans le désordre. Deux gardes empêchent un
environnement de reculer ou de sauter un commit :
- un commit qui **précède** celui déjà déployé depuis la même branche est ignoré, avec une
annotation dans le run. Dans `dev`, une autre branche que celle en place est toujours déployée ;
- les déploiements d'un même environnement passent un par un sous un verrou `flock` posé dans le
clone de la VM, y compris deux branches lancées coup sur coup dans `dev`. Un groupe
`concurrency` ne convenait pas : GitHub n'y garde qu'un job en attente, et un troisième arrivé
l'annule sans erreur.
Le job ne fait pas de `actions/checkout` dans son espace de travail, et c'est voulu : le dossier Le job ne fait pas de `actions/checkout` dans son espace de travail, et c'est voulu : le dossier
de l'environnement est stable, hors du runner, parce que `.env`, certificats et volumes doivent de l'environnement est stable, hors du runner, parce que `.env`, certificats et volumes doivent
survivre d'un déploiement à l'autre. survivre d'un déploiement à l'autre.
**Piège à connaître.** Un runner auto-hébergé sur un dépôt public exécute ce qu'un workflow lui **Piège à connaître.** Un runner auto-hébergé sur un dépôt public exécute ce qu'un workflow lui
envoie, et une PR de fork peut réécrire un workflow. Trois parades, et les trois sont envoie, et une PR de fork peut ajouter son propre workflow qui vise le label `eni-g3`. L'absence
nécessaires : `deploy.yml` ne se déclenche jamais sur `pull_request` ; le runner tourne sous un de `pull_request` dans `deploy.yml` ne suffit donc pas. Ce qui protège vraiment le runner :
utilisateur dédié membre du groupe `docker`, jamais root ; le dépôt doit exiger une approbation
pour les workflows des PR externes (Settings, Actions, « Require approval for all outside
collaborators »), ce qui reste à activer. Les workflows de CI restent sur `ubuntu-latest`.
Cet utilisateur dédié doit posséder `/srv/enervision` : sinon git refuse les deux clones pour - le dépôt exige l'approbation des workflows de tous les contributeurs externes (Settings,
Actions, « Require approval for all external contributors ») ;
- l'environnement `prod` n'accepte que `main`, ce qui bloque un job qui le déclare depuis une
autre branche avant qu'il atteigne le runner. `rec` et `dev` n'ont, eux, aucune règle
(relevé par l'API le 24/09) ;
- le runner tourne sous un utilisateur dédié membre du groupe `docker`, jamais root.
Restent à activer par l'administratrice : l'approbation des workflows de contributeurs
externes (non vérifiable sans droit d'administration), un relecteur requis sur `prod`, et la
restriction de `rec` à `dev`. Tous les autres workflows restent sur `ubuntu-latest`.
Cet utilisateur dédié doit posséder `/srv/enervision` : sinon git refuse les clones pour
propriété douteuse et le `.env` en `600` lui échappe. `PROPRIETAIRE=<utilisateur du runner>` propriété douteuse et le `.env` en `600` lui échappe. `PROPRIETAIRE=<utilisateur du runner>`
passé à `scripts/provision-host.sh` fixe ce propriétaire. passé à `scripts/provision-host.sh` fixe ce propriétaire.
La machine se prépare avec `scripts/provision-host.sh`, qui vérifie Docker et Compose 2.24.4 ou La machine se prépare avec `scripts/provision-host.sh`, qui vérifie Docker et Compose 2.24.4 ou
plus, clone les deux branches, génère les secrets de chaque `.env` et les certificats plus, prépare les trois clones (`prod` sur `main`, `rec` et `dev` sur `dev`), génère les secrets
auto-signés, et ne démarre rien. Le détail des deux environnements, ports et noms d'hôte, est de chaque `.env`, obtient les certificats Let's Encrypt par DNS-01 (un auto-signé ne reste en
place qu'en cas d'échec) et planifie leur renouvellement, et ne démarre rien. Le détail des trois environnements, ports et noms d'hôte, est
dans [10-infra.md](10-infra.md). dans [10-infra.md](10-infra.md).
## Ce qui bloque un merge ## Ce qui bloque un merge
@@ -143,6 +163,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 |
@@ -150,6 +172,17 @@ 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 |
| Verrous uv à jour | backend, ml, airflow | `uv sync --locked` : un `uv.lock` qui ne suit plus `pyproject.toml` échoue | Bloque |
| Fichiers Compose | infra | `docker compose config` sur la stack de dev et la stack déployée, tous profils | Bloque |
| Frontal SNI | infra | `docker compose config` et `nginx -t` de `infra/front` | Bloque |
| Stockage objet | infra | fumée S3 sur Garage démarré par Compose : aller-retour, suppression, lecture refusée sans clé SSE-C (`tests/garage`) | Bloque |
| Supervision | infra | `promtool check config`, `promtool test rules` (un cas par alerte), `amtool check-config`, JSON des tableaux | Bloque |
| Workflows | infra | `actionlint`, shellcheck compris sur les blocs `run:` | Bloque |
| Parcours de bout en bout | e2e | 18 parcours Playwright contre la stack de prod (proxy TLS) | Bloque |
| Tir k6 de fumée | e2e | p95 < 500 ms et p99 < 1 s sur les lectures, moins de 1 % d'échecs | Bloque |
| Limitation de débit | e2e | k6 par le proxy : des 429 au-delà de 20 req/s, aucune 5xx | Bloque |
| **CI ok** | ci.yml | aucun job en `failure` ou `cancelled` | Bloque, **seul check à exiger** |
Deux seuils portent une décision qu'il faut savoir défendre : Deux seuils portent une décision qu'il faut savoir défendre :
@@ -160,8 +193,8 @@ Deux seuils portent une décision qu'il faut savoir défendre :
sans bloquer. Sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace. Le sans bloquer. Sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace. Le
revers à connaître : cette seconde étape porte `continue-on-error`, donc le job reste **vert** revers à connaître : cette seconde étape porte `continue-on-error`, donc le job reste **vert**
même quand elle relève quelque chose ; un LOW ne se voit qu'en ouvrant le journal. Au même quand elle relève quelque chose ; un LOW ne se voit qu'en ouvrant le journal. Au
21/09/2026, les deux modules sont à **zéro constat, tous niveaux confondus**, sur 5 904 lignes 24/09/2026, les deux modules sont à **zéro constat, tous niveaux confondus**, sur 6 858 lignes
analysées. analysées (6 136 pour le backend, 722 pour le ML).
- **La version de Bandit est épinglée** (`uvx bandit==1.9.4`) dans les deux jobs. Sans épingle, - **La version de Bandit est épinglée** (`uvx bandit==1.9.4`) dans les deux jobs. Sans épingle,
une nouvelle version passerait la CI au rouge sans qu'une seule ligne du dépôt ait changé, et une nouvelle version passerait la CI au rouge sans qu'une seule ligne du dépôt ait changé, et
le rejeu à l'identique promis plus bas n'existerait pas. le rejeu à l'identique promis plus bas n'existerait pas.
@@ -180,12 +213,42 @@ avant `alembic upgrade head`.
La couverture est **désactivée** sur ce job (`pytest -m integration --no-cov`) : il ne joue qu'une La couverture est **désactivée** sur ce job (`pytest -m integration --no-cov`) : il ne joue qu'une
partie de la suite, et son taux n'aurait aucun sens face au seuil de 85 %. partie de la suite, et son taux n'aurait aucun sens face au seuil de 85 %.
### Pourquoi le job d'intégration ML installe aussi le backend
Le schéma de la base n'a qu'une source, les sept révisions Alembic de `apps/backend/alembic` : le
backend est propriétaire du schéma, `ml/` n'en est que consommateur. Reconstruire ce schéma à la
main dans le job ML donnerait un job vert sur une base qui n'est pas la nôtre, exactement l'erreur
qu'évite déjà le choix de l'image `timescaledb-ha` plutôt qu'un `postgres` nu. Le job installe
donc les deux environnements uv, applique `alembic upgrade head`, puis joue `-m integration` côté
`ml/` et `-m chaine` côté backend.
Conséquence sur le déclenchement : le filtre `ml` de `ci.yml` inclut `apps/backend/alembic/**` et
`apps/backend/app/models/**`. Sans eux, une migration qui renomme une colonne de `reading` ne
déclencherait pas ce job, le SQL brut du pipeline dériverait du schéma, et **rien ne casserait
avant la production**. Le prix est qu'une PR touchant seulement une migration lance aussi le lint
et le typage de `ml/` : environ deux minutes de runner, en parallèle. Même arbitrage que le filtre
d'`airflow.yml`, qui écoute déjà `ml/**` et `apps/backend/app/**` parce que son image réunit les
deux.
Le marqueur `chaine` est distinct d'`integration` pour une raison mécanique : le job `integration`
de `backend.yml` n'installe pas `ml/.venv`, et sélectionnerait sinon un test qui lance les
binaires du pipeline. Il est aussi exclu d'`addopts`, sans quoi `make test` échouerait sur tout
poste où `ml/` n'est pas installé.
## SonarCloud, et l'incident qui a immobilisé trois PR ## SonarCloud, et l'incident qui a immobilisé trois PR
Le workflow `sonarqube.yml` exécute cinq jobs de préparation (`build-front`, `test-front`, Le job `sonar` de `ci.yml` ne reconstruit ni ne reteste rien. Les jobs `verification` de
`build-back`, `test-back`, `test-ml`) dont les tests produisent chacun un rapport de couverture en `backend.yml`, `ml.yml` et `frontend.yml` versent leur rapport de couverture en artefact, et
artefact, puis un dernier job qui les télécharge et lance `SonarSource/sonarqube-scan-action@v8` `sonar` les télécharge dans le même run, un par un (backend et ML nomment tous deux le leur
avec le secret `SONAR_TOKEN`. Le périmètre est décrit par `sonar-project.properties` à la racine. `coverage.xml`), puis lance `SonarSource/sonarqube-scan-action`, épinglée sur un SHA, avec le
secret `SONAR_TOKEN`. Il ne tourne ni pour Dependabot ni pour une PR de fork, qui n'ont pas ce
secret. Le périmètre est décrit par `sonar-project.properties` à la racine, seul fichier de
configuration Sonar du dépôt.
Jusqu'au 23/09, un `sonarqube.yml` à part rejouait build et tests des trois modules pour produire
ces rapports, en double exact des workflows qui le faisaient déjà. Les exclusions de
`sonar-project.properties` sont aussi passées en globs (`**/tests/**`, `**/alembic/**`) : un
motif sans `**` ne vise que la racine du dépôt.
Le périmètre couvre `apps/frontend`, `apps/backend`, `ml/` et `etl/airflow` (les deux derniers Le périmètre couvre `apps/frontend`, `apps/backend`, `ml/` et `etl/airflow` (les deux derniers
ajoutés après coup : ils n'étaient pas analysés, une PR qui ne touchait qu'eux ne lançait pas ajoutés après coup : ils n'étaient pas analysés, une PR qui ne touchait qu'eux ne lançait pas
@@ -210,9 +273,10 @@ contournée** en désactivant la gate ou en excluant les fichiers gênants.
## Dependabot ## Dependabot
`.github/dependabot.yml` déclare **six entrées hebdomadaires groupées, sur cinq écosystèmes** : `.github/dependabot.yml` déclare **sept entrées hebdomadaires, sur cinq écosystèmes** : `npm`
`npm` sur `/apps/frontend`, `uv` sur `/apps/backend`, `github-actions` sur `/`, `docker` sur les sur `/apps/frontend` et sur `/tests/e2e`, `uv` sur `/apps/backend`, `github-actions` sur `/`,
deux dossiers d'application, et `docker-compose` sur `/`. Les mises à jour arrivent en PR, donc `docker` sur les deux dossiers d'application, et `docker-compose` sur `/`, qui suit aussi les
images de supervision et de k6. Les mises à jour arrivent en PR, donc
elles traversent les mêmes gates que n'importe quel changement : une montée de version qui casse elles traversent les mêmes gates que n'importe quel changement : une montée de version qui casse
les tests ne se merge pas. les tests ne se merge pas.
@@ -223,7 +287,7 @@ les tests ne se merge pas.
| Préfixes de branche | `feat/`, `fix/`, `chore/`, `docs/`, `test/` | | Préfixes de branche | `feat/`, `fix/`, `chore/`, `docs/`, `test/` |
| Messages de commit | Conventional Commits | | Messages de commit | Conventional Commits |
| Branche d'intégration | `dev` ; `main` est la branche par défaut du dépôt public | | Branche d'intégration | `dev` ; `main` est la branche par défaut du dépôt public |
| Revue | Toute PR passe par une revue écrite avant merge | | Revue | Relecture écrite par un autre membre avant merge : 55 PR de fonctionnalité sur 62 au gel (89 %). Convention d'équipe, non imposée par une protection de branche |
| ADR | Toute décision structurante porte son ADR dans la même PR | | ADR | Toute décision structurante porte son ADR dans la même PR |
| Vues d'architecture | Toute PR qui change un composant met à jour sa vue **dans la même PR** | | Vues d'architecture | Toute PR qui change un composant met à jour sa vue **dans la même PR** |
@@ -241,21 +305,160 @@ 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 `db/seeds/demo.sql` (sans données, `GET /sites` rend
`[]`, chaque `/{site_id}` rend 404, et le scan actif ne frappe que des gestionnaires d'erreur),
démarre le backend, puis `scripts/dast-token.sh` s'appuie sur `scripts/comptes-test.sh` pour
créer les comptes et rend le jeton du **`lecteur`**. Le jeu et les comptes sont ceux de l'e2e.
ZAP charge le contrat `/openapi.json` depuis un fichier (`zap-api-scan.py -f openapi -t
/zap/wrk/openapi.json`) et en importe les 26 opérations **quel que soit le jeton** : c'est le
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.
## Tests de bout en bout et de charge
Le workflow `e2e.yml` démarre la stack telle qu'elle est déployée, derrière le proxy TLS, sur
`https://localhost` ([ADR 0015](../adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md)) :
1. Il construit et démarre `db`, `mailpit`, `backend`, `frontend` et `proxy` avec
`docker-compose.prod.yml`, sans Airflow. C'est le seul job qui construit les images backend et
frontend avant un déploiement.
2. Il migre la base, pose le rôle `supervision`, sème `db/seeds/demo.sql` et crée les comptes
(`scripts/comptes-test.sh`).
3. Il joue les 18 parcours Playwright de `tests/e2e`, sur un seul worker et avec une session par
fichier. Le rapport HTML et les traces du premier réessai sont versés en artefact.
4. Il lance le tir k6 `smoke`, directement sur `backend:8000`, puis `limitation-debit` par le
proxy. Les synthèses s'affichent dans le résumé du job, et les rapports HTML sont versés en
artefact.
La charge nominale (`make load-test`) et le stress (`make load-stress`) ne tournent pas en CI :
voir `tests/load/README.md`.
## Ce qui manque, et pourquoi ## Ce qui manque, et pourquoi
| Manque | Issue | Conséquence assumée | | Manque | Issue | Conséquence assumée |
|---|---|---| |---|---|---|
| Images publiées et promues par digest (GHCR) | aucune | Chaque environnement reconstruit ses images : la production n'exécute pas l'artefact validé en recette, mais un second build du même commit | | Images publiées et promues par digest (GHCR) | aucune | Chaque environnement reconstruit ses images : la production n'exécute pas l'artefact validé en recette, mais un second build du même commit |
| DAST (OWASP ZAP) | #41 | Aucune vérification sur l'application en fonctionnement, seulement sur le code et les dépendances | | DAST bloquant | #41 | Le scan ZAP existe mais ne bloque rien : aucun seuil n'est fixé tant que les alertes du premier passage ne sont pas triées |
| Tests end to end | #46 | Les parcours utilisateur ne sont pas vérifiés en CI | | Tir de charge nominal automatisé | #47 | Seul le smoke tourne en CI ; la charge à 50 utilisateurs se lance à la main en recette (`make load-test`), rec et prod partageant la VM |
| Tests de charge | #47 | Aucun garde-fou de performance | | Cache de couches Docker en CI | aucune | Le job E2E reconstruit les images backend et frontend à chaque run, deux à quatre minutes de plus |
| Scan d'image de conteneur | aucune | Les `Dockerfile` sont construits en local, pas analysés | | Scan d'image de conteneur | aucune | Les images sont construites par le job E2E, pas analysées |
| Scan de secrets et d'IaC en CI | aucune | gitleaks, Trivy et Checkov ne tournent qu'à la main, pour le rapport de sécurisation : un secret commité ne serait vu qu'au passage suivant |
| `pip-audit` sur les verrous ML et Airflow, `npm audit` sur `tests/e2e` | aucune | Seuls les verrous du backend et du frontend sont audités en CI : la CVE-2026-41016 d'`apache-airflow-providers-smtp`, relevée le 23/09, y reste invisible |
| Approbation humaine avant la production | aucune | L'environnement `prod` n'exige aucun relecteur : un push sur `main` au CI vert part en production sans autre garde |
## 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 sept 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`
```
Les autres jobs se rejouent aussi sur le poste :
```bash
make e2e-prepare e2e # parcours Playwright contre `make dev` (tests/e2e/README.md)
make load-smoke K6_EMAIL=... K6_PASSWORD=... # tir k6 d'une minute (tests/load/README.md)
make monitoring-check # promtool, amtool et JSON des tableaux de bord, comme le job Infra
```
Le SAST se rejoue à l'identique : `uvx bandit==1.9.4 --recursive app --severity-level medium Le SAST se rejoue à l'identique : `uvx bandit==1.9.4 --recursive app --severity-level medium
--confidence-level medium` depuis `apps/backend`, et la même commande sur `enervision_ml` depuis --confidence-level medium` depuis `apps/backend`, et la même commande sur `enervision_ml` depuis
+117
View File
@@ -0,0 +1,117 @@
# Observabilité
Ce document décrit ce qu'on voit du système en fonctionnement : métriques, tableaux de bord,
alertes et journaux. Décision dans l'[ADR 0016](../adr/0016-supervision-en-profil-compose.md),
mode d'emploi dans [`monitoring/README.md`](../../monitoring/README.md).
| Brique | Sert à | Statut |
|---|---|---|
| Métriques de l'API | Débit, erreurs et latences par route | `Fait` |
| Collecte et alertes | Prometheus, neuf règles testées, Alertmanager vers Mailpit | `Fait` |
| Tableaux de bord | Grafana : API, données et modèle, infrastructure | `Fait` |
| Métriques d'Airflow | StatsD ou OpenTelemetry des DAGs | `Cible` |
| Journaux centralisés | Loki ou équivalent | `Cible` |
## Vue d'ensemble
```mermaid
flowchart LR
subgraph projet["Projet Compose de la prod"]
api["backend<br/>/metrics"]
db[("db<br/>TimescaleDB")]
mail["mailpit"]
garage["garage<br/>:3903/metrics"]
subgraph sup["Profil monitoring"]
prom["prometheus<br/>15 s, 15 jours"]
am["alertmanager"]
graf["grafana"]
pge["postgres-exporter"]
node["node-exporter"]
cad["cadvisor"]
end
end
hote["Hôte : VM ENI<br/>recette et prod"]
prom -->|"Bearer APP_METRICS_TOKEN"| api
prom --> pge & node & cad
prom -->|"Bearer GARAGE_METRICS_TOKEN"| garage
pge -->|"rôle supervision"| db
node -.->|"/proc, /sys"| hote
cad -.->|"cgroups"| hote
prom -->|"règles franchies"| am -->|"SMTP"| mail
graf --> prom
graf -->|"rôle supervision, SQL"| db
```
Tout vit dans le projet Compose de la prod, sur son réseau. La recette n'a pas de supervision
propre. node-exporter et cAdvisor voient pourtant tout l'hôte : la mémoire de la VM et de chaque
conteneur couvre donc aussi la recette, qu'on distingue au préfixe `enervision-rec-`.
## Ce que mesure chaque source
| Source | Métriques utiles | Où les lire |
|---|---|---|
| API (`prometheus-fastapi-instrumentator`) | `http_requests_total` par route et classe de statut, `http_request_duration_seconds` par route (seaux 50 ms à 2,5 s), `http_request_duration_highr_seconds` global, mémoire du processus | Tableau « API » |
| postgres-exporter | `pg_up`, connexions par état, `max_connections`, transactions validées, taille des bases | Tableau « Infrastructure » |
| node-exporter | Processeur, mémoire disponible, espace disque de `/` | Tableau « Infrastructure » |
| cAdvisor | Mémoire (`working_set`) et processeur par conteneur | Tableau « Infrastructure » |
| TimescaleDB, en SQL | Fraîcheur des relevés par site, relevés ingérés par heure, alertes par sévérité, `drift_report` | Tableau « Données et modèle » |
| Garage (`/metrics` du port admin, jeton `GARAGE_METRICS_TOKEN`) | `api_s3_request_counter`, `block_bytes_written`, `garage_local_disk_avail`, `cluster_healthy` | Prometheus seulement, aucun tableau dédié ; `CibleInjoignable` couvre son indisponibilité ([ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)) |
Deux choix de l'instrumentation se lisent dans ces courbes :
- **Les sondes `/health/*` et `/metrics` ne sont pas comptées.** La sonde Docker frappe toutes
les 30 s : incluse, elle ferait baisser la latence moyenne et gonfler le débit d'une API au
repos.
- **Les seaux par route encadrent 500 ms**, seuil de charge de l'ADR 0015. Grafana lit ainsi le
même p95 que k6 pendant un tir.
## Alertes
| Groupe | Alertes | Sévérité |
|---|---|---|
| API | Indisponible 2 min, 5xx au-delà de 5 %, p95 au-delà d'une seconde | critical, critical, warning |
| Base | PostgreSQL injoignable 2 min, connexions au-delà de 80 % | critical, warning |
| Hôte | Mémoire au-delà de 90 %, disque sous 10 %, processeur au-delà de 90 % | warning, critical, warning |
| Supervision | Un exporteur muet 5 min | warning |
- **Tests des règles.** Chaque règle a un cas dans `monitoring/prometheus/tests/`, joué par
`promtool test rules` dans le job Infra de la CI. Une règle qui ne se déclenche plus, ou se
déclenche à tort, casse la CI avant d'atteindre la prod.
- **Envoi.** Alertmanager groupe les alertes par nom et sévérité et les envoie par courriel via
Mailpit, qui les capture sans rien relayer. Un `critical` est rappelé toutes les heures, un
`warning` toutes les douze. Un `critical` masque le `warning` de la même cible.
## Sécurité
- **Aucune interface exposée.** Prometheus, Alertmanager et Grafana n'écoutent que sur
`127.0.0.1`, et rien ne passe par le proxy (ADR 0007). Accès par tunnel SSH.
- **`/metrics` gardé par jeton.** Il n'est pas routé par nginx, et Prometheus y présente
`APP_METRICS_TOKEN`, que l'API exige dès qu'il est posé. Le jeton lui parvient en secret
Compose, jamais en clair dans sa configuration.
- **Base en lecture seule.** Grafana et postgres-exporter lisent la base par le rôle
`supervision`, en lecture seule, limité aux tables métier (`db/roles/supervision.sql`). Ils
n'ont ni `app_user`, ni jetons, ni journal d'audit.
- **Grafana verrouillé.** Il refuse de démarrer sans `GRAFANA_ADMIN_PASSWORD`. Inscription,
accès anonyme et appels sortants (statistiques d'usage, vérification de mises à jour) y sont
désactivés.
- **cAdvisor en `privileged`.** Il tourne ainsi pour lire les cgroups, avec des montages en
lecture seule et sans port publié.
## Journaux
Les journaux restent ceux de Docker : `docker compose logs`, `make stack-logs`,
`make monitoring-logs`. L'API écrit du JSON dès `APP_ENV=prod`, caviardé des jetons et des mots
de passe (voir [20-backend.md](20-backend.md)). Aucune agrégation centralisée n'est en place.
## Ce qui manque
| Manque | Conséquence assumée |
|---|---|
| Métriques d'Airflow (StatsD, OpenTelemetry) | Un DAG qui échoue ne se voit que dans Airflow ; le tableau « Données » le trahit indirectement par des relevés qui vieillissent |
| Alerte sur la fraîcheur des relevés | Visible dans Grafana, mais aucune règle Prometheus ne la porte : il faudrait une métrique calculée par l'API ou un exportateur SQL |
| Journaux centralisés | Un incident se diagnostique conteneur par conteneur |
| Destinataire réel des alertes | Mailpit capture tout : les alertes se lisent dans son interface, elles ne réveillent personne |
+105
View File
@@ -0,0 +1,105 @@
# 70 · Pilotage des traitements automatisés
Ce document dit à l'opérateur quoi surveiller, comment lire ce qu'il voit et quoi faire quand un
traitement déraille. Il ne redit pas le fonctionnement : la table des DAGs vit dans
[10-infra.md](10-infra.md), la chaîne de données dans [40-data.md](40-data.md), la dérive dans
l'[ADR 0013](../adr/0013-surveillance-de-derive-dans-le-backend.md), la rétention dans
l'[ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md), la supervision
dans [60-observabilite.md](60-observabilite.md).
## Carte des traitements
Les planifications sont en **UTC** : Airflow n'a pas de fuseau configuré. En heure de Paris
l'été, ajouter deux heures.
| DAG | Planification (UTC) | Ce qu'il fait | Même traitement à la main |
|---|---|---|---|
| `mock_api_import` | chaque heure à :45 | importe la mesure de l'heure pile de l'API Mock, une par site | `python -m app.etl.mock_api_import --start-time … --end-time …` |
| `ml_score` | chaque heure pile | score le pas horaire suivant, écrit `prediction` | `make ml-score` |
| `alertes` | chaque heure à :15 | détecte les alertes internes, puis génère les recommandations | `make detect-alerts`, puis `make recommendations` |
| `retention` | 03:20 | exporte vers Garage les chunks de `reading` de plus de 1 095 jours, puis les supprime | `python -m app.etl.reading_retention` |
| `derive` | 05:30 | calcule le rapport de dérive du modèle sur 168 h | `python -m app.monitoring.drift` |
| `ml_train` | manuel | réentraîne LightGBM et **écrase** le modèle | `make ml-train` |
| `historical_import` | manuel | importe le jeu historique CSV | `python -m app.etl.historical_import --csv … --metadata …` |
Les modules `app.*` se lancent depuis `apps/backend` (`uv run python -m …`). Tous les DAGs ont
`max_active_runs=1` et `catchup=False` : un retard ne rejoue pas les heures manquées.
## Où regarder
Sur la machine, chaque environnement vit dans `/srv/enervision/<env>` et n'écoute que sur la
boucle locale : on y accède par tunnel SSH.
| Quoi | Production | Recette | Dev |
|---|---|---|---|
| Interface Airflow | `127.0.0.1:8080` | `127.0.0.1:8082` | `127.0.0.1:8084` |
| Grafana (production seulement) | `127.0.0.1:3001` | - | - |
| Mailpit, où arrivent les alertes | `127.0.0.1:8025` | `127.0.0.1:8026` | `127.0.0.1:8027` |
```bash
ssh -L 8080:127.0.0.1:8080 -L 3001:127.0.0.1:3001 -L 8025:127.0.0.1:8025 root@<IP-VM-G3>
```
En ligne de commande, depuis `/srv/enervision/<env>` :
```bash
compose="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
$compose exec airflow-apiserver airflow dags list-runs derive # derniers passages
$compose exec airflow-apiserver airflow dags trigger ml_train # lancement manuel
$compose logs --tail=100 airflow-scheduler # les tâches tournent ici
```
**Aucune alerte ne signale l'échec d'un DAG** ([60-observabilite.md](60-observabilite.md)) :
un coup d'œil quotidien à l'interface Airflow reste nécessaire.
## Lire le rapport de dérive
`GET /api/v1/monitoring/drift` (rôle `operateur`) rend le dernier rapport par site et une ligne
globale. Le verdict suit cet ordre (`app/services/drift.py`) :
| Verdict | Condition | Ce que ça veut dire | Quoi faire |
|---|---|---|---|
| `indetermine` | moins de 24 prévisions vérifiées sur la fenêtre | pas assez de recul pour conclure | attendre ; si ça dure, vérifier `ml_score` |
| `derive`, couverture | moins de 80 % des prévisions ont trouvé leur mesure réelle | **le pipeline**, pas le modèle | vérifier `mock_api_import` et `ml_score` dans Airflow |
| `derive`, erreur | MAE au-delà de 1,25 fois celle de la fenêtre de référence | le modèle se trompe plus qu'avant | réentraîner (ci-dessous) |
| `derive`, biais | biais absolu au-delà du seuil, désactivé par défaut | le modèle se trompe toujours du même côté | réentraîner, après en avoir cherché la cause dans les données |
| `stable` | aucune des conditions précédentes | rien à faire | - |
## Réentraîner sans perdre le modèle en service
`ml_train` reste manuel parce que `train.py` écrase le modèle sans comparer ses métriques à
celles de l'ancien ([10-infra.md](10-infra.md)). Garder une copie avant de lancer :
```bash
$compose exec airflow-scheduler cp /opt/ml/state/models/lightgbm-consumption.txt \
/opt/ml/state/models/lightgbm-consumption.txt.avant
$compose exec airflow-apiserver airflow dags trigger ml_train
```
Comparer ensuite les métriques des deux derniers runs, que `train.py` enregistre dans le magasin
MLflow du volume (`/opt/ml/state/mlflow.db`). Aucune interface MLflow n'est servie sur la
machine : `ml/README.md` décrit `mlflow ui`. Si le nouveau modèle est moins bon, remettre la
copie en place : le prochain `ml_score` l'utilisera.
## Rétention et archives
`retention` supprime de la base les chunks de `reading` plus vieux que
`APP_READING_RETENTION_DAYS` (1 095 jours), **après** les avoir exportés en CSV gzip dans le
bucket Garage de l'environnement, chiffrés en SSE-C. Il est idempotent : une reprise ne réécrit
pas un objet déjà exporté et ne retrouve plus un chunk déjà supprimé.
- **La clé `GARAGE_SSE_KEY` du `.env` est la seule qui déchiffre les archives.** Garage ne la
garde pas. Perdue, les archives sont illisibles : elle se sauvegarde hors de la machine.
- Restaurer un chunk : `get_object` avec la clé SSE-C, `gunzip`, puis
`COPY reading FROM STDIN CSV HEADER`. La contrainte `uq_reading_source` refuse les doublons
([ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)).
## Reprendre après un incident
| Situation | Reprise |
|---|---|
| `mock_api_import` a manqué des heures | relancer à la main avec `--start-time` et `--end-time` sur la fenêtre manquante ; le module refuse toute fenêtre qui recouvre le CSV historique |
| `alertes` a échoué | relancer : les deux tâches sont idempotentes (`ON CONFLICT DO NOTHING`) |
| `derive` a échoué | relancer : un index d'unicité par fenêtre empêche les doublons |
| `retention` a échoué | relancer : idempotent, voir plus haut |
| `ml_score` en échec répété | lire les journaux du scheduler ; deux tentatives et un plafond de 30 minutes par passage |
+15 -7
View File
@@ -15,15 +15,18 @@ contredisent, c'est l'ADR qui fait foi et la vue qui est en retard.
| [31-contrat-authentification.md](31-contrat-authentification.md) | Ce que le frontend doit savoir pour coder la connexion | | [31-contrat-authentification.md](31-contrat-authentification.md) | Ce que le frontend doit savoir pour coder la connexion |
| [32-design-systeme-frontend.md](32-design-systeme-frontend.md) | Tokens CSS, composants `ev-*` partagés, règle anti-couleur-en-dur | | [32-design-systeme-frontend.md](32-design-systeme-frontend.md) | Tokens CSS, composants `ev-*` partagés, règle anti-couleur-en-dur |
| [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle | | [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle |
| [50-cicd.md](50-cicd.md) | Workflows, gates bloquantes, SonarCloud, Dependabot, ce qui manque | | [50-cicd.md](50-cicd.md) | Orchestrateur `ci.yml`, gates bloquantes, e2e et charge, SonarCloud, Dependabot, ce qui manque |
| [60-observabilite.md](60-observabilite.md) | Métriques, Prometheus, alertes, tableaux de bord Grafana, ce qui manque |
| [70-pilotage.md](70-pilotage.md) | Runbook de pilotage des traitements automatisés : entraîner, lire la dérive, rejouer un DAG, rétention |
| [owasp-traceabilite.md](owasp-traceabilite.md) | Traçabilité OWASP Top 10 et API Top 10 : couvert, partiel, ouvert |
La CI/CD a désormais son document : cinq workflows et seize jobs, c'est assez de matière pour La CI/CD a son document : un orchestrateur et ses workflows de composant, c'est assez de
qu'une section de plus dans une autre vue devienne illisible. L'observabilité, elle, n'en a matière pour qu'une section de plus dans une autre vue devienne illisible. L'observabilité a le
toujours pas : `monitoring/` ne contient que des `.gitkeep`. Elle en sortira le jour où elle aura sien depuis l'issue #26, qui lui a donné de la matière : collecte, alertes et tableaux de bord.
de la matière. Un fichier vide de plus n'aide personne.
L'orchestration Airflow, elle, en a depuis les issues #115 et #116 : trois DAGs, leur image et L'orchestration Airflow, elle, en a depuis les issues #115 et #116 : les sept DAGs, leur image et
leurs contraintes sont décrits dans [10-infra.md](10-infra.md). leurs contraintes sont décrits dans [10-infra.md](10-infra.md), leur pilotage au quotidien dans
[70-pilotage.md](70-pilotage.md).
La sécurité applicative, elle, a désormais de la matière : la vue consolidée reste dans La sécurité applicative, elle, a désormais de la matière : la vue consolidée reste dans
[00-vue-ensemble.md](00-vue-ensemble.md), le détail dans [20-backend.md](20-backend.md), la [00-vue-ensemble.md](00-vue-ensemble.md), le détail dans [20-backend.md](20-backend.md), la
@@ -38,6 +41,11 @@ GitHub rend Mermaid nativement dans les fichiers `.md`. Un diagramme est donc du
relit en revue, il se diffe, et il ne se périme pas dans un binaire que plus personne ne sait relit en revue, il se diffe, et il ne se périme pas dans un binaire que plus personne ne sait
rouvrir six mois plus tard. Aucune image exportée, aucun `.drawio`, aucun `.png`. rouvrir six mois plus tard. Aucune image exportée, aucun `.drawio`, aucun `.png`.
Une exception existe, et elle est connue : le schéma de données de
[40-data.md](40-data.md) est une image, `images/EnerVision-schema-donnees.png`, versionnée le 15/09,
quelques heures après l'adoption de la règle, sans que la revue le relève. Elle se relit à côté de la description des tables qui la suit,
qui fait foi ; la migrer en Mermaid reste à faire.
### Chaque section porte son statut ### Chaque section porte son statut
Une large part de la stack n'est pas écrite. Une vue qui mélange l'existant et la cible sans le Une large part de la stack n'est pas écrite. Une vue qui mélange l'existant et la cible sans le
+14 -10
View File
@@ -38,19 +38,23 @@ lecture seule ; plusieurs lignes resteront à compléter une fois les endpoints
| Caviardage des jetons, empreintes, mots de passe et cookies dans les journaux | `app/core/logging.py` | A09, A02 | | Caviardage des jetons, empreintes, mots de passe et cookies dans les journaux | `app/core/logging.py` | A09, A02 |
| Cinq gardes de configuration qui refusent le démarrage plutôt que de dégrader silencieusement | `app/core/config.py` | A05 | | Cinq gardes de configuration qui refusent le démarrage plutôt que de dégrader silencieusement | `app/core/config.py` | A05 |
| Documentation interactive fermée hors développement, `/metrics` derrière un jeton, sonde qui ne publie plus de version | `app/main.py`, `app/api/security.py` | A05 | | Documentation interactive fermée hors développement, `/metrics` derrière un jeton, sonde qui ne publie plus de version | `app/main.py`, `app/api/security.py` | A05 |
| En-têtes `nosniff`, `DENY`, `no-referrer`, et `no-store` sur les routes d'authentification | `app/api/middleware.py` | A05 | | Scan dynamique OWASP ZAP de l'API authentifiée (compte `lecteur` jetable), non bloquant, configuration par défaut du backend uniquement (ni TLS ni en-têtes du reverse proxy) | `.github/workflows/dast.yml`, `scripts/dast-token.sh` | A05, API8 Security Misconfiguration |
| En-têtes `nosniff`, `DENY`, `no-referrer`, `Cross-Origin-Resource-Policy: same-origin`, et `no-store` sur les routes d'authentification | `app/api/middleware.py` | A05 |
| Refus de rétrograder ou désactiver le dernier administrateur actif | `app/services/user.py` | A04 Insecure Design | | Refus de rétrograder ou désactiver le dernier administrateur actif | `app/services/user.py` | A04 Insecure Design |
| Amorçage du premier administrateur hors dépôt, mot de passe jamais dans `argv` ni dans Git | `app/cli.py` | A02, A05 | | Amorçage du premier administrateur hors dépôt, mot de passe jamais dans `argv` ni dans Git | `app/cli.py` | A02, A05 |
| Réponse de l'API Mock bornée avant écriture : timeout, plafond de sites et de mesures, bornes physiques par grandeur, recopie des seuls champs attendus | `app/etl/mock_api_import.py` | API10 Unsafe Consumption of APIs | | Réponse de l'API Mock bornée avant écriture : timeout, plafond de sites et de mesures, bornes physiques par grandeur, recopie des seuls champs attendus | `app/etl/mock_api_import.py` | API10 Unsafe Consumption of APIs |
| CI bloquante : format, lint avec règles Bandit, typage strict, tests avec seuil de couverture | `.github/workflows/backend.yml` | A06 Vulnerable and Outdated Components | | CI bloquante : format, lint avec règles Bandit, typage strict, tests avec seuil de couverture, SAST Bandit à partir de MEDIUM, `pip-audit` sur le verrou du backend, `npm audit --audit-level=high` sur le frontend | `.github/workflows/backend.yml`, `ml.yml`, `frontend.yml` | A06 Vulnerable and Outdated Components |
| Terminaison TLS au frontal, redirection 80 vers 443, HSTS et CSP posés par le proxy, limitation de débit au frontal | `infra/proxy/conf.d/enervision.conf`, ADR 0007 | API8 Security Misconfiguration, A05 | | Terminaison TLS au proxy de chaque stack, derrière un frontal SNI qui aiguille sans déchiffrer ; certificats Let's Encrypt par DNS-01 ; redirection 80 vers 443, HSTS et CSP posés par le proxy, limitation de débit sur l'adresse réelle du client (PROXY protocol) | `infra/proxy/conf.d/enervision.conf`, `infra/front/nginx.conf`, ADR 0007, ADR 0018 | API8 Security Misconfiguration, A05 |
Note sur A06 : le jeu de règles `S` de ruff, déjà actif dans `pyproject.toml`, est le portage des Note sur A06 : le jeu de règles `S` de ruff, actif dans `pyproject.toml`, est le portage des
règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu'annonce l'EC01. règles Bandit. Bandit lui-même a tout de même rejoint la CI le 21/09 (PR #121), bloquant à partir
de MEDIUM sur le backend et le ML, comme l'annonçait l'EC01 : les deux se recouvrent, redondance
assumée pour disposer d'un rapport SAST dédié et d'une version épinglée.
Note sur API8 : le transport est couvert, le certificat ne l'est qu'à moitié. Tant qu'aucun nom de Note sur API8 : le transport et le certificat sont couverts. La machine n'a qu'une adresse privée,
domaine public ne résout vers la machine, le défi HTTP-01 de Let's Encrypt ne peut pas aboutir et le défi HTTP-01 ne peut pas aboutir : les certificats Let's Encrypt sont obtenus par défi DNS-01,
le certificat servi reste auto-signé. Le chemin ACME est livré et documenté, pas exercé. sur un domaine public dont la zone publie les enregistrements de validation (ADR 0018). L'auto-signé
ne sert plus qu'au poste de développement et aux tests e2e.
## Non couvert, et pourquoi ## Non couvert, et pourquoi
@@ -59,9 +63,9 @@ le certificat servi reste auto-signé. Le chemin ACME est livré et documenté,
| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` et `GET /recommendations/{recommendation_id}` répondent à tout compte `lecteur` pour n'importe quel site ou recommandation, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. | | **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` et `GET /recommendations/{recommendation_id}` répondent à tout compte `lecteur` pour n'importe quel site ou recommandation, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. |
| **API4, lectures de séries temporelles** | **partiel** | `GET /readings` plafonne la fenêtre temporelle (90 jours) et la pagination (`limit` ≤ 2000), voir plus haut. Reste ouvert : pagination en `limit`/`offset` simple plutôt qu'en curseur (un `offset` élevé sur une fenêtre dense reste coûteux), et aucun `statement_timeout` au niveau de la connexion pour borner une requête individuelle si les plafonds au-dessus s'avéraient insuffisants. | | **API4, lectures de séries temporelles** | **partiel** | `GET /readings` plafonne la fenêtre temporelle (90 jours) et la pagination (`limit` ≤ 2000), voir plus haut. Reste ouvert : pagination en `limit`/`offset` simple plutôt qu'en curseur (un `offset` élevé sur une fenêtre dense reste coûteux), et aucun `statement_timeout` au niveau de la connexion pour borner une requête individuelle si les plafonds au-dessus s'avéraient insuffisants. |
| **API10 Unsafe Consumption of APIs** | **partiel, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse est traitée comme une entrée hostile par `app/etl/mock_api_import.py`, son seul consommateur à ce jour : les quatre garde-fous attendus sont en place, voir la ligne correspondante plus haut. Reste ouvert : le plafond de taille s'applique après désérialisation de la réponse, borner le corps HTTP lui-même demanderait une lecture en flux ; et `APP_MOCK_API_BASE_URL` n'impose pas `https`, donc les identifiants Basic partiraient en clair sur une URL en `http`. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. | | **API10 Unsafe Consumption of APIs** | **partiel, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse est traitée comme une entrée hostile par `app/etl/mock_api_import.py`, son seul consommateur à ce jour : les quatre garde-fous attendus sont en place, voir la ligne correspondante plus haut. Reste ouvert : le plafond de taille s'applique après désérialisation de la réponse, borner le corps HTTP lui-même demanderait une lecture en flux ; et `APP_MOCK_API_BASE_URL` n'impose pas `https`, donc les identifiants Basic partiraient en clair sur une URL en `http`. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. |
| **A08 Software and Data Integrity Failures** | **partiel** | La CI vérifie le code mais n'analyse ni les dépendances ni les images. `.terraform.lock.hcl` reste ignoré par git, ce qui contredit une chaîne d'approvisionnement maîtrisée. | | **A08 Software and Data Integrity Failures** | **partiel** | La CI audite les dépendances du backend (`pip-audit` sur le verrou figé) et du frontend (`npm audit`), et les `.terraform.lock.hcl` sont versionnés. Restent ouverts : les verrous ML et Airflow ne sont pas audités (une CVE MEDIUM de `apache-airflow-providers-smtp` y reste invisible), aucune image n'est analysée, aucun scan de secrets ne tourne en CI, et les images sont reconstruites sur la machine plutôt que promues par empreinte. |
| **A10 Server-Side Request Forgery** | **sans objet aujourd'hui** | Aucune URL sortante n'est pilotée par une donnée utilisateur. Le jour où l'adresse d'une source devient un champ de configuration, il faudra une liste blanche de schémas et d'hôtes, sans suivi de redirection. | | **A10 Server-Side Request Forgery** | **sans objet aujourd'hui** | Aucune URL sortante n'est pilotée par une donnée utilisateur. Le jour où l'adresse d'une source devient un champ de configuration, il faudra une liste blanche de schémas et d'hôtes, sans suivi de redirection. |
| **Cantonnement des accès ETL et ML** | **dette assumée** | Le compte applicatif porte l'identité, le rôle PostgreSQL porterait le cantonnement. Voir ADR 0003. Plus coûteuse depuis Airflow (#115) : ce service publie le port 8080, détient les identifiants Postgres complets (`ML_DATABASE_URL`, mêmes que le backend) et permet de déclencher l'exécution de code depuis son interface. Un compte Airflow compromis atteint donc toute la base, pas seulement `reading`/`site`. Aggravée par #116 : le conteneur reçoit aussi `DATABASE_URL` et exécute le code du backend en sous-processus (ADR 0008). Atténuations en place : le compte admin Airflow est distinct des `app_user` et son mot de passe passe par l'environnement, jamais par `argv` ; et l'`APP_SECRET_KEY` donnée à Airflow est distincte de celle de l'API, pour qu'une compromission ne livre pas la clé de signature des JWT. | | **Cantonnement des accès ETL et ML** | **dette assumée** | Le compte applicatif porte l'identité, le rôle PostgreSQL porterait le cantonnement. Voir ADR 0003. Plus coûteuse depuis Airflow (#115) : ce service publie le port 8080 (sur le poste de développement ; sur la machine, sur la boucle locale seulement), détient les identifiants Postgres complets (`ML_DATABASE_URL`, mêmes que le backend) et permet de déclencher l'exécution de code depuis son interface. Un compte Airflow compromis atteint donc toute la base, pas seulement `reading`/`site`. Aggravée par #116 : le conteneur reçoit aussi `DATABASE_URL` et exécute le code du backend en sous-processus (ADR 0008). Atténuations en place : le compte admin Airflow est distinct des `app_user` et son mot de passe passe par l'environnement, jamais par `argv` ; et l'`APP_SECRET_KEY` donnée à Airflow est distincte de celle de l'API, pour qu'une compromission ne livre pas la clé de signature des JWT. |
| **Non-répudiation de l'audit** | **dette assumée** | Les déclencheurs arrêtent les accidents, pas un compte détenant `ALTER TABLE`. Voir ADR 0004. | | **Non-répudiation de l'audit** | **dette assumée** | Les déclencheurs arrêtent les accidents, pas un compte détenant `ALTER TABLE`. Voir ADR 0004. |
## Ce qu'il faut répondre, et ne pas répondre ## Ce qu'il faut répondre, et ne pas répondre
@@ -0,0 +1,356 @@
# EC02 · Management de projet : rapport collectif
**Groupe 3 (HEADL_015B) · Projet EnerVision.**
| | |
|---|---|
| **Fichier source** | `EADL26_EC02 - Rapport Collectif HEADL_015B-G3.md`, encodage UTF-8, aucun média externe |
| **Version figée** | `EADL26_EC02 - Rapport Collectif HEADL_015B-G3.pdf`, produite par la chaîne Markdown → HTML → CSS de pagination → PDF |
| **Dépôt** | Devoir Teams, à côté du ZIP du dépôt Git, **et** dans le dépôt Git lui-même (`docs/livrables/EC02/`) |
| **Échéance** | Vendredi 25/09/2026, 9h00 (gel technique) |
| **Relevé** | **24/09/2026 à 14h25**, sur le commit gelé `9f343e9` (`dev` = `main`) et par l'API GitHub : dépôt, tracker, board, jalons, PR et revues relevés au même instant, heures en heure locale (CEST) |
Chaque chiffre de ce rapport est reproductible par une commande citée en fin de document : le
critère officiel est un compte rendu d'activité « complet et **honnête** ». Les chiffres du
rapport du 23/09 (board du 18/09, tracker du 21/09) sont remplacés, pas complétés.
---
## 1. Organisation de l'équipe
Le pilotage passe par un **GitHub Project** (« EnerVision », projet n°2), avec assignation
nominative, et par le dépôt `ProjetPiscine_EnerVision`, branche d'intégration `dev`, branche de
production `main`.
### Activité par membre
| Membre | Compte GitHub | Commits sur `dev`, hors merges | PR mergées, auteur principal | PR mergées par lui | Board : Done / En cours / Todo | Domaines observés dans ses PR |
|---|---|---|---|---|---|---|
| Johan LEROY | `JohanLeroy` | 172 | 34 | 59 | 36 / 0 / 0 | Socle backend et sécurité, moteur de règles, vues frontend, déploiement et environnements, CI, supervision, stockage objet |
| Dorian PESCE | `phyri0s` | 42 | 11 | 11 | 16 / 0 / 0 | Terraform k3s, endpoints, pipeline LightGBM, scoring, alertes internes, DAGs ML, DAST, en-tête CORP, réconciliation des sources |
| Inès ZANG | `ineszang` | 54 | 4 | 5 | 5 / 2 / 0 | Terraform initial, pipeline CI, SonarCloud, administration du dépôt, procédures de déploiement |
| Meryem EL GHAM | `Meryemel-gham` | 23 | 6 | 2 | 7 / 2 / 0 | Schéma de données, imports historique et API Mock, DAGs d'import |
| Valentin DE FARIA RODRIGUES | `ValentinDeFaria` | 20 | 8 | 4 | 12 / 0 / 0 | Frontend et ses tests, auth frontend, audit de dépendances, supervision des capteurs, registre MLflow, amorce Garage |
| Dependabot | - | 12 | 12 | - | - | Mises à jour de dépendances |
| *(remontées `dev` → `main`)* | - | - | 6 | - | - | - |
| *(non assigné)* | - | - | - | - | 1 / 0 / 1 | - |
Totaux : **323 commits** hors merges sur `dev` (458 avec merges), **81 PR mergées**, 69 éléments
au board. Méthode : l'**auteur principal** d'une PR est l'auteur majoritaire des commits de sa
branche, qui peut différer du membre qui l'a ouverte. Les six remontées de `dev` vers `main` ne sont attribuées à
personne. Le nombre de commits mesure une activité, pas une valeur : les pratiques de découpage
diffèrent d'un membre à l'autre.
**Correspondances nom / identifiant.** Établies par Git : `Dorian`, `Dorian PESCE` et
`Phyrios` sont le compte `phyri0s` ; `ineszang` et `ineszang44` partagent la même adresse,
`Valentin` et `valentin` aussi.
**Rôles principaux** (Tech Lead, Cloud/DevOps, Data & IA, Fullstack Dev, PO). Seul celui de
Tech Lead a été nommé au départ ; les autres se lisent dans les PR de chacun :
| Membre | Rôle principal exercé |
|---|---|
| Johan LEROY | **Tech Lead** : architecture, intégration, sécurité, déploiement |
| Dorian PESCE | **Data & IA** : modèle LightGBM, DAGs ML, scoring, scan DAST |
| Inès ZANG | **Cloud / DevOps** : Terraform initial, pipeline CI, SonarCloud, administration du dépôt |
| Meryem EL GHAM | **Data** : schéma de données, imports historique et API Mock, DAGs d'import |
| Valentin DE FARIA RODRIGUES | **Fullstack Dev** : frontend et ses tests, supervision des capteurs, registre MLflow |
Le rôle de **PO** n'a pas eu de titulaire nommé : les arbitrages de périmètre ont été pris aux
points d'avancement, dont la coupe du 21/09. Le formateur demandait une **rotation** des rôles
sur les deux semaines : elle n'a pas eu lieu. Chacun est resté sur son domaine d'origine, ce qui a
favorisé la vitesse au détriment de la polyvalence.
### RACI
Reconstitué depuis l'historique des PR mergées, chaque PR étant rattachée aux chantiers dont elle
touche les fichiers : *Responsible* = qui écrit, *Accountable* = qui valide le merge,
*Consulted* = qui relit (revue ou commentaire), *Informed* = toute l'équipe, par le board et les
points d'avancement.
| Chantier | Responsible | Accountable | Consulted | Informed |
|---|---|---|---|---|
| Backend / API | les cinq membres | `ineszang`, `JohanLeroy`, `Meryemel-gham`, `phyri0s` | `JohanLeroy`, `Meryemel-gham`, `phyri0s`, `ValentinDeFaria` | équipe |
| Frontend | `ineszang`, `JohanLeroy`, `phyri0s`, `ValentinDeFaria` | `ineszang`, `JohanLeroy`, `phyri0s`, `ValentinDeFaria` | `JohanLeroy`, `Meryemel-gham`, `phyri0s` | équipe |
| Data & ML | `JohanLeroy`, `Meryemel-gham`, `phyri0s`, `ValentinDeFaria` | les cinq membres | `JohanLeroy`, `phyri0s` | équipe |
| Infra / CI-CD | les cinq membres | les cinq membres | `JohanLeroy`, `Meryemel-gham`, `phyri0s` | équipe |
| Sécurité | `JohanLeroy`, `Meryemel-gham`, `phyri0s`, `ValentinDeFaria` | `JohanLeroy`, `phyri0s` | `JohanLeroy`, `Meryemel-gham`, `phyri0s` | équipe |
Membres cités par ordre alphabétique de leur compte. Lecture : chaque chantier compte au moins
quatre contributeurs, plusieurs membres ont validé des merges sur chacun, et chacun a été relu
par au moins deux membres. Le RACI n'a pas été posé en amont : il est reconstitué depuis les
merges et les revues.
---
## 2. Méthodologie, backlog, user stories
Constat factuel tiré du GitHub Project, du tracker d'issues et du dépôt :
- **Méthodologie** : **Kanban à jalons**, pratiqué sans avoir été nommé en amont. Le board a
**3 colonnes** (`Todo` / `In progress` / `Done`), découpé en **2 itérations** (« Première
semaine », « Seconde semaine ») et **6 jalons datés**.
- **Priorisation MoSCoW** appliquée à chaque ticket, et **respectée dans les faits** : au
24/09, **56 `Must` faits sur 56**, 5 `Should` sur 5, 4 `Could` sur 6. Au 18/09, 66 % des
`Must` étaient faits contre 0 % des `Should` et des `Could` : aucun ticket de confort n'a été
pris avant un ticket essentiel.
- **Estimation en taille de tee-shirt** : 48 S, 13 M, 5 XS, 3 sans taille.
- **Traçabilité ticket → PR → commit** : chaque ticket livré porte ses PR liées.
- **Revue de code avant merge.** **55 des 62 PR de fonctionnalité (89 %)** ont été relues par
un autre membre avant merge, par revue formelle ou commentaire ; les remontées de `dev` vers
`main` ne portent que des PR déjà relues.
- **Intégration.** **81 PR mergées** : 62 vers `dev`, 19 vers `main` (6 remontées, 1 réglage
Sonar, 12 Dependabot). Les cinq membres ont mergé des PR.
- **Décisions écrites.** **20 ADR** versionnées dans `docs/adr/`, dont deux procédures de
déploiement (0011, 0012) qui relèvent davantage de la note d'exécution que de l'ADR.
- **Points d'avancement** les 15, 17, 18 et 21/09, chacun terminé par une décision ; ceux du 15
et du 18/09 sont versionnés dans `docs/dailies/`. Le compte rendu du 18/09 a été rédigé après
coup, le 21/09.
- **Étiquettes par domaine** sur les issues (`feature`, `backend`, `frontend`, `ml`, `infra`,
`ci/cd`, `test`, `securite`, `pipeline ETL`, `accessibility`).
- **User stories formalisées** (« en tant que... je veux... afin de... ») : non retrouvées
telles quelles, le besoin fonctionnel est porté par le corps des issues.
---
## 3. Planning, jalons, gestion des risques
### Jalons internes du projet
À ne pas confondre avec la numérotation J1 à J10 du calendrier de formation : ce sont deux
échelles différentes.
| Jalon projet | Échéance (API) | Fermées / total au 24/09 | État |
|---|---|---|---|
| J1 · Environnement et dépôt | 14/09 | 5/5 | clos le 15/09 |
| J2 · Périmètre et choix technologiques | 15/09 | 4/4 | clos le 16/09 |
| J3 · Ingestion et backend | 21/09 | 22/22 | tout fermé, jalon laissé ouvert |
| J4 · Architecture, sécurité, frontend | 22/09 | 24/24 | tout fermé, jalon laissé ouvert |
| J5 · Robustesse et livrables | 23/09 | 5/6 | reste #154 (déclaration IA et RGPD, portée par ce rapport) |
| J6 · Amélioration possible | 28/09 | 7/9 | créé le 21/09 pour le périmètre coupé (§5) ; restent #11 et #54 |
Le point d'avancement du 21/09 donnait le 18/09 et le 21/09 pour J3 et J4 ; l'API donne
aujourd'hui le 21/09 et le 22/09. L'API ne garde pas l'historique des échéances : l'écart est
signalé, pas expliqué.
### Calendrier institutionnel
| Jalon | Contenu | Date |
|---|---|---|
| J1 | Rendu EC01, dossier de conception individuel | fait, 14/09 |
| J9 | Oral EC01, 15 min + ~10 min de questions | jeudi 24/09 |
| J10 | Gel technique 9h00, rendu EC02 à EC06, oral EC02 (15 min + ~5 min de vidéo + ~5 min de questions) | vendredi 25/09 |
### Risques identifiés et leur traitement
| Risque | Impact | Statut au 24/09 |
|---|---|---|
| **`main` en retard sur `dev`** | `main` est la branche par défaut et celle de la production | **Traité.** Six remontées (#125, #152, #160, #161, #163, #165) ; au gel, `main` et `dev` portent le même commit. Sept déploiements de production réussis, le dernier sur le commit gelé |
| Tickets sans assigné (17 au 21/09) | Aucun responsable identifié | **Traité par arbitrage** : coupe du 21/09 (§5), puis assignation. Restent 2 issues ouvertes sans assigné, #55 et #154 |
| Jalon J5 sans assigné (#41, #45, #46, #47) | Preuves attendues pour EC03 et EC04 | **Traité** : les quatre livrés et assignés, DAST (#140), tests d'intégration (#147), e2e Playwright et charge k6 (#148) |
| Aucun scan de code dans le pipeline | Note DevSecOps EC03 / EC04 | **Traité** : SAST Bandit bloquant (#121), DAST OWASP ZAP (#140). **Restent hors CI** : Trivy et gitleaks, joués à la main pour le rapport EC04 |
| Aucun déploiement | Attendu explicite d'EC03 et EC04 | **Traité et constaté** : trois environnements sur la machine du groupe (production sur `main`, recette sur `dev`, dev à la demande), certificats Let's Encrypt, runner auto-hébergé (ADR 0009, 0017, 0018) |
| Montée de version majeure d'Airflow par Dependabot (#135) | Provisionnement et déploiement cassés | **Traité le jour même** (#143) |
| Trois PR immobilisées par un quality gate mal configuré | Blocage de la chaîne de merge | **Traité le 18/09**, en configuration et non par contournement |
| Mémoire de la machine (8 Go) insuffisante pour trois stacks | Arrêts par manque de mémoire | **Traité** : portée à 32 Go sur demande à l'école (ADR 0017) |
| Chiffrement au repos (#42) | Données en clair sur le disque | **Partiel, découvert le 24/09** : la machine est un conteneur LXC où LUKS est impossible ; seules les archives sont chiffrées (SSE-C, ADR 0020), demande adressée à l'école |
| Production sans approbation humaine, branches non protégées | Un push non relu part en production | **Ouvert** : annoncés par l'ADR 0009, laissés à poser par l'ADR 0014, jamais activés ; seule l'administratrice du dépôt peut le faire |
| Services hors dépôt sur la machine : k3s et trois serveurs Vault, installés depuis une branche de travail non fusionnée | Surface exposée que le code livré ne documente pas : l'API k3s et les trois Vault écoutent sur toutes les interfaces, k3s redémarre en boucle depuis le 17/09 | **Découvert le 24/09**, à arbitrer par l'administratrice : le code livré n'en dépend pas (rapport EC04, constat 1) |
---
## 4. Compte rendu d'activité honnête
### Indicateurs, depuis la baseline
**Baseline : 57 issues créées le 14/09**, jour 1. Série quotidienne relevée par l'API :
| Jour | Issues créées | Périmètre | Fermées | Cumul fermées | Ouvertes | PR mergées | Cumul PR |
|---|---|---|---|---|---|---|---|
| 14/09 | 57 | 57 | 5 | 5 | 52 | 3 | 3 |
| 15/09 | 1 | 58 | 10 | 15 | 43 | 7 | 10 |
| 16/09 | 1 | 59 | 8 | 23 | 36 | 10 | 20 |
| 17/09 | 6 | 65 | 10 | 33 | 32 | 9 | 29 |
| 18/09 | 2 | 67 | 6 | 39 | 28 | 9 | 38 |
| 21/09 | 5 | 72 | 16 | 55 | 17 | 11 | 49 |
| 22/09 | 1 | 73 | 2 | 57 | 16 | 18 | 67 |
| 23/09 | 3 | 76 | 11 | 68 | 8 | 12 | 79 |
| 24/09 | 0 | 76 | 4 | 72 | 4 | 2 | 81 |
| Indicateur | 16/09 | 18/09 | 24/09 |
|---|---|---|---|
| Board : Done / In progress / Todo | 19 / 6 / 26 | 33 / 6 / 21 (16h) | **66 / 2 / 1** |
| `Must` faits | 45 % | 66 % | **100 %** (56/56) |
| `Should` et `Could` faits | 0 % | 0 % | 100 % et 67 % |
| Issues fermées / périmètre | 23 / 59 | 39 / 67 | **72 / 76** |
**Deux lectures à défendre.** La priorisation est tenue dans les faits : le premier `Should`
n'a été fermé que le 23/09 à 11h10, quand 54 des 56 `Must` l'étaient déjà. Et le périmètre a dérivé de
57 à 76 issues (+33 %), dont 19 créées en cours de route : la dérive a été absorbée par la coupe
du 21/09 (§5) plutôt que laissée ouverte. Le 22/09 illustre la limite du comptage : 2 issues
fermées, mais 18 PR mergées, le déploiement continu et Terraform, les plus lourdes du projet.
### Livré depuis le 18/09 à 16h00
| PR | Mergée le | Contenu |
|---|---|---|
| #113, #118 | 18 et 21/09 | Détection des alertes internes ; entraînement et scoring LightGBM orchestrés par deux DAGs |
| #114, #124 | 21/09 | Moteur de règles de recommandation (ADR 0006) ; DAG d'alertes et de recommandations (ADR 0008) |
| #112, #138, #146 | 21 et 22/09 | Import depuis l'API Mock borné ; import historique, puis import horaire, orchestrés par Airflow |
| #107, #123 | 21 et 22/09 | Supervision des capteurs par site ; enregistrement du modèle dans le registre MLflow |
| #117, #121 | 21/09 | Reverse proxy Nginx et TLS (ADR 0007) ; SAST Bandit et vue CI/CD |
| #136, #137 | 21/09 | Flux d'alertes du tableau de bord ; vue recommandations |
| #139, #143, #144 | 22/09 | Déploiement continu, runner auto-hébergé (ADR 0009) ; réalignement Airflow 3 ; Terraform provisionne la machine (ADR 0010) |
| #140 | 22/09 | Scan dynamique OWASP ZAP de l'API |
| #147 | 22/09 | Tests d'intégration API, base et ML ; surveillance de dérive (ADR 0013) |
| #148 | 23/09 | CI unifiée (ADR 0014), e2e Playwright et charge k6 (ADR 0015), supervision Prometheus, Alertmanager, Grafana (ADR 0016) |
| #149 | 23/09 | En-tête `Cross-Origin-Resource-Policy` sur toutes les réponses |
| #151 | 23/09 | Troisième environnement, `dev`, déployé à la demande (ADR 0017) |
| #156, #159 | 23/09 | Noms publics, certificats Let's Encrypt par DNS-01, frontal SNI (ADR 0018) |
| #162 | 23/09 | Réconciliation des deux sources de relevés |
| #164 | 24/09 | Garage par environnement, rétention exportée des relevés, chiffrement des archives (ADR 0019, 0020) |
| 6 remontées, #142, 12 Dependabot | 21 au 24/09 | Mises en production, analyse Sonar sautée sur les PR Dependabot, mises à jour de dépendances |
### Ce qui n'a pas été livré, et pourquoi
- **#11 Responsive et #54 comparateur de scénarios** : `Could`, en cours au gel, jalon J6.
Démonstration sur poste, sans usage mobile dans le scénario du client pilote.
- **#55 Bouton de pic fictif** : ni assigné, ni au board, rien dans le code.
- **#43 Accessibilité** : fermée en doublon le 23/09, au motif qu'elle serait couverte par les
tests Playwright ; **aucun test d'accessibilité n'existe**. La fermeture est à corriger dans
l'outil, pas à défendre.
- **#42 Chiffrement au repos** : fermée comme faite, livrée **en partie** (archives seulement),
pour une raison d'infrastructure découverte le 24/09 (§3).
- **Loki, Trivy et gitleaks en CI, approbation de la production** : prévus ou annoncés, non
faits.
- **Rotation des rôles** : elle n'a pas eu lieu (§1).
Lecture honnête : tout ticket assigné à quelqu'un a été mené au bout ou reste en cours au gel.
Le retard du 21/09 n'était pas un problème d'exécution individuelle mais de **répartition** : il
a été traité par la coupe et l'assignation, puis rattrapé. Les faiblesses restantes sont de
rigueur de processus, pas de livraison : fermetures d'issues trop généreuses (#42, #43) et
réglages de protection jamais activés.
---
## 5. Périmètre coupé
**Un périmètre coupé et argumenté est un acte de management ; une issue laissée ouverte sans
rien est un trou.** La coupe a été faite le 21/09, jour de l'échéance du J4, et tracée dans
l'outil : un jalon **J6 « Amélioration possible »**, échéance 28/09, donc après le gel. Elle a
servi à ordonner, pas à renoncer : une fois les `Must` faits, une partie du J6 a été livrée
avant le gel.
| Issue | Sujet | Raison de la coupe au 21/09 | Au gel |
|---|---|---|---|
| #11 | Responsive | Démonstration sur poste, aucun usage mobile | en cours, non livré |
| #24 | MinIO, couche bronze | La charge brute est déjà en base (`raw_data`) | **livré autrement** : Garage, jugé plus léger (ADR 0019) |
| #26 | Monitoring Prometheus et Grafana | Classé bonus par le sujet | **livré** (#148, ADR 0016) |
| #36 | Rétention des données | Jeu de démonstration borné | **livré** : export vers Garage puis suppression (#164) |
| #42 | Chiffrement au repos | Données synthétiques, priorité au chiffrement en transit | **partiel** : archives chiffrées, base en clair (ADR 0020) |
| #43 | Accessibilité | Hors des critères de notation technique | fermée en doublon, **non livrée** |
| #54 | Comparateur de scénarios | Confort (`Could`) | en cours, non livré |
Les tests e2e (#46) et de charge (#47), dont la coupe était proposée le 21/09, ont finalement
été livrés (#148). Deux sacrifices restent assumés : **Big Data**, hors de portée dans le temps
imparti, et **RPA avancé**, au-delà de l'orchestration Airflow.
---
## 6. Usage de l'intelligence artificielle
Déclaration exigée par le formateur : outils utilisés, tâches réalisées, valeur ajoutée,
limites constatées, vérifications humaines.
### Déclaration de Johan LEROY
| | |
|---|---|
| **Outil** | Claude Code (Anthropic), en assistant de développement dans le terminal et l'IDE |
| **Tâches** | Aide à la rédaction de code backend, d'infrastructure et de tests, relecture de PR en amont de la revue humaine, rédaction et mise à jour de la documentation d'architecture et des ADR, analyse d'écarts entre le dépôt et les attendus, relevés chiffrés de ce rapport |
| **Valeur ajoutée** | Vitesse sur le travail répétitif (gabarits de tests, migrations, documentation), et surtout **recoupement systématique** : détection d'incohérences entre documentation et code qu'une relecture humaine laisse passer |
| **Limites constatées** | Des **chiffres plausibles mais faux** quand ils ne sont pas recalculés ; des **références à des fichiers inexistants** ; une tendance à **présenter comme acquis** ce qui n'est que prévu (l'approbation de la production, annoncée par deux ADR, n'a jamais été activée) ; des **horodatages en UTC recopiés comme heure locale** |
| **Vérifications humaines** | Tout code généré passe par la CI (lint, typage, tests, couverture, SAST) et par une revue de PR. Tout chiffre publié est réobtenu par une commande (`gh`, `git log`) au moment de la rédaction. Les décisions d'architecture restent prises et signées en ADR par un humain |
### Déclarations des autres membres
Non transmises au moment du gel. Le gabarit reste celui de la déclaration ci-dessus : outil,
tâches, valeur ajoutée, limites, vérifications.
---
## 7. Licences logicielles
**Licence du dépôt** : aucun fichier `LICENSE` au gel. Faute de licence explicite, le code reste
sous le régime par défaut : tous droits réservés à ses auteurs, aucune réutilisation accordée.
**Dépendances, relevées le 24/09** par `pip-licenses` (verrous figés, sans dépendances de dev)
et `license-checker --production` :
| Périmètre | Résultat |
|---|---|
| Backend, ML, Airflow (Python) | MIT, BSD, Apache 2.0 et PSF pour l'essentiel. **Aucune GPL ni AGPL embarquée.** À noter : `psycopg` (ML) sous LGPL 3.0, utilisé comme bibliothèque, sans obligation sur le code appelant ; `certifi` et `pathspec` sous MPL 2.0, copyleft limité au fichier, non modifiés ; `text-unidecode` (Airflow) sous double licence Artistic ou GPL, retenue sous Artistic |
| Frontend, dépendances de production | 12 paquets : 10 MIT, 1 Apache 2.0, 1 0BSD |
**Composants exécutés à côté du produit**, chacun dans son conteneur, sans modification :
| Composant | Rôle | Licence |
|---|---|---|
| Nginx | Reverse proxy, frontal SNI | BSD 2-Clause |
| PostgreSQL · TimescaleDB | Base, séries temporelles | PostgreSQL License · Apache 2.0 pour le cœur, Timescale License pour certaines fonctions de l'image utilisée |
| Apache Airflow, MLflow | Orchestration, registre de modèles | Apache 2.0 |
| Prometheus, Alertmanager, exporteurs, cAdvisor | Supervision | Apache 2.0 |
| **Grafana, Garage, k6** | Tableaux de bord, stockage objet, tirs de charge en CI | **AGPL 3.0** |
| acme.sh | Certificats Let's Encrypt | GPL 3.0 |
| Mailpit | Courriel de développement et d'alerte | MIT |
| Terraform | Infrastructure as code | **BUSL 1.1** : usage interne autorisé, redistribution concurrente interdite |
Les composants AGPL et GPL tournent sans modification, dans des conteneurs séparés : ils
n'imposent rien au code du produit. Point de cohérence : l'ADR 0019 écarte MinIO en citant
notamment sa licence AGPL, que Garage partage ; les autres raisons de l'ADR restent.
---
## 8. Anonymisation et conformité RGPD
- **Les données du projet sont synthétiques.** Les séries de consommation proviennent des CSV
fournis par l'organisme de formation et de l'API Mock simulée. Aucune donnée de consommation
réelle d'un client identifiable n'est présente dans le dépôt.
- **Les sites sont désignés par identifiants** (`SITE001` à `SITE007`), sans raison sociale ni
adresse.
- **Les comptes applicatifs** de test utilisent des adresses de domaine fictif et des mots de
passe de test. Le compte du scan DAST est un compte `lecteur` jetable.
- **Aucun secret dans le dépôt, vérifié** : scan gitleaks sur tout l'historique le 24/09, 342
commits hors merges, 8 constats, tous faux positifs après tri ligne à ligne ; aucun `.env`,
clé, certificat ni state Terraform jamais commité (rapport EC04, preuve 05). C'est le ZIP
`.git` complet qui est remis au jury : le contrôle porte donc bien sur l'historique.
- **Adresse de la machine** : l'adresse privée de la machine du groupe apparaît dans des
documents d'exploitation de l'historique git. Adresse non routable, joignable depuis le seul
réseau de l'école ; elle est masquée dans l'arbre livré.
- **Journal d'audit** : les accès sont tracés en ajout seul (ADR 0004). Cette table contient des
identifiants d'utilisateurs applicatifs : sur un déploiement réel, elle relèverait d'une durée
de conservation définie. Elle n'est pas fixée à ce jour : à arrêter avant tout déploiement réel.
- **Dans ce rapport et les supports d'oral** : aucune URL, adresse IP, identifiant de connexion
ou coordonnée personnelle n'est reproduite. Les identifiants GitHub sont des pseudonymes
publics, conservés parce qu'ils sont la seule clé de traçabilité vérifiable.
---
## Sources
Relevé du 24/09/2026 à 14h25, commit `9f343e9` :
- **Git** : `git rev-list --count origin/dev` et `--no-merges` · `git log origin/dev
--no-merges --format=%an | sort | uniq -c` (alias regroupés) · `git log --no-merges
<merge>^1..<merge>^2` (auteur principal) · `git ls-tree --name-only origin/main docs/adr/` ·
`git rev-list --count origin/main..origin/dev`
- **API GitHub** : `gh issue list --state all --limit 300 --json
number,state,assignees,milestone,createdAt,closedAt` · `gh project item-list 2 --owner
ineszang --format json` · `gh api repos/ineszang/ProjetPiscine_EnerVision/milestones?state=all`
· `gh pr list --state all --limit 300 --json number,author,mergedBy,mergedAt,baseRefName,reviews`
· `gh api .../deployments?environment=prod`
- **Licences** : `uv run --frozen --no-dev --with pip-licenses pip-licenses --from=mixed` dans
chaque projet Python · `npx license-checker@25 --production --summary` dans `apps/frontend`
- `CONSIGNES-PROJET.md`, documents officiels 01, 02 et 05, points d'avancement des 15, 17, 18 et
21/09, rapport de sécurisation EC04
@@ -0,0 +1,431 @@
# EC04 · Rapport de sécurisation · EnerVision
| | |
|---|---|
| **Auteur** | Johan LEROY, Groupe 3 (HEADL_015B), Tech Lead |
| **Date du relevé** | 24/09/2026, de 14h45 à 15h15 |
| **Référence du code** | commit gelé `9f343e9` du 24/09/2026 à 11h08, porté à la fois par `dev` et par `main` |
| **Périmètre** | la plateforme EnerVision et sa mise en ligne sur la machine du groupe (conteneur LXC sur l'hôte Proxmox de l'école) : trois environnements Docker Compose (production, recette, dev), chacun derrière son reverse proxy Nginx, et un frontal SNI commun |
| **Preuves** | dossier `preuves/`, quatorze fichiers numérotés, sorties brutes datées et anonymisées, chacune avec la commande qui la rejoue |
| **Format** | source Markdown UTF-8 sans média externe, version figée PDF produite par la chaîne Markdown vers HTML vers CSS de pagination vers PDF |
Ce rapport compile des preuves, il ne décrit pas des intentions. Chaque affirmation porte l'un de
trois marqueurs :
| Marqueur | Sens |
|---|---|
| **[Prouvé]** | vérifiable dans le dépôt ou par l'API GitHub, rejoué le 24/09 avec sa sortie dans `preuves/` |
| **[Constaté]** | relevé sur la machine le 24/09 par une commande en lecture seule (`12-constats-machine.txt`) |
| **[Absent]** | non fait, avec sa raison et son coût |
Anonymisation : aucune adresse IP, URL, identifiant de connexion, adresse électronique ni secret
n'est reproduit, ici comme dans les preuves. La machine est notée « la machine du groupe », ses
noms publics `<env>.<domaine-du-groupe>`. Le dépôt, lui, a porté l'adresse privée de la machine
dans des documents d'exploitation : elle est masquée dans l'arbre livré, mais l'historique git la
conserve (section 3).
---
## 1. Synthèse
| Axe demandé | État | Ce qui le prouve |
|---|---|---|
| Scans IaC et code | **[Prouvé]** onze contrôles rejoués le 24/09 sur le commit gelé, **aucune vulnérabilité haute ou critique dans le code ni dans le produit livré** ; DAST : 0 échec sur 118 règles | Bandit, pip-audit, npm audit, Checkov, gitleaks, Trivy, `terraform validate`, `nginx -t`, OWASP ZAP |
| Gestion des secrets | **[Prouvé]** aucun secret dans l'historique git (342 commits hors merges, toutes branches) ; seize secrets générés sur la machine, jamais transmis | gitleaks, `git log` sur les fichiers sensibles, `scripts/provision-host.sh`, gardes de `config.py` |
| Règles réseau | **[Prouvé] [Constaté]** la plateforme n'expose que le frontal SNI, en 80 et 443, tout le reste sur la boucle locale ; **[Constaté]** la machine expose en plus SSH, k3s et trois Vault, hors code livré ; **[Absent]** pare-feu hôte | ports calculés par `docker compose config` et `provision-host.sh`, relevés par `ss` sur la machine |
| Authentification | **[Prouvé]** JWT court, rafraîchissement opaque avec rotation, Argon2id, RBAC à 3 rôles, **matrice d'accès testée** | 86 tests rejoués le 24/09, ADR 0002 et 0003 |
| Audits d'accès et d'infrastructure | **[Prouvé]** journal d'audit en ajout seul garanti par la base ; historique des déploiements ; supervision Prometheus active en production | ADR 0004 et 0016, state Terraform, API GitHub |
**Sept constats à traiter**, aucun bloquant pour le produit livré, détaillés en section 2.2 :
1. **La machine expose plus que la plateforme** : SSH en root avec mot de passe, l'API k3s et
trois serveurs Vault écoutent sur toutes les interfaces, sans pare-feu hôte. k3s et Vault ne
viennent pas du code livré (`12-constats-machine.txt`).
2. **Aucune approbation humaine avant la production**, contrairement à ce qu'annonce l'ADR 0009,
et **aucune branche protégée** (`13-github-reglages.txt`).
3. **Le chiffrement au repos est impossible sur cette machine** : c'est un conteneur LXC, où LUKS
ne peut pas fonctionner. La base est en clair sur le disque ; seules les archives sont
chiffrées (SSE-C, ADR 0020).
4. **Une vulnérabilité MEDIUM** (CVE-2026-41016) dans une dépendance transitive d'Airflow, que
**la CI ne peut pas voir** : son audit de dépendances ne porte que sur le verrou du backend.
5. **Le jeton de réinitialisation de mot de passe passe dans l'URL**, et le journal d'accès du
proxy l'enregistre en clair pendant ses quinze minutes de validité (`14-dast-zap.txt`).
6. **Du code non relu peut tourner sur la machine de production** : toute branche d'un membre,
par l'environnement `dev` (ADR 0017), et le workflow qu'apporterait une PR de fork, si son
exécution n'est pas soumise à approbation.
7. **Des fichiers sensibles dans l'arbre de travail du poste** (clé TLS locale, `.env`, state et
variables Terraform), ignorés par git mais **qui finiraient dans un ZIP fabriqué à partir du
dossier**. Le ZIP du rendu part donc d'un clone.
---
## 2. Scans de sécurité
### 2.1 Résultats
Tous les contrôles ont été lancés le 24/09/2026 entre 14h45 et 15h00 sur `9f343e9`. Checkov,
Trivy config et `terraform validate` portent sur un export `git archive` du commit, pour
qu'aucun fichier ignoré du poste ne s'y mêle ; Trivy fs porte volontairement sur l'arbre de
travail, pour le constat 7.
| # | Outil | Périmètre | Résultat | Preuve |
|---|---|---|---|---|
| 1 | **Bandit 1.9.4** (SAST Python) | `apps/backend/app`, `ml/enervision_ml`, `etl/airflow/dags` | **0 constat, tous niveaux**, sur 7 110 lignes (6 136 + 722 + 252) | `01` |
| 2 | **pip-audit** (verrou figé, sans dev) | backend 50 paquets, ML 94, Airflow 128 | backend **0**, ML **0**, Airflow **1 vulnérabilité** (PYSEC-2026-24) | `02` |
| 3 | **npm audit** (`--package-lock-only`) | frontend 511 dépendances, tests e2e 26 | **0, tous niveaux**, dans les deux verrous | `03` |
| 4 | **Checkov 3.3.19** (IaC) | Terraform, Dockerfiles, workflows GitHub, secrets | Terraform **0 ressource évaluable** · Dockerfile 268 réussis, **3 échecs** · workflows **596 réussis, 0 échec** · secrets 0 | `04` |
| 5 | **gitleaks** (secrets) | **tout l'historique**, toutes branches | 8 constats, **8 faux positifs** après tri ligne à ligne | `05` |
| 6 | **Trivy config** (IaC) | dépôt entier | **3 LOW** (HEALTHCHECK), Terraform propre | `06` |
| 7 | **Trivy fs** (dépendances et secrets) | arbre de travail du poste | la même CVE Airflow, et la clé TLS locale (fichier ignoré par git) | `07` |
| 8 | Tests d'accès du backend | matrice rôle × route, protection des routes, durcissement, JWT | **86 réussis**, 5 tests d'intégration désélectionnés (joués en CI) | `08` |
| 9 | `terraform fmt` et `validate` | les deux racines | **formatage conforme, deux configurations valides** | `09` |
| 10 | `nginx -t` et ports fusionnés | proxy de stack, frontal SNI, `docker-compose.prod.yml` | **deux configurations valides**, un seul composant exposé | `10` |
| 11 | State Terraform local | racine `vm-eni` | 3 ressources appliquées sur 4 déclarées | `11` |
| 13 | Réglages GitHub | environnements, branches, secrets, runs | voir constat 2 | `13` |
| 14 | **OWASP ZAP 2.17.0** (DAST) | l'API en fonctionnement, authentifiée | **0 échec, 0 avertissement, 118 règles passées**, 4 alertes informatives | `14` |
**Ce que la CI rejoue**, sur chaque PR et chaque push vers `dev` ou `main`, filtré par chemins,
derrière le check unique « CI ok » (ADR 0014) : Bandit bloquant à partir de MEDIUM sur le backend
et le ML, `pip-audit` sur le verrou du backend, `npm audit --audit-level=high` sur le frontend,
`terraform fmt` et `validate`, actionlint et shellcheck sur les workflows, validation des
fichiers Compose, `nginx -t` du frontal, `promtool` et `amtool` sur la supervision, une fumée S3
sur Garage avec chiffrement SSE-C, les parcours Playwright et deux tirs k6 (fumée et contrôle du
429 par le proxy), et une analyse SonarCloud. Dependabot suit sept entrées chaque semaine. Le
DAST tourne chaque lundi, à la demande, et sur les PR qui modifient son propre workflow.
**Ne sont pas en CI** : Checkov, Trivy, gitleaks, `pip-audit` sur les verrous ML et Airflow,
`npm audit` sur le verrou des tests e2e. Ils ont été joués pour ce rapport.
### 2.2 Lecture des constats
**1. La machine expose plus que la plateforme.** Relevé par `ss` le 24/09 : outre le frontal en
80 et 443, écoutent sur toutes les interfaces SSH (connexion root et mot de passe acceptés),
l'API **k3s** en 6443 et **trois serveurs Vault** 2.1.1 en 8200 à 8205, initialisés et
descellés. Aucun pare-feu ne filtre : la table nftables est vide, en politique `accept`. Ni k3s
ni Vault ne viennent du code livré : `git grep vault` ne trouve rien sur le commit gelé. Vault
est visé par le Terraform d'une branche de travail non fusionnée. k3s, installé
le 17/09, redémarre en boucle depuis (51 678 redémarrages) sans porter aucun pod. Correctif, sans
commit : arrêter k3s, restreindre Vault à la boucle locale ou l'arrêter, puis un pare-feu
n'ouvrant que 22, 80 et 443, et SSH par clé seule. C'est l'infrastructure de l'administratrice
du dépôt : la décision lui revient.
**2. Production sans approbation, branches sans protection.** L'environnement GitHub `prod` n'a
qu'une règle : il n'accepte que la branche `main`. Aucun relecteur n'est requis : le dernier
déploiement est passé de l'attente à l'exécution en une seconde. `main` et `dev` ne sont pas protégées, aucun
ruleset n'existe : trois commits ont été poussés directement sur `dev` (`c2f360c`, `cbbfaf4`,
`6c09bee`), relus ensuite seulement par les PR de remontée #161 et #163. L'ADR 0009 annonçait une
production « après approbation » ; l'ADR 0014 en faisait un réglage restant à poser par
l'administratrice. Il ne l'a jamais été : les deux ADR portent désormais une note datée du
24/09. Correctif : deux réglages, que seule l'administratrice du dépôt peut activer.
**3. Chiffrement au repos.** La commande `systemd-detect-virt` répond `lxc` : sans
device-mapper ni périphérique loop, LUKS est impossible. `scripts/coffre-luks.sh` le détecte et
refuse de démarrer (ADR 0020). La base TimescaleDB et les métadonnées de Garage sont donc en
clair sur le disque du conteneur. Ce qui est chiffré dès aujourd'hui : les archives exportées
vers Garage par le DAG `retention`, en SSE-C, avec une clé que Garage ne conserve pas. Le
chiffrement du disque relève de l'hôte Proxmox, donc de l'administrateur de l'école, à qui la
demande est adressée.
**4. CVE-2026-41016, MEDIUM, `apache-airflow-providers-smtp` 2.3.2.** Le `SmtpHook` d'Airflow
négocie STARTTLS sans valider le certificat. Dépendance **transitive** d'`apache-airflow` 3.3.2.
**Exposition actuelle : nulle**, aucun DAG n'envoie de courriel et aucune connexion SMTP n'est
déclarée dans Airflow. Correctif : `providers-smtp` 3.0.0 ou plus. **Le vrai constat est
ailleurs** : la CI audite le verrou du backend et pas ceux du ML ni d'Airflow, qui portent 222
paquets, et Dependabot ne suit en `uv` que le backend. La CVE était déjà relevée le 23/09 et elle
est toujours là : c'est exactement ce que produit un angle mort.
**5. Jeton de réinitialisation dans l'URL** (ZAP 10024, informatif). `GET
/api/v1/auth/reset-password/validate?token=…` : le jeton est stocké haché, valable quinze
minutes, à usage unique, caviardé des journaux de l'API, et `Referrer-Policy: no-referrer` est
posé. Mais le journal d'accès du proxy enregistre la requête complète, donc le jeton en clair,
lisible par qui administre la machine pendant sa validité. Correctif : passer la validation en
`POST`, ou journaliser `$uri` sans ses paramètres sur cette route.
**6. Du code non relu peut tourner sur la machine de production.** Le troisième environnement,
`dev`, se déploie par `workflow_dispatch` depuis n'importe quelle branche (ADR 0017). Il vit sur
la même machine et le même démon Docker que la production : tout membre qui a le droit
d'écriture peut y exécuter du code non relu. `deploy.yml` n'a jamais de déclencheur
`pull_request`, mais cela ne suffit pas, l'ADR 0014 le dit : une PR de fork peut apporter son
propre workflow qui cible le runner. La seule protection est alors l'approbation des workflows
des contributeurs externes, un réglage que l'API refuse de lire avec les droits d'un membre
(403) : non vérifié. Risque accepté pour une équipe de cinq, à fermer avant tout contributeur
extérieur.
**7. Fichiers sensibles du poste.** Trivy fs trouve la clé du certificat auto-signé local, et le
poste porte aussi, ignorés : deux `.env`, le state et les variables Terraform, les journaux de
session. Tous sont ignorés par git et n'ont jamais été versionnés (`05`). **Conséquence
opérationnelle pour vendredi : le ZIP du dépôt, `.git` inclus, se fabrique à partir d'un clone**,
jamais en compressant le dossier de travail.
**HEALTHCHECK absent** (Checkov CKV_DOCKER_2, Trivy DS-0026, LOW) sur les images frontend,
Airflow et ML. Le backend en porte un, et c'est lui que le proxy attend avant de démarrer ; la
base, l'API Airflow et Garage ont le leur dans `docker-compose.yml`. Reste le frontend, un nginx
statique qui tomberait sans être signalé. Impact faible.
**Terraform : Checkov n'évalue aucune ressource.** Les six ressources du dépôt sont des
`null_resource` qui agissent par SSH, pour lesquelles Checkov n'a aucune politique. Ce scan ne
prouve rien, dans un sens comme dans l'autre. Les garanties réelles sont ailleurs : validation en
CI, clé SSH seule, jeton du runner en variable `sensitive` et hors des triggers (`11`, ADR 0010).
**gitleaks : huit faux positifs.** Six viennent de la règle `generic-api-key` qui prend
`api_history` et `api_current`, deux valeurs de la colonne `source` des relevés, pour des clés.
Les deux nouveaux sont des valeurs de test : un identifiant S3 factice (`settings_s3()`) et le mot
de passe provisoire simulé d'un test Angular.
---
## 3. Gestion des secrets
| Mesure | Preuve | État |
|---|---|---|
| Aucun secret dans l'historique git, toutes branches ; aucun `.env`, `.pem`, `.key`, `.tfvars`, `.tfstate` ni jeton DNS jamais commité | `05` | [Prouvé] |
| `.env`, `*.pem`, `*.tfvars`, `*.tfstate` ignorés par git ; seuls les `*.example` sont versionnés ; `infra/garage/garage.toml`, versionné, ne porte aucun secret | `.gitignore`, `05` | [Prouvé] |
| **Seize secrets générés sur la machine** par `openssl rand` (base, API, jeton des métriques, cinq pour Airflow dont sa clé Fernet, six pour Garage dont la clé SSE-C, Grafana, rôle de supervision), `.env` écrit sous `umask 077` puis en `600`. Les secrets existants sont conservés ; le reste du fichier est réécrit à chaque passage pour réaligner hôte, ports et profils | `scripts/provision-host.sh` | [Prouvé] le script · [Constaté] les droits |
| Refus d'écrire le `.env` si une valeur d'exemple `change_me` subsiste, hors identifiants de l'API Mock posés à la main | `provision-host.sh`, fonction `preparer` | [Prouvé] |
| L'API **refuse de démarrer** si `APP_SECRET_KEY` fait moins de 32 caractères ou vaut une sentinelle, si `APP_DEBUG` est vrai hors local, ou si les origines CORS sont en joker ou absentes | `apps/backend/app/core/config.py` | [Prouvé] |
| Secrets typés `SecretStr`, clés S3 et SSE-C comprises, donc jamais journalisés ; jetons, mots de passe et cookies caviardés dans les journaux | `config.py`, `app/core/logging.py` | [Prouvé] |
| Compose exige par `${VAR:?}` les secrets de la base et de l'API ; ceux d'Airflow, de Garage et de la supervision sont gardés par `airflow-init` et par les gardes du `Makefile` avant tout démarrage | `docker-compose.yml`, `Makefile` | [Prouvé] |
| Clé de signature d'Airflow **distincte** de celle de l'API | `docker-compose.yml` | [Prouvé] |
| Côté GitHub, **un seul secret** (`SONAR_TOKEN`) ; le déploiement n'en consomme aucun | `13` | [Prouvé] |
| Terraform : clé SSH seule, jeton du runner en variable `sensitive`, absent des triggers | `11`, ADR 0010 | [Prouvé] |
| Jeton du scan DAST éphémère et caviardé des journaux publiés | `dast.yml`, `scripts/dast-token.sh` | [Prouvé] |
**Ce qui manque.** Le dossier EC01 prévoyait **SOPS + age** : non fait. Les secrets vivent en
clair sur le disque de la machine, protégés par les droits du fichier seulement, et la rotation
est manuelle. Le 23/09, ce rapport jugeait la situation acceptable pour deux environnements,
mais plus pour trois : **le seuil est franchi**, avec trois `.env`, un jeton DNS et une clé SSE-C dont
la perte rendrait les archives illisibles. L'ADR 0019 demande de sauvegarder cette clé hors de la
machine : c'est une procédure, rien ne le vérifie. Enfin, l'adresse privée de la machine figure
dans l'historique git (ADR d'exploitation du 21 et du 22/09) : adresse non routable, joignable
seulement depuis le réseau de l'école, masquée dans l'arbre livré.
---
## 4. Règles réseau
### 4.1 Surface exposée
Sur la machine, `provision-host.sh` ramène tous les ports des trois stacks sur la boucle locale
(`10`) :
| Composant | Publication sur la machine | Joignable depuis |
|---|---|---|
| **Frontal SNI** (`infra/front`, nginx `stream`) | réseau de l'hôte, **80 et 443** | le réseau |
| Proxy Nginx de chaque stack | `127.0.0.1` : 10443, 8443, 9443 (HTTPS) et l'écouteur PROXY protocol du frontal | la machine seule |
| Base, Mailpit, API Airflow | `127.0.0.1`, un port par environnement | la machine, donc par tunnel SSH |
| Garage (S3 et administration) | `127.0.0.1`, un port par environnement ; pas encore en `dev`, resté sur le commit du 23/09 | la machine seule |
| Prometheus, Alertmanager, Grafana (production) | `127.0.0.1` | la machine seule |
| Backend, frontend, scheduler et processeur Airflow, exporteurs | aucune | le réseau interne de Compose |
| *Hors plateforme* : SSH, API k3s, trois Vault | toutes les interfaces : 22, 6443, 8200 à 8205 | **le réseau** (constat 1) |
**Un seul composant exposé par la plateforme.** Le frontal lit le nom demandé (SNI) sans
déchiffrer et relaie la connexion, en PROXY protocol, vers le proxy de la stack visée : l'adresse
réelle du client arrive jusqu'à la limitation de débit. Les trois environnements sont trois
projets Compose distincts, sans réseau ni volume partagé (ADR 0009, 0017, 0018). Sur la
machine, `ss` confirme les ports de la plateforme, mais relève aussi les services hors plateforme
du constat 1. [Constaté, `12`]
### 4.2 Reverse proxy et TLS
| Directive | Valeur | Effet |
|---|---|---|
| Certificats | **Let's Encrypt par défi DNS-01** (acme.sh, domaine dynv6), vérifiés chaque nuit par une tâche cron et renouvelés à échéance ; l'auto-signé ne sert plus qu'au poste et aux tests e2e | chaîne de confiance publique, sans port 80 ouvert pour le défi (ADR 0018) |
| Protocoles | TLS 1.2 et 1.3, tickets de session désactivés | pas de protocole obsolète |
| Redirection | 80 vers 443 | pas de trafic applicatif en clair |
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | le navigateur refuse ensuite le HTTP |
| `Content-Security-Policy` | `default-src 'self'`, `frame-ancestors 'none'`... | limite l'injection de script et le clickjacking |
| En-têtes de l'API | `nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, `Cross-Origin-Resource-Policy: same-origin` | défense en profondeur si le proxy manquait |
| Limitation de débit | `api` 20 req/s, `auth` 30 req/min sur les routes qui vérifient un secret ; contrôlée par un tir k6 en CI | freine la force brute sans pénaliser la navigation derrière le NAT de l'école |
| `server_tokens off`, corps limité à 2 Mo | | pas de version publiée, pas de requête démesurée |
Les deux configurations sont validées par `nginx -t` sur l'image de production (`10`).
Certificats servis : Let's Encrypt (émetteur YE1) sur les trois noms, échéance au 22/12/2026, et
les trois sondes de santé répondent 200 par le nom public avec un certificat vérifié, sans `-k`.
[Constaté, `12`]
### 4.3 Accès à la machine et déploiement
- Le **runner GitHub Actions** auto-hébergé initie lui-même la connexion vers GitHub : **aucun
port entrant** n'est ouvert pour déployer. [Prouvé]
- `deploy.yml` ne se déclenche **jamais sur `pull_request`** : sur un dépôt public, une PR venue
d'un fork exécuterait sinon son code sur la machine. Il n'est appelé qu'après une CI verte sur
un push, et refuse de revenir à un commit plus ancien que celui déployé. [Prouvé]
- La production n'accepte que `main`, **sans approbation humaine** (constat 2). [Prouvé, `13`]
- Terraform se connecte par **clé SSH**, jamais par mot de passe. [Prouvé]
**Ce qui manque.** **Aucun pare-feu hôte** n'est configuré ni documenté : la restriction repose
sur la publication des ports et sur le réseau de l'école ; la table nftables est vide, en
politique `accept` [Constaté, `12`]. **SSH n'est pas durci** : connexion root et authentification
par mot de passe acceptées [Constaté, `12`] ; Ansible était prévu au dossier EC01 et n'a pas été
fait. Pas de
réseaux Docker nommés : l'intention du dossier EC01 (la base jamais exposée) est tenue par
l'absence de publication, plus fragile à la relecture qu'une politique explicite. Azure n'est pas
utilisé, par choix d'architecture on-premise : il n'y a ni NSG ni Application Gateway à auditer.
---
## 5. Authentification et autorisation
| Mécanisme | Détail | Trace |
|---|---|---|
| Jeton d'accès | JWT HS256, **15 minutes**, algorithme épinglé, `aud`, `iss` et `typ` vérifiés, `alg: none` rejeté, gardé en mémoire côté navigateur | ADR 0002, `app/core/security.py` |
| Jeton de rafraîchissement | chaîne **opaque de 256 bits**, stockée hachée, **rotation à chaque usage et détection de réutilisation** ; cookie `HttpOnly`, `Secure`, `SameSite=Strict`, chemin restreint | ADR 0002, `app/services/auth.py` |
| Mots de passe | **Argon2id** (m=19 456 Kio, t=2, p=1), re-hachage passif si les paramètres changent | `app/core/hashing.py` |
| Réinitialisation | jeton haché, 15 minutes, usage unique ; voir le constat 5 | `app/models/password_reset_token.py` |
| Force brute | limitation à fenêtre glissante sur trois clés, **évaluée avant le hachage** ; pas de verrouillage de compte, qui serait un déni de service | ADR 0002, `login_attempt` |
| Énumération de comptes | message et temps de réponse identiques quelle que soit la cause | `app/services/auth.py` |
| Autorisation | **RBAC à trois rôles ordonnés** (`lecteur`, `operateur`, `admin`), décision prise sur **la ligne en base relue à chaque requête**, jamais sur le claim | ADR 0003, `app/api/deps.py` |
| Révocation | immédiate : compte désactivé ou mot de passe changé invalide les jetons antérieurs | `credentials_changed_at` |
| Refus par défaut | **16 routes sous rôle**, 3 authentifiées sans rôle, 1 par cookie, 8 publiques listées nommément (dont `/metrics`, gardée par son propre jeton) ; un test appelle réellement chaque route sans jeton | `tests/api/acces.py`, `test_route_protection.py` |
| Matrice d'accès | chaque route gardée croisée avec les trois rôles, sur les routes réelles, puis rejouée avec de vrais jetons contre une vraie base en CI | `test_matrice_acces.py` |
| Garde-fous d'administration | refus de rétrograder ou désactiver le dernier administrateur ; premier administrateur créé hors dépôt | `app/services/user.py`, `app/cli.py` |
**Preuve d'exécution** : 86 tests d'accès, de protection des routes, de durcissement et de JWT,
réussis le 24/09 (`08`) ; le DAST authentifié ne relève ni échec ni avertissement (`14`).
**Ce qui reste ouvert, et c'est écrit dans le dépôt** (`owasp-traceabilite.md`) : **pas
d'autorisation par objet** (OWASP API1). Les rôles sont globaux, un compte `lecteur` lit tous les
sites. Sans conséquence tant que les routes métier sont en lecture seule ; à corriger par une table
d'affectation compte-site avant la première route d'écriture.
---
## 6. Audits d'accès et d'infrastructure
### 6.1 Traçabilité applicative
- **Journal d'audit en ajout seul, garanti par PostgreSQL** : deux déclencheurs refusent
`UPDATE`, `DELETE` et `TRUNCATE` sur `audit_log`. Les champs de détail passent par une liste
blanche ; l'acteur est dénormalisé pour survivre à la suppression d'un compte (ADR 0004).
[Prouvé] par les tests d'intégration · en production, 5 créations de compte et 3 changements
de mot de passe [Constaté, `12`]
- **Tentatives de connexion** journalisées à part (`login_attempt`). [Prouvé]
- **Limite assumée** : les déclencheurs arrêtent l'accident, pas un compte qui détient
`ALTER TABLE`. Le journal n'est pas une preuve de non-répudiation.
### 6.2 Traçabilité de l'infrastructure
- **Provisionnement** : le state Terraform porte trois ressources appliquées (Docker, les trois
environnements, le runner). La quatrième, le coffre LUKS, n'a jamais été appliquée (`11`,
constat 3).
- **Déploiements** : chaque déploiement est un run de `deploy.yml` rattaché à un environnement
GitHub. Sept déploiements de production entre le 23/09 11h37 et le 24/09 11h12, le dernier sur
le commit gelé (`13`). [Prouvé]
- **Dépôt** : une seule administratrice, aucune branche protégée (constat 2).
### 6.3 Supervision
**[Prouvé]** dans le dépôt, active en production (profil Compose `monitoring`, ADR 0016) :
Prometheus lit `/metrics` avec son jeton, ainsi que la base, l'hôte, les conteneurs et Garage.
Neuf règles d'alerte sont testées par `promtool` en CI : API indisponible, erreurs serveur,
latence, base indisponible ou saturée, mémoire, disque et CPU de l'hôte, cible injoignable.
Alertmanager les envoie à Mailpit. Trois tableaux Grafana couvrent l'API, les données et
l'infrastructure. En production, les sept cibles sont `up` et les neuf règles chargées
[Constaté, `12`].
**Ce qui manque** : aucune règle sur des **événements de sécurité** (pic de 401, de 403 ou de
429) ; des alertes qui restent dans Mailpit et ne réveillent personne ; pas de Loki, donc aucune
centralisation des journaux ; aucune alerte sur l'échec d'un DAG.
---
## 7. Écarts avec le dossier EC01, et leur coût
| Prévu au dossier EC01 | Livré | Coût |
|---|---|---|
| Traefik en terminaison TLS | **Nginx** par stack (ADR 0007), plus un **frontal SNI** (ADR 0018) | configuration écrite à la main, mais explicite et validée en CI |
| Trivy, Bandit, gitleaks en CI | **Bandit bloquant en CI** ; Trivy, gitleaks et Checkov joués pour ce rapport | un secret commité demain ne serait vu qu'au prochain passage manuel |
| SOPS + age | secrets générés sur la machine, jamais transmis | secrets en clair sur disque, sans sauvegarde vérifiée |
| Ansible pour le durcissement | rien | pare-feu et SSH non durcis de façon reproductible |
| Deux réseaux Docker | un seul composant exposé | propriété portée par une absence, fragile à la relecture |
| Prometheus, Grafana, Loki | **Prometheus, Alertmanager, Grafana** actifs en production ; pas de Loki | journaux dispersés, aucune alerte de sécurité |
| Scan d'image de conteneur | aucun | les images construites sur la machine ne sont pas analysées |
| Chiffrement au repos | **SSE-C des archives** ; LUKS écrit mais impossible sur LXC (ADR 0020) | base en clair sur le disque du conteneur |
---
## 8. Plan d'action
### 8.1 Avant le gel du 25/09, 9h00
1. **Fabriquer le ZIP du dépôt depuis un clone**, `.git` inclus, et vérifier qu'il ne contient
ni `.env`, ni `*.pem`, ni `*.tfvars`, ni `*.tfstate`.
2. Activer, par l'administratrice : un relecteur requis sur l'environnement `prod`, et la
protection de `main`. Deux réglages, sans commit.
3. Arrêter k3s, qui redémarre en boucle, et restreindre les trois Vault à la boucle locale
(constat 1). Sans commit, sur décision de l'administratrice.
### 8.2 Après le gel, par ordre de valeur
1. Pare-feu hôte n'ouvrant que 22, 80 et 443, et SSH par clé seule, dans un script rejouable.
2. `pip-audit` sur les trois verrous, gitleaks et Trivy en CI ; montée de `providers-smtp`.
3. Validation du jeton de réinitialisation en `POST`, ou journal d'accès sans paramètres.
4. Chiffrement du disque par l'hôte Proxmox, ou une vraie machine virtuelle pour dérouler le
coffre LUKS déjà écrit.
5. Environnement `dev` sur une autre machine, ou limité aux branches relues.
6. Règles d'alerte de sécurité et Loki ; seuil bloquant sur le DAST.
7. Autorisation par site (API1) ; SOPS + age ; HEALTHCHECK du frontend.
---
## 9. Contribution personnelle
Attribution vérifiée par `git log` sur chaque fichier cité.
| Sujet | Auteur principal |
|---|---|
| Authentification, RBAC, journal d'audit, gardes de configuration, caviardage des journaux, ADR 0002 à 0004 | **Johan** |
| Matrice d'accès et protection des routes, et leurs tests | **Johan** |
| Reverse proxy Nginx et TLS, `docker-compose.prod.yml`, ADR 0007 | **Johan** |
| Trois environnements, `provision-host.sh`, `deploy.yml`, Terraform `vm-eni`, ADR 0009, 0010 et 0017 | **Johan** |
| CI unifiée (`ci.yml`), e2e et k6, supervision, ADR 0014 à 0016 | **Johan** |
| Certificats DNS-01 et frontal SNI, ADR 0018 | **Johan** |
| Garage par environnement, rétention, SSE-C, coffre LUKS, ADR 0019 et 0020 | **Johan**, sur une amorce de Valentin |
| Scan DAST OWASP ZAP (`dast.yml`, `dast-token.sh`) | Dorian ; ma part est la revue, trois points bloquants dont une fuite du jeton dans les artefacts |
| En-tête `Cross-Origin-Resource-Policy`, module Terraform k3s | Dorian |
| Workflow frontend, configuration SonarCloud, administration du dépôt | Inès |
| Ce rapport et les contrôles du 24/09 | **Johan** |
---
## 10. Usage de l'IA
| | |
|---|---|
| **Outil** | Claude Code (Anthropic), en assistant dans le terminal |
| **Tâches** | lancement des scans et mise en forme de leurs sorties, recoupement entre la documentation, le code et l'API GitHub, première rédaction de ce rapport |
| **Vérifications humaines** | chaque chiffre est lu dans une sortie de `preuves/`, rejouable par la commande en tête du fichier ; les huit constats gitleaks et les quatre alertes ZAP ont été triés ligne à ligne ; les constats sur la machine viennent de commandes lancées par l'auteur |
| **Limite constatée** | l'outil tend à présenter comme acquis ce qui n'est que prévu, et à conclure avant d'avoir vérifié (une première lecture de l'alerte ZAP citait des codes HTTP non relevés) : d'où les trois marqueurs et le tri ligne à ligne |
## 11. Licences
Outils de ce rapport : Bandit, pip-audit, Checkov, Trivy et OWASP ZAP sous licence Apache 2.0,
gitleaks sous licence MIT. Aucun n'est embarqué dans le produit. Composants ajoutés depuis le
dossier EC01 : Prometheus, Alertmanager, les exporteurs, cAdvisor, Playwright et boto3 sous Apache
2.0 ; acme.sh sous GPL 3.0 ; **Garage, Grafana et k6 sous AGPL 3.0**. Ils sont utilisés sans
modification, chacun dans son propre conteneur, ce qui n'emporte aucune obligation de publication.
L'ADR 0019 écarte pourtant MinIO en citant notamment sa licence AGPL, que Garage partage :
l'argument ne tient pas, les autres raisons de l'ADR restent. Le recensement complet est dans le
rapport collectif EC02.
---
## Annexe · Index des preuves
| Fichier | Contenu |
|---|---|
| `01-bandit.txt` | Bandit, seuil de la CI puis tous niveaux, trois modules |
| `02-pip-audit.txt` | pip-audit sur les trois verrous Python |
| `03-npm-audit.txt` | npm audit, frontend et tests e2e |
| `04-checkov.txt` | Checkov, quatre frameworks |
| `05-gitleaks.txt` | gitleaks sur tout l'historique, constats caviardés et tri |
| `06-trivy-config.txt` | Trivy config sur le commit |
| `07-trivy-fs.txt` | Trivy fs sur l'arbre de travail, clé retirée |
| `08-tests-acces.txt` | tests d'accès et d'authentification du backend |
| `09-terraform-validate.txt` | `terraform fmt` et `validate` sur les deux racines |
| `10-proxy-tls.txt` | `nginx -t` du proxy et du frontal, directives, ports |
| `11-terraform-state.txt` | ressources appliquées sur la machine |
| `12-constats-machine.txt` | relevés en lecture seule sur la machine |
| `13-github-reglages.txt` | environnements, protection des branches, secrets, déploiements |
| `14-dast-zap.txt` | dernier rapport OWASP ZAP et tri des alertes |
Chaque fichier donne en tête sa date, le commit analysé et la commande exacte pour le rejouer.
+142
View File
@@ -0,0 +1,142 @@
# Bandit 1.9.4 · 2026-09-24 14:45 CEST · commit 9f343e9 (dev = main)
## apps/backend/app · seuil CI (MEDIUM, confiance MEDIUM)
Run started:2026-09-24 12:45:08.896999+00:00
Test results:
No issues identified.
Code scanned:
Total lines of code: 6136
Total lines skipped (#nosec): 0
Total potential issues skipped due to specifically being disabled (e.g., #nosec BXXX): 0
Run metrics:
Total issues (by severity):
Undefined: 0
Low: 0
Medium: 0
High: 0
Total issues (by confidence):
Undefined: 0
Low: 0
Medium: 0
High: 0
Files skipped (0):
exit=0
## apps/backend/app · tous niveaux
Test results:
No issues identified.
Code scanned:
Total lines of code: 6136
Total lines skipped (#nosec): 0
Total potential issues skipped due to specifically being disabled (e.g., #nosec BXXX): 0
Run metrics:
Total issues (by severity):
Undefined: 0
Low: 0
Medium: 0
High: 0
Total issues (by confidence):
Undefined: 0
Low: 0
Medium: 0
High: 0
Files skipped (0):
## ml/enervision_ml · seuil CI (MEDIUM, confiance MEDIUM)
Run started:2026-09-24 12:45:09.551911+00:00
Test results:
No issues identified.
Code scanned:
Total lines of code: 722
Total lines skipped (#nosec): 0
Total potential issues skipped due to specifically being disabled (e.g., #nosec BXXX): 0
Run metrics:
Total issues (by severity):
Undefined: 0
Low: 0
Medium: 0
High: 0
Total issues (by confidence):
Undefined: 0
Low: 0
Medium: 0
High: 0
Files skipped (0):
exit=0
## ml/enervision_ml · tous niveaux
Test results:
No issues identified.
Code scanned:
Total lines of code: 722
Total lines skipped (#nosec): 0
Total potential issues skipped due to specifically being disabled (e.g., #nosec BXXX): 0
Run metrics:
Total issues (by severity):
Undefined: 0
Low: 0
Medium: 0
High: 0
Total issues (by confidence):
Undefined: 0
Low: 0
Medium: 0
High: 0
Files skipped (0):
## etl/airflow/dags · seuil CI (MEDIUM, confiance MEDIUM)
Run started:2026-09-24 12:45:09.903724+00:00
Test results:
No issues identified.
Code scanned:
Total lines of code: 252
Total lines skipped (#nosec): 0
Total potential issues skipped due to specifically being disabled (e.g., #nosec BXXX): 0
Run metrics:
Total issues (by severity):
Undefined: 0
Low: 0
Medium: 0
High: 0
Total issues (by confidence):
Undefined: 0
Low: 0
Medium: 0
High: 0
Files skipped (0):
exit=0
## etl/airflow/dags · tous niveaux
Test results:
No issues identified.
Code scanned:
Total lines of code: 252
Total lines skipped (#nosec): 0
Total potential issues skipped due to specifically being disabled (e.g., #nosec BXXX): 0
Run metrics:
Total issues (by severity):
Undefined: 0
Low: 0
Medium: 0
High: 0
Total issues (by confidence):
Undefined: 0
Low: 0
Medium: 0
High: 0
Files skipped (0):

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