From 6b3908d3216def7909fdf3af60cc6dd463f4c2ea Mon Sep 17 00:00:00 2001 From: ineszang <163989672+ineszang@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:34:33 +0200 Subject: [PATCH 1/3] Add files via upload --- .../0011-enervision-procedure-deploiement.md | 162 ++++++++++++++++++ ...-enervision-deploiement-rec-prod-vm-eni.md | 120 +++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 docs/adr/0011-enervision-procedure-deploiement.md create mode 100644 docs/adr/0012-enervision-deploiement-rec-prod-vm-eni.md diff --git a/docs/adr/0011-enervision-procedure-deploiement.md b/docs/adr/0011-enervision-procedure-deploiement.md new file mode 100644 index 0000000..fb74241 --- /dev/null +++ b/docs/adr/0011-enervision-procedure-deploiement.md @@ -0,0 +1,162 @@ +# EnerVision · procédure de déploiement (22/09/2026) + +Terraform provisionne la machine, GitHub Actions déploie (ADR 0010). Deux environnements Compose +sur la VM ENI `10.101.200.37` : `rec` sur la branche `dev`, `prod` sur `main` (ADR 0009). + +| | recette | production | +|---|---|---| +| Branche, environnement GitHub | `dev`, `rec` | `main`, `prod` | +| Dossier, projet Compose | `/srv/enervision/rec`, `enervision-rec` | `/srv/enervision/prod`, `enervision-prod` | +| URL | `https://rec.enervision.local:8443` | `https://enervision.local` | +| Proxy HTTP / HTTPS | `127.0.0.1:8081` / `8443` | `80` / `443` | +| Postgres / Mailpit / Airflow (locaux) | `5434` / `8026` / `8082` | `5433` / `8025` / `8080` | + +## 0. Avant toute commande + +1. **Clé SSH déposée** sur la VM : `ssh-copy-id -i ~/.ssh/id_ed25519.pub root@10.101.200.37`. + Terraform ne gère **pas** l'authentification par mot de passe (elle finirait dans le state). +2. **L'utilisateur propriétaire existe déjà** sur la VM (ex. `enervision`) : il possède + `/srv/enervision` et fait tourner le runner. Terraform échoue tôt s'il manque, il ne le crée pas. +3. **Jeton d'enregistrement du runner** : Settings > Actions > Runners > New self-hosted runner. + Valable 1 h, une seule inscription, créé par un administrateur du dépôt (ineszang). +4. **`main` est en retard de 64 commits** et ne porte ni `deploy.yml`, ni `provision-host.sh`, ni + le Terraform, ni l'overlay paramétré (ports et `PUBLIC_ORIGIN` en dur). Tant que `dev` n'est pas + remonté dans `main`, seule la recette est déployable : le clone `prod` sera préparé mais son + `make stack-up` publierait 80/443 sans les variables, et aucun push sur `main` ne déclencherait + de déploiement (le workflow n'y existe pas). **Remonter `dev` → `main` avant de toucher à prod.** + +## 1. Provisionner la machine (depuis le poste) + +```bash +cd infra/terraform/environments/vm-eni +cp terraform.tfvars.example terraform.tfvars +terraform init +terraform apply +``` + +`terraform.tfvars`, ignoré par git, trois valeurs à renseigner : + +```hcl +proprietaire = "enervision" # doit exister sur la VM +runner_version = "2.330.0" # épingler depuis github.com/actions/runner/releases +runner_token = "..." # jeton d'1 h, à retirer du fichier après l'apply +``` + +Défauts utiles : `ssh_host = "10.101.200.37"`, `ssh_user = "root"`, +`ssh_private_key_path = "~/.ssh/id_ed25519"`, `racine = "/srv/enervision"`, +`runner_labels = "eni-g3"` (ciblé par `deploy.yml`), `runner_dossier = "/opt/actions-runner"`. + +L'apply fait trois choses, dans cet ordre : Docker + plugin Compose et `usermod -aG docker`, +puis `scripts/provision-host.sh`, puis l'installation et l'enregistrement du runner en service. +Il ne construit aucune image et ne démarre aucun conteneur : un apply n'interrompt pas la stack. + +Rejouable : un clone existant est réaligné, un `.env` présent n'est **jamais** réécrit, un +certificat présent n'est jamais régénéré. Un nouvel apply de la ressource runner redemande un +jeton frais (il expire en 1 h). + +## 2. Variables d'environnement + +Un `.env` par dossier, en `600`, généré sur la machine depuis `.env.example`. **Aucun secret ne +passe par git ni par GitHub** : le runner n'en reçoit aucun (seul `SONAR_TOKEN` existe côté CI). + +**Générés automatiquement** : `POSTGRES_PASSWORD`, `APP_SECRET_KEY`, `AIRFLOW_FERNET_KEY`, +`AIRFLOW_API_SECRET_KEY`, `AIRFLOW_JWT_SECRET`, `AIRFLOW_ADMIN_PASSWORD`, `AIRFLOW_APP_SECRET_KEY`. + +**Fixés par environnement** : `COMPOSE_PROJECT_NAME`, `PUBLIC_HOST`, `PUBLIC_ORIGIN`, +`PROXY_HTTP_PORT`, `PROXY_HTTPS_PORT`, `POSTGRES_PORT`, `MAILPIT_UI_PORT`, `AIRFLOW_PORT`. + +**À renseigner à la main**, dans chaque `.env`, avant le premier démarrage : + +``` +APP_MOCK_API_USERNAME=... +APP_MOCK_API_PASSWORD=... +``` + +Garde-fou : le script refuse d'écrire un `.env` s'il reste un `change_me` hors `APP_MOCK_API_*` +(cas vécu d'une clé renommée en amont, `AIRFLOW_WEBSERVER_SECRET_KEY` sous Airflow 3). + +`APP_ENV=prod` et `APP_DEBUG=false` sont en dur dans l'overlay, pas dans le `.env` : la valeur +`local` du poste reprendrait le dessus et rouvrirait `/docs` sans cookie `__Secure-`. + +`TS_TUNE_MEMORY=2GB` et `TS_TUNE_NUM_CPUS=2` sont obligatoires : deux TimescaleDB sur 8 Go se +réserveraient 25 % de la RAM chacune. La montée à 32 Go est à demander. + +Certificats auto-signés générés par le script (`infra/proxy/tls/`), couvrant le nom d'hôte, +`localhost` et l'IP. Let's Encrypt (`make tls-acme`, `ACME_EMAIL`) reste hors d'atteinte sans +domaine public résolvable. + +## 3. Premier démarrage (manuel, une seule fois, sur la VM) + +```bash +cd /srv/enervision/rec && make stack-up # build + up + alembic upgrade head +cd /srv/enervision/prod && make stack-up # seulement après la remontée dev → main +``` + +`stack-up` refuse de démarrer si le certificat manque ou ne couvre pas `PUBLIC_HOST`, et applique +les migrations : sans elles la stack démarrerait verte sur une base sans schéma. + +Premier administrateur, stack démarrée, dans chaque dossier : + +```bash +docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend \ + python -m app.cli create-admin --email +``` + +Données historiques : `data/raw` n'est pas dans git. Déposer les fichiers dans chaque dossier +avant de déclencher le DAG `historical_import`. + +## 4. Réglages GitHub (administrateur du dépôt) + +- Environnement `prod` : branche `main` seule autorisée, **approbation d'un relecteur** requise. +- Environnement `rec` : branche `dev` seule autorisée, sans approbation. +- Settings > Actions : **« Require approval for all outside collaborators »**. Un runner + auto-hébergé sur un dépôt public exécute ce qu'on lui envoie ; `deploy.yml` ne se déclenche + jamais sur `pull_request`, et le runner ne tourne jamais en root. + +## 5. Déploiement continu, ensuite + +Un push sur `dev` déploie la recette, un push sur `main` la production après approbation. +Le job (runner `eni-g3`) aligne le clone (`fetch`, `checkout`, `reset --hard`), lance +`make stack-up`, puis sonde `/api/v1/health/ready` derrière le proxy pendant 3 minutes ; en cas +d'échec il publie `ps` et les 50 dernières lignes de `backend` et `proxy`. Pas de `checkout` dans +l'espace du runner : `.env`, certificats et volumes doivent survivre d'un déploiement à l'autre. +Concurrence par branche, sans annulation. + +Déclenchement manuel possible : `workflow_dispatch`. + +## 6. Vérifier + +```bash +curl -k https://localhost:8443/api/v1/health/ready # recette, sur la VM +curl -k https://localhost/api/v1/health/ready # production, sur la VM +``` + +Depuis un poste, ajouter à `/etc/hosts` : + +``` +10.101.200.37 enervision.local rec.enervision.local +``` + +Les deux noms sont obligatoires : le cookie `__Secure-ev_refresh` est posé par hôte et non par +port ; un seul nom déconnecterait la production à chaque connexion en recette. + +## Pièges à connaître + +- Compose **2.24.4 minimum** : l'overlay emploie `!override` et `!reset`, sans quoi l'API resterait + joignable en clair à côté du proxy. Le script le vérifie. +- Le runner doit tourner sous le propriétaire de `/srv/enervision` : sinon git refuse les clones + (propriété douteuse) et le `.env` en `600` lui échappe. Correctif : + `PROPRIETAIRE= 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`. diff --git a/docs/adr/0012-enervision-deploiement-rec-prod-vm-eni.md b/docs/adr/0012-enervision-deploiement-rec-prod-vm-eni.md new file mode 100644 index 0000000..8d01d39 --- /dev/null +++ b/docs/adr/0012-enervision-deploiement-rec-prod-vm-eni.md @@ -0,0 +1,120 @@ +# EnerVision · Recette et production sur la VM ENI, aujourd'hui + +État au lundi 21 septembre 2026, 15h. Cible : deux environnements qui tournent sur la VM +`eadl-2025-nantes-g3` (`10.101.200.37`) avant vendredi 25/09 9h, déployés automatiquement depuis +GitHub. Ce document donne la solution retenue, ce qu'elle change dans le dépôt, et le déroulé de +l'après-midi avec qui fait quoi. + +## 1. La décision en une phrase + +**Deux projets Docker Compose sur la même VM, un par environnement, déployés par un runner GitHub +Actions installé sur la VM.** `dev` alimente la recette, `main` alimente la production. Terraform +reste ce qu'il est : le module k3s, cible à terme, non utilisé pour cette mise en ligne. + +| | Recette (`rec`) | Production (`prod`) | +|---|---|---| +| Branche | `dev` | `main` | +| Environnement GitHub | `rec` (créé ce midi) | `prod` (créé ce midi) | +| Dossier sur la VM | `/srv/enervision/rec` | `/srv/enervision/prod` | +| Projet Compose | `enervision-rec` | `enervision-prod` | +| URL | `https://rec.enervision.local:8443` | `https://enervision.local` | +| Proxy HTTPS | `8443` | `443` | +| Proxy HTTP (redirection) | `127.0.0.1:8081`, inutilisé | `80` | +| PostgreSQL, Mailpit, Airflow | `127.0.0.1` : `5434`, `8026`, `8082` | `127.0.0.1` : `5433`, `8025`, `8080` | +| Certificat | auto-signé, SAN `rec.enervision.local` | auto-signé, SAN `enervision.local` | +| Déclenchement | chaque push sur `dev` | push sur `main`, après approbation dans GitHub | + +Les deux noms d'hôte pointent sur la même IP. Deux lignes dans le `/etc/hosts` des postes de +l'équipe suffisent. Deux noms distincts sont indispensables : le cookie de rafraîchissement +`__Secure-ev_refresh` est posé par hôte, pas par port, et un seul nom ferait se déconnecter la +prod à chaque connexion sur la recette. + +## 2. Pourquoi c'est la solution la plus simple + +- **Tout existe déjà.** L'overlay `docker-compose.prod.yml`, le proxy Nginx TLS, les scripts de + certificat et `make stack-up` sont écrits et validés sur poste (PR #117, ADR 0007). Il ne + manque que quatre variables pour que deux instances cohabitent sur une machine. +- **Un projet Compose isole tout.** Volumes, réseau, noms de conteneurs sont préfixés par le nom + du projet. Casser la recette ne touche pas la prod, ce qui est la raison d'être d'une recette. +- **Le runner sur la VM est la seule façon d'atteindre une IP privée d'école depuis GitHub.** Les + runners hébergés par GitHub ne voient pas `10.101.200.37`. Le runner se connecte en sortie + vers GitHub, aucun port entrant n'est nécessaire. C'était le choix 16 du dossier EC01 : il + redevient tenu. +- **La promotion existe déjà dans la stratégie de branches** : `dev` puis `main` par PR. Le + même code est déployé en recette, puis en production, sans troisième mécanisme. + +Ce qu'on écarte, et pourquoi : + +| Piste | Pourquoi pas cette semaine | +|---|---| +| k3s avec deux namespaces | Le cluster serait vide : aucun manifeste, aucun registre d'images, aucun stockage persistant. Trois jours de travail sans valeur visible au J10 | +| Terraform de `feat/deploy` (nginx système + copie de fichiers) | Revue postée sur l'issue #21 : huit points bloquants, `rec` et `prod` ne passent pas `terraform validate`. On abandonne cette voie | +| Azure ENI pour la prod | Deuxième infrastructure à provisionner, choix à justifier devant le jury (document 03), et rien n'est prêt côté Azure | +| Images publiées sur GHCR | Meilleure pratique, mais un registre de plus à authentifier sur la VM. Les images se construisent sur la VM, où le runner tourne déjà. À faire ensuite, issue à ouvrir | +| Let's Encrypt | Aucun domaine public ne résout vers la VM. Auto-signé assumé, chemin ACME déjà câblé | + +## 3. Ce qui change dans le dépôt (une PR vers `dev`) + +| Fichier | Changement | Raison | +|---|---|---| +| `apps/frontend/Dockerfile` | `FROM nginx:1.28-alpine` à la place de `dhi.io/nginx:...` | Le registre Docker Hardened Images demande une authentification. L'image frontend n'a jamais été construite, sur aucun poste : c'est le premier point où `make stack-up` échouerait sur la VM | +| `docker-compose.prod.yml` | Ports du proxy en variables `PROXY_HTTP_PORT` et `PROXY_HTTPS_PORT`. Origine publique `PUBLIC_ORIGIN` pour CORS et le lien de réinitialisation. `TS_TUNE_MEMORY` sur la base | Deux proxys ne peuvent pas publier 80 et 443. L'origine de la recette porte un port. Deux TimescaleDB sur 8 Go se réserveraient chacune 2 Go sans réglage | +| `.env.example` | `COMPOSE_PROJECT_NAME`, les variables ci-dessus, ports de la recette en commentaire | Le `.env` de chaque dossier est la seule différence entre les deux environnements | +| `.github/workflows/deploy.yml` | Nouveau. `on: push` sur `dev` et `main`, `runs-on: [self-hosted, eni-g3]`, `environment: rec` ou `prod`, puis `git reset --hard origin/` et `make stack-up` dans le dossier de l'environnement | Le D de CI/CD, issue #21 | +| `scripts/provision-host.sh` | Nouveau. Vérifie Docker et Compose 2.24.4 ou plus, crée `/srv/enervision/{rec,prod}`, clone les deux branches | Rejouable, et réutilisable par Terraform plus tard | +| `docs/adr/0009-...md`, `10-infra.md`, `50-cicd.md`, `infra/proxy/README.md` | Décision, vue infra, vue CI/CD, tableau des ports | Règle du dépôt : la vue change dans la même PR que le composant | + +Ce qui ne change pas : `docker-compose.yml`, la configuration Nginx, `infra/terraform`. + +## 4. Déroulé de l'après-midi + +| # | Qui | Quoi | Durée | +|---|---|---|---| +| 1 | **ineszang** (seule admin du dépôt) | Environnement `prod` : branche autorisée `main`, un relecteur requis. Environnement `rec` : branche `dev`. Settings > Actions : « Require approval for all outside collaborators ». Générer le jeton d'enregistrement du runner (Settings > Actions > Runners > New self-hosted runner, Linux x64) et le transmettre à Johan | 10 min | +| 2 | **Johan** | Déposer sa clé sur la VM : `ssh-copy-id -i ~/.ssh/id_ed25519.pub root@10.101.200.37`, mot de passe du compte administrateur local des postes de l'école | 2 min | +| 3 | Johan + Claude | **Fait à 15h** : branche locale `feat/deploy-rec-prod` avec tous les changements du §3, image frontend reconstruite avec succès, fusion Compose vérifiée pour les deux environnements. Reste : commit, push, PR vers `dev` | fait | +| 4 | Claude, par SSH | `scripts/provision-host.sh` sur la VM. Écrire les deux `.env` (secrets générés sur la VM, jamais dans git). Certificats : `PUBLIC_HOST=rec.enervision.local PUBLIC_IP=10.101.200.37 make tls-selfsigned` dans `rec`, idem avec `enervision.local` dans `prod`. Puis `make stack-up` dans chaque dossier | 20 min plus la construction des images | +| 5 | Johan, sur la VM | Installer le runner sous un utilisateur non-root membre du groupe `docker`, label `eni-g3`, en service systemd (`./config.sh --unattended --labels eni-g3`, `sudo ./svc.sh install && sudo ./svc.sh start`) | 10 min | +| 6 | Équipe | Merger la PR dans `dev` : la recette se redéploie seule. Ouvrir la PR `dev` vers `main` : la prod se déploie après approbation dans l'onglet Environments | 15 min | +| 7 | Tous | Vérifier depuis un poste de l'équipe, `/etc/hosts` renseigné : connexion, tableau de bord, Airflow par tunnel SSH | 15 min | + +Contrôle en fin de chaîne, depuis la VM : + +```bash +curl -k https://localhost/api/v1/health/ready # prod +curl -k https://localhost:8443/api/v1/health/ready # rec +docker compose -p enervision-prod ps +docker compose -p enervision-rec ps +``` + +## 5. Ce qui peut faire échouer la journée, et la parade + +| Risque | Parade | +|---|---| +| **8 Go de RAM pour deux stacks complètes** (deux Airflow, deux TimescaleDB, deux API) | Demander dès maintenant le passage à 32 Go, prévu par les consignes. En attendant : `TS_TUNE_MEMORY=2GB` et deux workers gunicorn pour Airflow. Si la RAM ne suit pas, démarrer la recette sans Airflow (`docker compose up -d --scale airflow-webserver=0 --scale airflow-scheduler=0`) | +| **Compose trop ancien sur la VM** (les marqueurs `!override` et `!reset` exigent 2.24.4) | `docker compose version` en premier. Sinon installer le paquet `docker-compose-plugin` depuis le dépôt Docker | +| **Pas de sortie Internet depuis la VM** | `curl -sI https://github.com` et `docker pull hello-world` avant tout. Sans sortie, ni construction d'image ni runner : déploiement manuel par `scp` d'images, plan B lourd | +| **Runner auto-hébergé sur un dépôt public** | Le workflow de déploiement ne s'exécute que sur `push` vers `dev` et `main`, jamais sur `pull_request`. Réglage d'approbation des PR externes (étape 1). Runner sous un utilisateur dédié, jamais root | +| **Premier démarrage avec un volume `pgdata` vide** | C'est le cas nominal sur la VM : `db/init` crée les bases `enervision`, `enervision_test` et `airflow`. Ne pas restaurer un volume de poste | +| **Le jury accepte mal un certificat auto-signé** | Dire pourquoi avant qu'on le demande : aucun DNS public, ACME câblé et documenté, ADR 0007. Un clic « continuer » dans le navigateur | +| **Conflit avec `feat/deploy`** (ineszang y a mergé `dev` à 14h06) | Partager ce document avant de pousser. La PR remplace `feat/deploy`, elle ne s'y ajoute pas | + +## 6. Ce que ça donne pour la grille + +- **EC03, CI/CD** : la chaîne ne s'arrête plus au merge. Deux environnements, déploiement + automatique en recette, promotion approuvée en production, journal des déploiements dans + l'onglet Environments de GitHub. +- **EC04, cloud et sécurisation** : une application déployée et fonctionnelle, une seule surface + exposée par environnement, secrets hors de git et hors de GitHub, base et Airflow joignables + uniquement par tunnel SSH. +- **Dossier EC01** : le choix 16 (runner auto-hébergé, déploiement automatique) passe de « non + fait » à « tenu ». Le choix 12 (Ansible) reste non fait, et la réponse est prête : le + durcissement de la machine n'est pas automatisé, le script de provisionnement en est la + première brique, Terraform pourra l'appeler. + +## 7. Après vendredi, si on continue + +Dans l'ordre de valeur : images construites une fois en CI et publiées sur GHCR, puis déployées +par digest (vraie promotion d'artefact). Racine Terraform `environments/eni-g3` qui provisionne +la machine et le runner à partir du script. Sauvegarde de `pgdata` par `pg_dump` planifié. +Monitoring (issue #26). Et seulement ensuite la bascule k3s, si elle garde un sens. From d86224a0f71798f4375a7fd4890aef2b63afa22a Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Tue, 22 Sep 2026 11:55:13 +0200 Subject: [PATCH 2/3] feat(airflow): orchestre l'import de la Mock API --- .github/workflows/airflow.yml | 7 ++-- README.md | 2 +- docker-compose.yml | 15 ++++++--- docs/architecture/00-vue-ensemble.md | 7 ++-- docs/architecture/10-infra.md | 8 ++++- etl/README.md | 10 ++++-- etl/airflow/dags/mock_api_import.py | 50 ++++++++++++++++++++++++++++ etl/airflow/tests/test_dags.py | 45 ++++++++++++++++++++++++- 8 files changed, 127 insertions(+), 17 deletions(-) create mode 100644 etl/airflow/dags/mock_api_import.py diff --git a/.github/workflows/airflow.yml b/.github/workflows/airflow.yml index 23b6af0..0d652d9 100644 --- a/.github/workflows/airflow.yml +++ b/.github/workflows/airflow.yml @@ -93,11 +93,12 @@ jobs: # `--help` sort par argparse avant `get_settings()` : ni base ni secret requis, et # l'import des modules prouve que l'environnement /opt/backend est complet. - # Les deux commandes du DAG `alertes` et la commande du DAG historique sont couvertes. - - name: Vérifie que les trois commandes backend s'importent sans réseau + # Les commandes des DAGs `alertes`, historique et API Mock sont couvertes. + - name: Vérifie que les quatre commandes backend s'importent sans réseau run: > docker run --rm --network none enervision-airflow:ci 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.cli generate-recommendations --help - && env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.historical_import --help" \ No newline at end of file + && env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.historical_import --help + && env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.mock_api_import --help" \ No newline at end of file diff --git a/README.md b/README.md index daa08f4..2d04a38 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans │ ├── migrations/ Migrations SQL versionnees │ └── seeds/ Jeux de donnees de reference ├── etl/airflow/ -│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, import historique) +│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, imports historique et API Mock) │ ├── plugins/ Operateurs et hooks maison │ ├── include/ Requetes SQL et ressources des DAGs │ └── tests/ Tests d'integrite des DAGs diff --git a/docker-compose.yml b/docker-compose.yml index ffa7238..5960dfa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,12 +34,19 @@ x-airflow-common: &airflow-common # memes identifiants que le backend en attendant. ML_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} MLFLOW_TRACKING_URI: sqlite:////opt/ml/state/mlflow.db - # Le DAG `alertes` lance le backend en sous-processus : il lit `DATABASE_URL`, en - # dialecte asyncpg, là où le pipeline ML lit `ML_DATABASE_URL`. + # Les DAGs backend lisent `DATABASE_URL` en dialecte asyncpg, là où le pipeline ML + # utilise `ML_DATABASE_URL`. 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:-} + + # Configuration utilisée par `app.etl.mock_api_import` dans le scheduler Airflow. + 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} volumes: - ./etl/airflow/dags:/opt/airflow/dags - ./etl/airflow/plugins:/opt/airflow/plugins diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 0aec09c..ea30741 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -70,11 +70,10 @@ Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API, intercepteur répond à sa place tant que les endpoints n'existent pas. Voir [30-frontend.md](30-frontend.md). -Le lien `airflow --> db` est maintenant en trait plein : quatre DAGs tournent, deux pour +Le lien `airflow --> db` est maintenant en trait plein : cinq DAGs tournent, deux pour l'entraînement et le scoring du modèle ML (issue #115), un pour la détection d'alertes et la -génération des recommandations (issue #116), et `historical_import` pour l'ingestion du dataset -historique (issue #119). L'orchestration de l'import API Mock et la réconciliation globale des -deux sources restent à compléter dans l'issue #15. +génération des recommandations (issue #116), `historical_import` pour le dataset historique +(issue #119) et `mock_api_import` pour l'ingestion horaire de l'API Mock (issue #15). Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun collecteur ne vient le lire. diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md index 204067b..f4485c8 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -54,7 +54,7 @@ Trois pièges sont documentés en tête du `docker-compose.yml`, ils ne se devin - `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais de l'api-server : c'est le scheduler qui a besoin du volume `airflow_ml_state` (modèle, magasin MLflow). -### Airflow (issues #115, #116 et #119) +### Airflow (issues #15, #115, #116 et #119) Quatre services (Airflow 3.3), `docker compose profiles` non utilisés (démarrage explicite via `make airflow-up`, pas dans `make dev`) : @@ -87,12 +87,18 @@ l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md). | `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` | | `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` | `0 * * * *` | `app.etl.mock_api_import`, dans `/opt/backend/.venv` ; importe l'intervalle horaire Airflow précédent depuis l'API Mock | 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 `./data/raw:/opt/data/raw:ro` permet au scheduler de lire les fichiers CSV/JSON sans pouvoir les modifier. +Le DAG `mock_api_import` exécute le pipeline API Mock toutes les heures. Il transmet +`data_interval_start` et `data_interval_end` au script backend et charge les mesures dans les +mêmes tables `site` et `reading` que le pipeline historique. `max_active_runs=1` empêche deux +intervalles de s'exécuter simultanément. + **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 finir. Aucune dépendance n'est déclarée entre les deux DAGs pour autant, ni `ExternalTaskSensor` ni diff --git a/etl/README.md b/etl/README.md index d789d6b..f952283 100644 --- a/etl/README.md +++ b/etl/README.md @@ -670,9 +670,13 @@ recommandations (`alertes`, issue #116), ainsi que l'import historique Le DAG `historical_import` est déclenché manuellement. Il exécute `app.etl.historical_import` avec les fichiers montés en lecture seule depuis `data/raw` vers -`/opt/data/raw`. L'orchestration de l'import API Mock et la réconciliation globale des deux -sources restent couvertes par l'issue #15. +`/opt/data/raw`. -Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py` et `alertes.py` et `historical_import.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui). +Le DAG `mock_api_import` exécute `app.etl.mock_api_import` toutes les heures. Chaque exécution +traite l'intervalle Airflow précédent. Les deux pipelines normalisent leurs données vers les +tables communes `site` et `reading`, tout en conservant leur source (`csv` ou `api_history`). + +Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py`, `alertes.py`, `historical_import.py` et +`mock_api_import.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui). Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning. diff --git a/etl/airflow/dags/mock_api_import.py b/etl/airflow/dags/mock_api_import.py new file mode 100644 index 0000000..07973e2 --- /dev/null +++ b/etl/airflow/dags/mock_api_import.py @@ -0,0 +1,50 @@ +"""DAG d'import périodique des données de l'API Mock EnerVision (issue #15). + +Orchestre le pipeline existant `app.etl.mock_api_import` sans dupliquer sa logique ETL. +Chaque exécution traite l'intervalle horaire Airflow précédent. + +Le pipeline backend reste responsable de la validation, de la normalisation, du suivi de la +qualité, de l'idempotence et du chargement dans PostgreSQL/TimescaleDB. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from airflow.providers.standard.operators.bash import BashOperator +from airflow.sdk import DAG + +# Le backend possède son propre environnement uv dans l'image Airflow (ADR 0008). +COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m" + +# L'API et le pipeline backend plafonnent une réponse à 1 000 lectures par site. +LIMITE_LECTURES = 60 + +# Deux reprises donnent trois tentatives au total. Même dans le pire cas, l'exécution reste +# inférieure au pas horaire du DAG. +NOMBRE_REPRISES = 2 +DELAI_ENTRE_REPRISES = timedelta(minutes=2) +PLAFOND_PAR_TENTATIVE = timedelta(minutes=10) + +with DAG( + dag_id="mock_api_import", + description="Importe chaque heure les données de l'API Mock dans site et reading.", + schedule="@hourly", + start_date=datetime(2026, 1, 1), + catchup=False, + # Deux exécutions simultanées pourraient demander et traiter le même intervalle. + max_active_runs=1, + tags=["etl", "mock-api"], +) as dag: + BashOperator( + task_id="import_mock_api", + bash_command=( + f"{COMMANDE_BACKEND} app.etl.mock_api_import " + '--start-time "{{ data_interval_start.isoformat() }}" ' + '--end-time "{{ data_interval_end.isoformat() }}" ' + f"--limit {LIMITE_LECTURES}" + ), + retries=NOMBRE_REPRISES, + retry_delay=DELAI_ENTRE_REPRISES, + execution_timeout=PLAFOND_PAR_TENTATIVE, + ) diff --git a/etl/airflow/tests/test_dags.py b/etl/airflow/tests/test_dags.py index 60978b5..282b7d5 100644 --- a/etl/airflow/tests/test_dags.py +++ b/etl/airflow/tests/test_dags.py @@ -10,13 +10,20 @@ from airflow.sdk import BaseOperator DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags" -DAG_IDS = ["ml_train", "ml_score", "alertes", "historical_import"] +DAG_IDS = [ + "ml_train", + "ml_score", + "alertes", + "historical_import", + "mock_api_import", +] TACHES = [ ("ml_train", "train"), ("ml_score", "score"), ("alertes", "detection"), ("alertes", "recommandations"), ("historical_import", "import_historical"), + ("mock_api_import", "import_mock_api"), ] @@ -52,6 +59,10 @@ def test_historical_import_has_no_schedule(dagbag: DagBag) -> None: assert dagbag.dags["historical_import"].schedule is None +def test_mock_api_import_runs_every_hour(dagbag: DagBag) -> None: + assert dagbag.dags["mock_api_import"].timetable.expression == "0 * * * *" + + def test_ml_train_task_calls_the_training_module(dagbag: DagBag) -> None: tache = dagbag.dags["ml_train"].get_task("train") assert "enervision_ml.train" in tache.bash_command @@ -84,6 +95,20 @@ def test_historical_import_uses_the_expected_source_files(dagbag: DagBag) -> Non assert "--metadata /opt/data/raw/dataset_metadata.json" in commande +def test_mock_api_import_calls_the_existing_backend_module(dagbag: DagBag) -> None: + commande = dagbag.dags["mock_api_import"].get_task("import_mock_api").bash_command + + assert "app.etl.mock_api_import" in commande + + +def test_mock_api_import_uses_the_airflow_data_interval(dagbag: DagBag) -> None: + commande = dagbag.dags["mock_api_import"].get_task("import_mock_api").bash_command + + assert '--start-time "{{ data_interval_start.isoformat() }}"' in commande + assert '--end-time "{{ data_interval_end.isoformat() }}"' in commande + assert "--limit 60" in commande + + @pytest.mark.parametrize("task_id", ["detection", "recommandations"]) def test_alertes_tasks_run_in_the_backend_environment(dagbag: DagBag, task_id: str) -> None: # Le backend a son propre venv dans l'image, distinct de celui de ml/ (ADR 0008). @@ -95,6 +120,12 @@ def test_historical_import_runs_in_the_backend_environment(dagbag: DagBag) -> No assert "/opt/backend" in commande +def test_mock_api_import_runs_in_the_backend_environment(dagbag: DagBag) -> None: + commande = dagbag.dags["mock_api_import"].get_task("import_mock_api").bash_command + + assert "/opt/backend" in commande + + def test_alertes_generates_recommendations_after_detecting(dagbag: DagBag) -> None: # `recommendation.alert_id` est une cle etrangere `NOT NULL` : la generation n'a rien a lire # tant que la detection n'a pas ecrit. @@ -136,6 +167,14 @@ def duree_au_pire(tache: BaseOperator) -> timedelta: return (tache.retries + 1) * tache.execution_timeout + tache.retries * tache.retry_delay +def test_mock_api_import_worst_case_stays_below_its_hourly_step( + dagbag: DagBag, +) -> None: + tache = dagbag.dags["mock_api_import"].get_task("import_mock_api") + + assert duree_au_pire(tache) < timedelta(hours=1) + + def test_alertes_worst_case_stays_below_its_hourly_step(dagbag: DagBag) -> None: # Les deux taches s'enchainent : c'est leur somme, reprises comprises, qui doit tenir dans le # pas horaire, sinon `max_active_runs=1` fait attendre l'execution suivante. @@ -159,6 +198,10 @@ def test_historical_import_retries_after_a_transient_failure(dagbag: DagBag) -> assert dagbag.dags["historical_import"].get_task("import_historical").retries >= 1 +def test_mock_api_import_retries_after_a_transient_failure(dagbag: DagBag) -> None: + assert dagbag.dags["mock_api_import"].get_task("import_mock_api").retries >= 1 + + @pytest.mark.parametrize(("dag_id", "task_id"), TACHES) def test_tasks_never_resync_the_baked_environment( dagbag: DagBag, dag_id: str, task_id: str From e118c008bf653b521dec9561cf001c9655ecf410 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Tue, 22 Sep 2026 16:24:05 +0200 Subject: [PATCH 3/3] fix(airflow): fiabilise l'import horaire de la Mock API --- README.md | 2 +- apps/backend/app/etl/mock_api_import.py | 14 +++++--- .../backend/tests/etl/test_mock_api_import.py | 35 +++++++++++++++++++ docker-compose.yml | 13 ++++--- docs/architecture/00-vue-ensemble.md | 12 ++++--- docs/architecture/10-infra.md | 16 ++++++--- etl/README.md | 16 +++++---- etl/airflow/dags/mock_api_import.py | 23 ++++++++---- etl/airflow/tests/test_dags.py | 22 ++++++++---- 9 files changed, 113 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 2d04a38..236d8f6 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m | Backend | FastAPI, Python 3.14 | `apps/backend` | En place | | Frontend | Angular 22, Node 26 | `apps/frontend` | En place | | Base | PostgreSQL 17 + TimescaleDB | `db` | En place | -| ETL | Apache Airflow | `etl/airflow` | Quatre DAGs | +| ETL | Apache Airflow | `etl/airflow` | Cinq DAGs | | Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | | Reverse proxy | Nginx, TLS | `infra/proxy` | En place | | CI/CD | GitHub Actions | `.github/workflows` | En place | diff --git a/apps/backend/app/etl/mock_api_import.py b/apps/backend/app/etl/mock_api_import.py index 0d5d6be..65fcea5 100644 --- a/apps/backend/app/etl/mock_api_import.py +++ b/apps/backend/app/etl/mock_api_import.py @@ -45,15 +45,19 @@ CAPACITY_BOUNDS = (0.0, 100_000.0) def create_mock_api_client() -> httpx.AsyncClient: 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.") return httpx.AsyncClient( base_url=settings.mock_api_base_url.rstrip("/"), - auth=( - settings.mock_api_username, - settings.mock_api_password.get_secret_value(), - ), + auth=(username, password), timeout=settings.mock_api_timeout_seconds, ) diff --git a/apps/backend/tests/etl/test_mock_api_import.py b/apps/backend/tests/etl/test_mock_api_import.py index cdcff55..fa654bc 100644 --- a/apps/backend/tests/etl/test_mock_api_import.py +++ b/apps/backend/tests/etl/test_mock_api_import.py @@ -283,6 +283,41 @@ def test_create_mock_api_client_requires_credentials( 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( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/docker-compose.yml b/docker-compose.yml index 5960dfa..b6d9f5d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,11 +42,6 @@ x-airflow-common: &airflow-common # vérifient aucun jeton. Airflow permet d'exécuter du code depuis son interface (ADR 0008). APP_SECRET_KEY: ${AIRFLOW_APP_SECRET_KEY:-} - # Configuration utilisée par `app.etl.mock_api_import` dans le scheduler Airflow. - 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} volumes: - ./etl/airflow/dags:/opt/airflow/dags - ./etl/airflow/plugins:/opt/airflow/plugins @@ -174,6 +169,14 @@ services: airflow-scheduler: <<: *airflow-common command: scheduler + environment: + <<: *airflow-common-env + # LocalExecutor exécute les tâches dans le scheduler : lui seul a besoin des + # identifiants de l'API Mock. + APP_MOCK_API_BASE_URL: ${APP_MOCK_API_BASE_URL:-https://api-mock.charlieandre.fr} + APP_MOCK_API_USERNAME: ${APP_MOCK_API_USERNAME:-} + APP_MOCK_API_PASSWORD: ${APP_MOCK_API_PASSWORD:-} + APP_MOCK_API_TIMEOUT_SECONDS: ${APP_MOCK_API_TIMEOUT_SECONDS:-10} depends_on: db: condition: service_healthy diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index ea30741..ad49c47 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -74,6 +74,7 @@ Le lien `airflow --> db` est maintenant en trait plein : cinq DAGs tournent, deu 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), `historical_import` pour le dataset historique (issue #119) et `mock_api_import` pour l'ingestion horaire de l'API Mock (issue #15). +La réconciliation globale des données provenant des deux sources reste à compléter dans l'issue #15. Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun collecteur ne vient le lire. @@ -88,15 +89,16 @@ collecteur ne vient le lire. | 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 | | Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Provisionnement de la VM par Terraform, qui installe Docker, prépare les deux environnements et enregistre le runner, jamais appliqué ([ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API | -| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Quatre DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes` et `historical_import`. Le DAG historique orchestre `app.etl.historical_import` et charge `dataset`, `site` et `reading`. L'orchestration API Mock reste à compléter dans #15 | +| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver et scheduler avec LocalExecutor via Docker Compose, sur une base PostgreSQL dédiée. Cinq DAGs sont présents : `ml_train`, `ml_score`, `alertes`, `historical_import` et `mock_api_import`. L'import historique reste manuel et l'import API Mock est exécuté chaque heure. La réconciliation globale des deux sources reste à compléter dans l'issue #15. | | CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 7 workflows, 19 jobs : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow, formatage et validation du Terraform. Déploiement continu vers la VM ENI écrit par `deploy.yml`, `dev` en recette et `main` en production après approbation ([ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)), mais jamais exécuté : la machine n'est pas provisionnée et le runner n'y est pas enregistré. Détail dans [50-cicd.md](50-cicd.md) | ## Flux bout en bout -Statut : `En cours`. **Le chemin de lecture tourne** : base, API et frontend. **Le chemin -d'ingestion dessiné ci-dessous n'existe pas** : les trois DAGs livrés (`ml_train`, `ml_score`, -issue #115 ; `alertes`, issue #116) orchestrent le pipeline ML et la détection d'alertes, pas -l'ingestion, qui reste lancée à la main par les scripts d'import (issues #15 et #16). +Statut : `En cours`. **Le chemin de lecture tourne** entre la base, l'API et le frontend. +**Le chemin d'ingestion est maintenant orchestré par Airflow** : `historical_import` charge +manuellement le dataset CSV/JSON et `mock_api_import` collecte périodiquement les mesures de +l'API Mock. La réconciliation globale des données provenant des deux sources reste à compléter +dans l'issue #15. ```mermaid sequenceDiagram diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md index f4485c8..ff7fa0f 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -87,17 +87,23 @@ l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md). | `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` | | `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` | `0 * * * *` | `app.etl.mock_api_import`, dans `/opt/backend/.venv` ; importe l'intervalle horaire Airflow précédent depuis l'API Mock | +| `mock_api_import` | `45 * * * *` | `app.etl.mock_api_import`, dans `/opt/backend/.venv` ; importe l'heure précédant son déclenchement depuis l'API Mock | 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 `./data/raw:/opt/data/raw:ro` permet au scheduler de lire les fichiers CSV/JSON sans pouvoir les modifier. -Le DAG `mock_api_import` exécute le pipeline API Mock toutes les heures. Il transmet -`data_interval_start` et `data_interval_end` au script backend et charge les mesures dans les -mêmes tables `site` et `reading` que le pipeline historique. `max_active_runs=1` empêche deux -intervalles de s'exécuter simultanément. +Le DAG `mock_api_import` exécute le pipeline API Mock toutes les heures, à la minute `:45`. +Un `CronTriggerTimetable` explicite lui attribue un intervalle d'une heure, y compris lors d'un +déclenchement manuel. Il transmet cet intervalle au script backend et charge les mesures dans +les tables communes `site` et `reading`. Le décalage à `:45` laisse quinze minutes avant le +scoring exécuté à l'heure pile, puis quinze minutes supplémentaires avant les alertes à `:15`. +`max_active_runs=1` empêche deux exécutions du DAG de se chevaucher. + +Le DAG conserve `catchup=False` pour éviter un rattrapage massif depuis sa date de démarrage. +Une interruption du scheduler peut donc créer un intervalle manquant, qui devra être rejoué +explicitement par une opération de backfill. **Pourquoi `alertes` tourne à la quinzième minute.** Sa règle `anomaly` compare une lecture à la `prediction` du même instant, que `ml_score` écrit à l'heure pile. Le décalage laisse le scoring diff --git a/etl/README.md b/etl/README.md index f952283..94f8ba2 100644 --- a/etl/README.md +++ b/etl/README.md @@ -663,14 +663,16 @@ mock_api_import.py La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP. -Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`) et orchestre le pipeline -ML (`ml_train`/`ml_score`, issue #115), la détection d'alertes et la génération des -recommandations (`alertes`, issue #116), ainsi que l'import historique -(`historical_import`, issue #119). +Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`) et orchestre cinq DAGs : +le pipeline ML (`ml_train` et `ml_score`, issue #115), la détection d'alertes et la génération +des recommandations (`alertes`, issue #116), l'import historique (`historical_import`, +issue #119) et l'import périodique de l'API Mock (`mock_api_import`, issue #15). -Le DAG `historical_import` est déclenché manuellement. Il exécute -`app.etl.historical_import` avec les fichiers montés en lecture seule depuis `data/raw` vers -`/opt/data/raw`. +Le DAG `mock_api_import` s'exécute chaque heure, à la minute `:45`. Il appelle +`app.etl.mock_api_import` avec un intervalle explicite d'une heure et une limite de 1 000 lectures +par site. Les deux pipelines normalisent leurs données vers les tables communes `site` et +`reading`, tout en conservant leur source (`csv` ou `api_history`). La réconciliation globale +des deux sources reste à compléter dans l'issue #15. Le DAG `mock_api_import` exécute `app.etl.mock_api_import` toutes les heures. Chaque exécution traite l'intervalle Airflow précédent. Les deux pipelines normalisent leurs données vers les diff --git a/etl/airflow/dags/mock_api_import.py b/etl/airflow/dags/mock_api_import.py index 07973e2..f0043d0 100644 --- a/etl/airflow/dags/mock_api_import.py +++ b/etl/airflow/dags/mock_api_import.py @@ -1,7 +1,7 @@ """DAG d'import périodique des données de l'API Mock EnerVision (issue #15). Orchestre le pipeline existant `app.etl.mock_api_import` sans dupliquer sa logique ETL. -Chaque exécution traite l'intervalle horaire Airflow précédent. +Chaque exécution traite l'heure précédant son déclenchement. Le pipeline backend reste responsable de la validation, de la normalisation, du suivi de la qualité, de l'idempotence et du chargement dans PostgreSQL/TimescaleDB. @@ -13,12 +13,14 @@ from datetime import datetime, timedelta from airflow.providers.standard.operators.bash import BashOperator from airflow.sdk import DAG +from airflow.timetables.trigger import CronTriggerTimetable # Le backend possède son propre environnement uv dans l'image Airflow (ADR 0008). COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m" -# L'API et le pipeline backend plafonnent une réponse à 1 000 lectures par site. -LIMITE_LECTURES = 60 +# Le pipeline backend et l'API acceptent au maximum 1 000 lectures par site. +# Cette marge évite de perdre silencieusement une lecture si une heure en contient plus de 60. +LIMITE_LECTURES = 1000 # Deux reprises donnent trois tentatives au total. Même dans le pire cas, l'exécution reste # inférieure au pas horaire du DAG. @@ -26,10 +28,19 @@ NOMBRE_REPRISES = 2 DELAI_ENTRE_REPRISES = timedelta(minutes=2) PLAFOND_PAR_TENTATIVE = timedelta(minutes=10) +# L'intervalle est déclaré explicitement pour ne pas dépendre de la valeur du paramètre Airflow +# `create_cron_data_intervals`. Le déclenchement à :45 laisse quinze minutes avant `ml_score`, +# exécuté à l'heure pile, puis avant `alertes`, exécuté à :15. +PLANIFICATION = CronTriggerTimetable( + "45 * * * *", + timezone="UTC", + interval=timedelta(hours=1), +) + with DAG( dag_id="mock_api_import", description="Importe chaque heure les données de l'API Mock dans site et reading.", - schedule="@hourly", + schedule=PLANIFICATION, start_date=datetime(2026, 1, 1), catchup=False, # Deux exécutions simultanées pourraient demander et traiter le même intervalle. @@ -40,8 +51,8 @@ with DAG( task_id="import_mock_api", bash_command=( f"{COMMANDE_BACKEND} app.etl.mock_api_import " - '--start-time "{{ data_interval_start.isoformat() }}" ' - '--end-time "{{ data_interval_end.isoformat() }}" ' + "--start-time \"{{ data_interval_start.strftime('%Y-%m-%dT%H:%M:%S') }}\" " + "--end-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:%M:%S') }}\" " f"--limit {LIMITE_LECTURES}" ), retries=NOMBRE_REPRISES, diff --git a/etl/airflow/tests/test_dags.py b/etl/airflow/tests/test_dags.py index 282b7d5..572b057 100644 --- a/etl/airflow/tests/test_dags.py +++ b/etl/airflow/tests/test_dags.py @@ -1,12 +1,13 @@ """Tests d'integrite des DAGs : s'importent sans erreur, structure attendue. Pas d'execution reelle des taches (ca reclamerait le conteneur avec `uv`/`enervision_ml`), juste la definition.""" -from datetime import timedelta +from datetime import datetime, timedelta from pathlib import Path import pytest from airflow.dag_processing.dagbag import DagBag from airflow.sdk import BaseOperator +from airflow.timetables.trigger import CronTriggerTimetable DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags" @@ -59,8 +60,17 @@ def test_historical_import_has_no_schedule(dagbag: DagBag) -> None: assert dagbag.dags["historical_import"].schedule is None -def test_mock_api_import_runs_every_hour(dagbag: DagBag) -> None: - assert dagbag.dags["mock_api_import"].timetable.expression == "0 * * * *" +def test_mock_api_import_uses_an_explicit_hourly_interval(dagbag: DagBag) -> None: + timetable = dagbag.dags["mock_api_import"].timetable + + assert isinstance(timetable, CronTriggerTimetable) + assert timetable.serialize()["expression"] == "45 * * * *" + + manual_interval = timetable.infer_manual_data_interval( + run_after=datetime.fromisoformat("2026-09-22T12:30:00+00:00"), + ) + + assert manual_interval.end - manual_interval.start == timedelta(hours=1) def test_ml_train_task_calls_the_training_module(dagbag: DagBag) -> None: @@ -104,9 +114,9 @@ def test_mock_api_import_calls_the_existing_backend_module(dagbag: DagBag) -> No def test_mock_api_import_uses_the_airflow_data_interval(dagbag: DagBag) -> None: commande = dagbag.dags["mock_api_import"].get_task("import_mock_api").bash_command - assert '--start-time "{{ data_interval_start.isoformat() }}"' in commande - assert '--end-time "{{ data_interval_end.isoformat() }}"' in commande - assert "--limit 60" in commande + assert "--start-time \"{{ data_interval_start.strftime('%Y-%m-%dT%H:%M:%S') }}\"" in commande + assert "--end-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:%M:%S') }}\"" in commande + assert "--limit 1000" in commande @pytest.mark.parametrize("task_id", ["detection", "recommandations"])