docs: acte l'ordonnancement des alertes par l'ADR 0008 et met à jour les vues

L'ADR 0008 décide qu'Airflow exécute le code du backend en sous-processus plutôt
que d'appeler l'API, et assume ce que cela coûte : une image plus lourde, la CI
Airflow déclenchée par les changements du backend, une clé applicative de plus.

Les vues suivent. Trois DAGs dans 10-infra.md et dans la vue d'ensemble, avec le
motif du décalage horaire. La détection n'est plus « lancée à la main » dans
20-backend.md. La génération des recommandations gagne son troisième déclencheur
dans 40-data.md. La dette de cantonnement ETL et ML porte l'aggravation comme
l'atténuation. L'affirmation selon laquelle `etl/airflow/` ne contient que des
`.gitkeep`, fausse depuis l'issue #115, disparaît.

L'index des décisions omettait les ADR 0005 et 0006, il les récupère au passage.
This commit is contained in:
Johan LEROY
2026-09-21 12:14:06 +02:00
parent ae58a896d9
commit 2686880185
10 changed files with 153 additions and 32 deletions
@@ -0,0 +1,79 @@
# 0008 - Airflow exécute le code du backend en sous-processus
- Statut : accepté
- Date : 2026-09-21
## Contexte
L'issue #116 demande un DAG d'alertes. Ce qu'il a à ordonnancer existe déjà et n'est pas à
réécrire : `AlertService.detect()` et ses cinq règles (#104), puis le moteur de recommandations
(#38). Les deux vivent dans `apps/backend/app/`, et
l'[ADR 0006](0006-moteur-de-regles-dans-le-backend.md) a précisément décidé qu'ils y restent parce
qu'ils s'appuient sur les repositories ORM de l'API plutôt que sur du SQL brut. Les deux
sont décrits par la documentation comme « lancés à la main ».
L'image Airflow livrée par #115 ne porte que `ml/`, dans un environnement `uv` distinct
(`/opt/ml/.venv`, Python 3.14) de celui d'Airflow lui-même (Python 3.12, contraint par
apache-airflow 2.10). Les DAGs `ml_train` et `ml_score` shellent vers cet environnement. Rien
d'équivalent n'existe pour `apps/backend` : un `BashOperator` sur
`python -m app.detection.internal_alerts` échouerait en `ModuleNotFoundError`.
## Décision
**L'image Airflow porte un troisième environnement, `/opt/backend/.venv`**, construit depuis le
`pyproject.toml`, le `uv.lock` et le paquet `app/` du backend. Le DAG `alertes` shelle vers lui
exactement comme `ml_score` shelle vers `/opt/ml/.venv`.
Trois raisons :
- **Le patron existe et vient d'être revu.** #115 a posé `BashOperator` + `uv run --no-sync` +
`env -u VIRTUAL_ENV`, avec les tests d'intégrité qui le verrouillent. Introduire une seconde
forme d'appel dans le même dossier `dags/` coûterait plus cher à lire qu'un second environnement
dans le même `Dockerfile`.
- **Aucune surface réseau n'est ajoutée.** La détection n'a pas de route HTTP, contrairement à la
génération de recommandations (`POST /recommendations/generate`, rôle `admin`). En créer une pour
qu'Airflow l'appelle donnerait à l'ordonnanceur un compte administrateur de l'API, en plus des
identifiants PostgreSQL complets qu'il détient déjà, et ferait dépendre la production d'alertes
de la disponibilité du conteneur `backend`.
- **La logique reste où l'ADR 0006 l'a mise.** Le DAG n'apprend rien du domaine : ni les seuils, ni
les cinq règles, ni les clés d'idempotence. Il ne sait que l'heure à laquelle appeler.
## Conséquences
- **Airflow reçoit une `APP_SECRET_KEY` délibérément distincte de celle de l'API.** La
configuration du backend refuse de se construire sans elle (`app/core/config.py`), et
`internal_alerts.main()` appelle `get_settings()` avant toute requête pour échouer tôt. Mais la
détection ne signe ni ne vérifie aucun jeton, et Airflow permet d'exécuter du code arbitraire
depuis son interface : un Airflow compromis ne doit pas livrer la clé de signature des JWT. D'où
`AIRFLOW_APP_SECRET_KEY`, avec sa propre garde dans `airflow-init`.
- **`DATABASE_URL`, en dialecte asyncpg, rejoint `ML_DATABASE_URL`** dans l'environnement du
conteneur. Le cantonnement des rôles PostgreSQL reste la dette de
l'[ADR 0003](0003-autorisation-rbac-a-trois-roles.md), et cette décision l'alourdit d'un
consommateur de plus.
- **La CI Airflow se déclenche sur les changements du backend.** L'image le `COPY` : sans
`apps/backend/app/**`, `pyproject.toml` et `uv.lock` dans les déclencheurs du workflow, une
dépendance modifiée casserait la construction sans que rien ne le signale avant le déploiement.
En contrepartie, l'image grossit de ce que pèsent SQLAlchemy, asyncpg et pandas.
- **Aucune variable ne départage les deux environnements, et c'est voulu.** `uv` place par défaut
le venv d'un projet dans `<projet>/.venv` : `cd /opt/ml` ou `cd /opt/backend` suffit à choisir le
bon. L'image ne pose donc plus de `UV_PROJECT_ENVIRONMENT` global, hérité de #115 : il vaudrait
pour les deux projets, et `uv run` dans l'un résoudrait le venv de l'autre. Le symptôme n'est pas
une construction ratée mais un `ModuleNotFoundError` à la première tâche, d'où la vérification
d'import sans réseau que la CI fait maintenant sur chacun des deux.
- Airflow lui-même reste étranger au domaine : ni LightGBM, ni SQLAlchemy, ni FastAPI n'entrent
dans son interpréteur. C'est la propriété que #115 avait établie, et elle tient toujours.
## Alternatives écartées
- **Route HTTP `POST /alerts/detect` réservée `admin`, appelée par le DAG.** L'image ne bougeait
pas, mais Airflow détenait alors un compte administrateur de l'API, la détection devenait
tributaire du conteneur `backend`, et l'API gagnait une route d'écriture dont aucun client
humain n'a l'usage. À rouvrir si un jour un tiers doit déclencher la détection.
- **`DockerOperator` lançant l'image du backend.** Demande la socket Docker de l'hôte dans le
conteneur Airflow, c'est-à-dire un équivalent root sur la machine, pour un service qui permet
déjà d'exécuter du code depuis son interface. Le provider n'est d'ailleurs pas installé.
- **Réécrire les cinq règles en SQL dans le DAG.** Contredit frontalement l'ADR 0006, duplique le
domaine, et fait diverger les deux copies au premier changement de seuil.
- **Monter `apps/backend` en volume plutôt que le copier.** L'environnement ne serait plus figé à
la construction, `uv` resynchroniserait au premier lancement, et la CI ne prouverait plus rien
de ce qui tourne réellement.