Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6301ffedd7 | ||
|
|
59f050ec5e | ||
|
|
6e9c830557 | ||
|
|
5a3c526856 | ||
|
|
314e3b72c0 | ||
|
|
e118c008bf | ||
|
|
d86224a0f7 | ||
|
|
f21a843fc2 | ||
|
|
6b3908d321 | ||
|
|
da5d2d838a | ||
|
|
b16861e211 | ||
|
|
57b9735804 | ||
|
|
c007ea01bd | ||
|
|
5472b19504 | ||
|
|
44163bfb98 | ||
|
|
ea8f9d0a3a | ||
|
|
f58fc4ba81 | ||
|
|
2c7ee2c064 | ||
|
|
e220f8f0c6 | ||
|
|
d9103ee4ed | ||
|
|
3ddeb24207 | ||
|
|
8760ebc701 | ||
|
|
a013dfa87f | ||
|
|
6d741e45fb | ||
|
|
1bec2c1376 | ||
|
|
00fab49d80 | ||
|
|
de88c4f156 | ||
|
|
67dcf506e9 | ||
|
|
8386e05f31 | ||
|
|
9cd4f0de1c | ||
|
|
5cc99178c2 | ||
|
|
b41a16364b | ||
|
|
33aeea835b | ||
|
|
9312d3b60f | ||
|
|
2a7e1c25fc | ||
|
|
5d955ca991 | ||
|
|
268496a8c4 | ||
|
|
cefb07067d | ||
|
|
3cc6c67e0d | ||
|
|
93afc6031c | ||
|
|
99db807e3c | ||
|
|
47c4fd445b | ||
|
|
667592033b | ||
|
|
6785c70f6f | ||
|
|
692ec436a5 | ||
|
|
a3d32a6fb1 |
@@ -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"
|
||||
&& 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"
|
||||
@@ -0,0 +1,325 @@
|
||||
name: DAST
|
||||
|
||||
# Scan dynamique OWASP ZAP de l'API (issue #41). Il attaque une API qui tourne : le job démarre
|
||||
# la base et le backend sur le runner, sème un site et quelques relevés (sans ça le scan ne
|
||||
# frappe que des gestionnaires d'erreur), crée un compte `lecteur` jetable
|
||||
# (scripts/dast-token.sh), puis lance ZAP sur le contrat OpenAPI avec le jeton de ce compte.
|
||||
#
|
||||
# Non bloquant pour l'instant sur les alertes (`continue-on-error` sur la seule étape du scan) :
|
||||
# le volume d'un premier passage trié est inconnu. Deux étapes suivantes, elles, bloquent si le
|
||||
# scan n'a rien testé (import du contrat, absence de toute réponse de succès) : un job vert doit
|
||||
# vouloir dire qu'un scan a eu lieu.
|
||||
#
|
||||
# Piège : ce scan tape la configuration par défaut du backend (`APP_ENV=local`, pas de TLS, pas
|
||||
# de reverse proxy). Il ne dit rien des en-têtes ni du TLS posés par le proxy en production, et
|
||||
# remontera des alertes (HSTS absent...) qui n'existent pas derrière lui.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Un scan actif est long : hebdomadaire plutôt qu'à chaque PR.
|
||||
- cron: "0 3 * * 1"
|
||||
pull_request:
|
||||
# Ne se lance sur une PR que si le scan lui-même change.
|
||||
paths:
|
||||
- ".github/workflows/dast.yml"
|
||||
- "scripts/dast-token.sh"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: dast-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
zap:
|
||||
name: Scan OWASP ZAP de l'API
|
||||
runs-on: ubuntu-latest
|
||||
# Généreux face aux ~2 minutes observées de bout en bout : le vrai plafond est
|
||||
# `scanner.maxScanDurationInMins` (étape Scan ZAP), sous le TTL du jeton. Une annulation par
|
||||
# ce timeout-ci n'exécute pas les étapes `always()` : mieux vaut ne jamais l'atteindre.
|
||||
timeout-minutes: 30
|
||||
|
||||
# Même image que docker-compose.yml : la première migration refuse de s'appliquer sans
|
||||
# l'extension TimescaleDB (cf. backend.yml).
|
||||
services:
|
||||
db:
|
||||
image: timescale/timescaledb-ha:pg17
|
||||
env:
|
||||
POSTGRES_USER: enervision
|
||||
POSTGRES_PASSWORD: change_me
|
||||
POSTGRES_DB: enervision_dast
|
||||
ports:
|
||||
- "5433:5432"
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U enervision -d enervision_dast"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
--health-start-period 40s
|
||||
|
||||
env:
|
||||
# Base jetable : ZAP y écrira et le script y crée deux comptes.
|
||||
DATABASE_URL: postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_dast
|
||||
APP_SECRET_KEY: secret-de-scan-assez-long-pour-le-validateur
|
||||
APP_ENV: local
|
||||
# Le jeton du lecteur doit survivre à toute la durée du scan (15 minutes par défaut).
|
||||
# 3600 est le plafond accepté par la configuration ; `scanner.maxScanDurationInMins`
|
||||
# (étape Scan ZAP) reste très en dessous, marge comprise pour les étapes qui l'entourent.
|
||||
APP_ACCESS_TOKEN_TTL_SECONDS: "3600"
|
||||
PGPASSWORD: change_me
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Installe uv
|
||||
# Épinglé sur le commit du tag v7 (règle Sonar githubactions:S7637 : dépendance tierce,
|
||||
# contrairement à actions/checkout ou actions/upload-artifact, premières parties).
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: apps/backend/uv.lock
|
||||
# `prune-cache` vaut `true` par défaut (encore sur ce commit) : l'étape de post-job
|
||||
# « Pruning cache » est restée bloquée 5 minutes avant d'échouer (exit code 2) sur un
|
||||
# run où les 16 étapes précédentes passaient, sans lien avec le scan. Le prune n'est
|
||||
# qu'une optimisation de taille de cache entre deux runs, pas une garantie : le
|
||||
# désactiver retire le blocage sans rien changer au comportement du job.
|
||||
prune-cache: false
|
||||
|
||||
- name: Installe l'interpréteur déclaré par .python-version
|
||||
run: uv python install
|
||||
working-directory: apps/backend
|
||||
|
||||
# `--no-build` : aucune dépendance n'est construite depuis ses sources, donc aucun script de
|
||||
# build exécuté (règle Sonar S8541). Le projet lui-même n'est pas installé : il tourne depuis
|
||||
# `apps/backend`, comme dans son Dockerfile. Les `uv run` suivants portent `--frozen
|
||||
# --no-sync` pour ne rien résoudre ni reconstruire (règle S8544).
|
||||
- name: Synchronise les dépendances sans dévier du verrou
|
||||
run: uv sync --frozen --no-dev --no-install-project --no-build
|
||||
working-directory: apps/backend
|
||||
|
||||
- name: Active TimescaleDB sur la base du scan
|
||||
run: psql -h localhost -p 5433 -U enervision -d enervision_dast -c "CREATE EXTENSION IF NOT EXISTS timescaledb"
|
||||
|
||||
- name: Applique les migrations
|
||||
run: uv run --frozen --no-sync --no-build alembic upgrade head
|
||||
working-directory: apps/backend
|
||||
|
||||
# Sans données, `GET /sites` rend `[]`, chaque `/{site_id}` rend 404 et le scan actif ne
|
||||
# frappe que des gestionnaires d'erreur plutôt que la logique métier. `db/seeds/` est vide
|
||||
# (pas encore d'outillage de jeu de données pour la CI) : un site et deux relevés à la main,
|
||||
# juste assez pour que les routes de lecture aient quelque chose à rendre.
|
||||
- name: Insère un site et des relevés minimaux pour le scan
|
||||
run: |
|
||||
psql -h localhost -p 5433 -U enervision -d enervision_dast <<'SQL'
|
||||
INSERT INTO site (site_id, site_name, site_type, location, capacity_kw, status)
|
||||
VALUES ('dast-site', 'Site du scan DAST', 'bureau', 'CI', 50, 'actif')
|
||||
ON CONFLICT (site_id) DO NOTHING;
|
||||
|
||||
INSERT INTO reading (site_id, timestamp, source, consumption_kw, consumption_kwh, is_working_hours, data_quality, raw_data)
|
||||
VALUES
|
||||
('dast-site', now() - interval '2 hours', 'api_current', 12.5, 12.5, true, 'good', '{}'),
|
||||
('dast-site', now() - interval '1 hour', 'api_current', 13.0, 13.0, true, 'good', '{}')
|
||||
ON CONFLICT DO NOTHING;
|
||||
SQL
|
||||
|
||||
- name: Démarre l'API
|
||||
run: |
|
||||
nohup uv run --frozen --no-sync --no-build uvicorn app.main:create_app --factory \
|
||||
--host 0.0.0.0 --port 8000 > "$RUNNER_TEMP/api.log" 2>&1 &
|
||||
for _ in $(seq 1 30); do
|
||||
curl -fsS http://localhost:8000/api/v1/health/ready >/dev/null 2>&1 && exit 0
|
||||
sleep 2
|
||||
done
|
||||
echo "L'API ne répond pas sur /health/ready" >&2
|
||||
cat "$RUNNER_TEMP/api.log" >&2
|
||||
exit 1
|
||||
working-directory: apps/backend
|
||||
|
||||
- name: Crée le compte lecteur du scan
|
||||
id: jeton
|
||||
run: |
|
||||
jeton="$(../../scripts/dast-token.sh)"
|
||||
echo "::add-mask::$jeton"
|
||||
echo "jeton=$jeton" >> "$GITHUB_OUTPUT"
|
||||
working-directory: apps/backend
|
||||
|
||||
# Étape distincte du scan lui-même, et sans `continue-on-error` : un `curl` qui échoue ici
|
||||
# (API tombée juste après la sonde de readiness, par exemple) doit rester un échec visible,
|
||||
# pas se travestir en « ZAP n'a importé aucune URL » à l'étape de garde suivante.
|
||||
- name: Prépare le contrat pour ZAP
|
||||
run: |
|
||||
mkdir -p zap-out zap-logs
|
||||
curl -fsS http://localhost:8000/openapi.json -o zap-out/openapi.json
|
||||
# Le dossier passe à l'uid 1000 (utilisateur du conteneur ZAP) : le runner n'y écrit
|
||||
# plus après ce chown, d'où `zap-logs/` (uid du runner) pour les journaux ci-dessous.
|
||||
# Pas de `chmod 777` (règle Sonar S2612).
|
||||
sudo chown -R 1000:1000 zap-out
|
||||
|
||||
# `--network host` : ZAP atteint l'API sur le localhost du runner.
|
||||
#
|
||||
# Piège vécu : la clé du nom d'en-tête est `matchstr`, pas `matchstring`. ZAP accepte
|
||||
# n'importe quelle clé `-config` sans erreur ; avec la mauvaise, il ajoutait à TOUTES les
|
||||
# requêtes un en-tête au nom vide (`: Bearer <jeton>`), qu'uvicorn refuse par un 400
|
||||
# (« Invalid HTTP request received »), y compris sur les routes publiques.
|
||||
#
|
||||
# Le jeton ne passe ni par `${{ }}` dans ce script (il finirait en clair dans le fichier de
|
||||
# commande que GitHub écrit sur le disque du runner pour toute la durée de l'étape), ni par
|
||||
# l'argv de `docker run` (visible par `ps aux` et par `docker inspect zap` tant que le
|
||||
# conteneur existe) : il est écrit dans un fichier de configuration ZAP séparé, monté en
|
||||
# lecture seule hors de `/zap/wrk` pour ne jamais atterrir dans l'artefact publié.
|
||||
#
|
||||
# Les routes d'authentification qui changent l'état du compte du scan sont exclues : un
|
||||
# scan actif y déclencherait la limitation de débit du login, la réinitialisation de mots de
|
||||
# passe et la fermeture des sessions, sans rien apprendre de plus.
|
||||
#
|
||||
# `scanner.maxScanDurationInMins`/`maxRuleDurationInMins` bornent le scan actif, que `-T` ne
|
||||
# couvre pas (il ne borne que le démarrage et le scan passif) : sans ça, une règle qui
|
||||
# traîne peut dépasser le TTL du jeton (401 muets en fin de scan) ou le timeout du job (qui
|
||||
# annule sans exécuter les étapes `always()`, rapport et journaux perdus).
|
||||
- name: Scan ZAP
|
||||
id: zap
|
||||
continue-on-error: true
|
||||
env:
|
||||
JETON: ${{ steps.jeton.outputs.jeton }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
printf 'replacer.full_list(0).description=auth\nreplacer.full_list(0).enabled=true\nreplacer.full_list(0).matchtype=REQ_HEADER\nreplacer.full_list(0).matchstr=Authorization\nreplacer.full_list(0).regex=false\nreplacer.full_list(0).replacement=Bearer %s\n' "$JETON" > "$RUNNER_TEMP/zap-auth.conf"
|
||||
# Piège vécu : `chmod 600` seul rend le fichier illisible pour le conteneur, qui lit un
|
||||
# montage bind avec son propre uid (1000), distinct de celui du runner qui l'a écrit.
|
||||
# ZAP échoue alors dès le lancement (« File not readable: /zap/auth.conf »), et
|
||||
# `zap-api-scan.py` attend `-T` minutes complètes avant d'abandonner : dix minutes qui
|
||||
# ressemblent à un scan actif, pour un daemon mort depuis le début.
|
||||
#
|
||||
# Piège vécu (numéro deux) : une fois le fichier passé à l'uid 1000 par `sudo chown`,
|
||||
# l'utilisateur du runner n'en est plus propriétaire et un `chmod` sans `sudo` échoue
|
||||
# (« Operation not permitted »). Avec le `-e` implicite de bash sur les étapes GitHub
|
||||
# Actions, cette erreur arrêtait toute l'étape avant même `docker run` : scan « réussi »
|
||||
# en une fraction de seconde, sans le moindre journal ni rapport produit.
|
||||
sudo chown 1000:1000 "$RUNNER_TEMP/zap-auth.conf"
|
||||
sudo chmod 644 "$RUNNER_TEMP/zap-auth.conf"
|
||||
docker run --name zap --network host \
|
||||
-v "$PWD/zap-out:/zap/wrk:rw" \
|
||||
-v "$RUNNER_TEMP/zap-auth.conf:/zap/auth.conf:ro" \
|
||||
ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py \
|
||||
-t /zap/wrk/openapi.json -f openapi -O http://localhost:8000 \
|
||||
-T 10 \
|
||||
-r zap-report.html -J zap-report.json -w zap-report.md \
|
||||
-z "-configfile /zap/auth.conf \
|
||||
-config globalexcludeurl.url_list.url(0).description=auth-etat \
|
||||
-config globalexcludeurl.url_list.url(0).enabled=true \
|
||||
-config globalexcludeurl.url_list.url(0).regex='.*/api/v1/auth/(login|password|logout-all|forgot-password|reset-password).*' \
|
||||
-config scanner.maxScanDurationInMins=15 \
|
||||
-config scanner.maxRuleDurationInMins=5" \
|
||||
2>&1 | tee "$RUNNER_TEMP/zap-stdout.log"
|
||||
|
||||
- name: Récupère les journaux de ZAP
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p zap-logs
|
||||
# ZAP journalise la valeur de chaque `-config`/`-configfile` chargé, y compris le jeton,
|
||||
# à un niveau visible sans `-d` : les copies publiées en artefact sont donc caviardées,
|
||||
# même si `::add-mask::` (posé à la création du jeton) protège déjà le journal du job.
|
||||
masque() { sed -E 's/(Bearer )[A-Za-z0-9._-]+/\1[MASQUE]/Ig'; }
|
||||
[ -f "$RUNNER_TEMP/zap-stdout.log" ] && masque < "$RUNNER_TEMP/zap-stdout.log" > zap-logs/zap-stdout.log
|
||||
docker cp zap:/home/zap/.ZAP/zap.log "$RUNNER_TEMP/zap-internal.log" 2>/dev/null || true
|
||||
[ -f "$RUNNER_TEMP/zap-internal.log" ] && masque < "$RUNNER_TEMP/zap-internal.log" > zap-logs/zap.log
|
||||
[ -f "$RUNNER_TEMP/api.log" ] && masque < "$RUNNER_TEMP/api.log" > zap-logs/api.log
|
||||
rm -f "$RUNNER_TEMP/zap-auth.conf"
|
||||
docker rm -f zap >/dev/null 2>&1 || true
|
||||
|
||||
# `continue-on-error` sur le scan ne doit pas faire passer pour vert un scan qui n'a rien
|
||||
# testé. Constaté une première fois : 2 URL importées sur 26 opérations, ZAP n'avait envoyé
|
||||
# que des requêtes vouées au 404. Le seuil est dérivé du contrat plutôt que d'un nombre fixe
|
||||
# : un contrat qui grossit ne doit pas rendre la garde plus permissive qu'elle ne l'était.
|
||||
- name: Vérifie que le contrat a bien été importé
|
||||
run: |
|
||||
attendu="$(python3 -c "
|
||||
import json
|
||||
d = json.load(open('zap-out/openapi.json'))
|
||||
methodes = ('get', 'post', 'put', 'patch', 'delete', 'head', 'options')
|
||||
print(sum(1 for chemin in d['paths'].values() for m in chemin if m in methodes))
|
||||
")"
|
||||
minimum=$((attendu * 80 / 100))
|
||||
importees="$(sed -n 's/.*Number of Imported URLs: \([0-9]*\).*/\1/p' "$RUNNER_TEMP/zap-stdout.log" | tail -1)"
|
||||
echo "URL importées depuis le contrat OpenAPI : ${importees:-aucune} (contrat : $attendu opérations, minimum accepté : $minimum)"
|
||||
if [ "${importees:-0}" -lt "$minimum" ]; then
|
||||
echo "::error::ZAP n'a importé que ${importees:-0} URL sur $attendu opérations du contrat OpenAPI (minimum attendu : $minimum, soit 80%). Le scan n'a pas testé l'API, voir zap-logs/zap.log dans l'artefact zap-report."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Deuxième garde-fou : le contrat peut être importé et ZAP n'obtenir que des erreurs
|
||||
# (constaté : base sans données, toutes les routes de site répondaient 404).
|
||||
#
|
||||
# Piège de conception, trouvé en répétant ce job en local avant de l'écrire ici : borner le
|
||||
# pourcentage de 4xx ne marche pas. Un scan actif fuzze délibérément un grand nombre
|
||||
# d'entrées invalides (identifiants inventés, méthodes non supportées...), donc même un scan
|
||||
# sain, contre l'API seedée juste au-dessus, reste à 98% de 4xx avec seulement 1% de 2xx :
|
||||
# c'est la forme normale d'un scan actif, pas un signe d'échec. Le signal qui distingue
|
||||
# vraiment un scan cassé (0% de 2xx, `insight.code.2xx` absent du rapport dans le premier
|
||||
# incident) d'un scan sain (2xx non nul, aussi faible soit-il) est donc l'absence de succès,
|
||||
# pas la part d'échecs. Dérivé de `zap-report.json` (champ structuré `insights[]`) plutôt
|
||||
# que du texte libre du rapport Markdown, qui aurait le même défaut de conception en plus
|
||||
# d'être fragile au format.
|
||||
- name: Vérifie que le scan a obtenu au moins une réponse de succès
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
rapport = json.load(open("zap-out/zap-report.json"))
|
||||
except FileNotFoundError:
|
||||
print("::error::Aucun rapport ZAP produit : le scan n'a rien testé.")
|
||||
sys.exit(1)
|
||||
|
||||
pourcentage_2xx = 0.0
|
||||
for insight in rapport.get("insights", []):
|
||||
if insight.get("key") == "insight.code.2xx":
|
||||
pourcentage_2xx = float(insight.get("statistic", 0))
|
||||
break
|
||||
|
||||
print(f"Pourcentage de réponses 2xx : {pourcentage_2xx}%")
|
||||
if pourcentage_2xx <= 0:
|
||||
print(
|
||||
"::error::Aucune réponse 2xx (succès) reçue : le scan n'a atteint aucune route "
|
||||
"réelle de l'API. Voir zap-logs/api.log et zap-logs/zap.log dans l'artefact "
|
||||
"zap-report."
|
||||
)
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
# Uniquement la synthèse (jusqu'à « Alert Detail » exclu) : `$GITHUB_STEP_SUMMARY` est
|
||||
# limité à 1 Mio, et cette étape tourne sous `always()` - son échec ferait échouer le job
|
||||
# après le passage des deux garde-fous, pour une simple raison de mise en forme. Le rapport
|
||||
# complet reste dans l'artefact `zap-report`.
|
||||
- name: Publie le résumé
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f zap-out/zap-report.md ]; then
|
||||
awk '/^## Alert Detail/{exit} {print}' zap-out/zap-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Rapport complet (HTML/JSON/Markdown) dans l'artefact \`zap-report\`." >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "Aucun rapport ZAP produit, voir le journal du job." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Publie les rapports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: zap-report
|
||||
path: |
|
||||
zap-out/
|
||||
zap-logs/
|
||||
if-no-files-found: warn
|
||||
|
||||
# Diagnostic de dernier recours : les journaux de l'API sont déjà dans l'artefact
|
||||
# (zap-logs/api.log) via l'étape « Récupère les journaux de ZAP » (always()), mais les
|
||||
# afficher directement dans le journal du job évite d'avoir à le télécharger pour un échec
|
||||
# évident (l'API n'a jamais démarré, par exemple).
|
||||
- name: Journal de l'API en cas d'échec
|
||||
if: failure() || steps.zap.outcome == 'failure'
|
||||
run: cat "$RUNNER_TEMP/api.log" || true
|
||||
+2
-1
@@ -63,7 +63,8 @@ ml/models/*
|
||||
!ml/models/.gitkeep
|
||||
ml/mlruns/
|
||||
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)
|
||||
etl/airflow/tests/.airflow_home/
|
||||
|
||||
@@ -20,6 +20,8 @@ PG_USER := $(or $(strip $(call env-val,POSTGRES_USER)),enervision)
|
||||
PG_PASSWORD := $(or $(strip $(call env-val,POSTGRES_PASSWORD)),change_me)
|
||||
PG_DB := $(or $(strip $(call env-val,POSTGRES_DB)),enervision)
|
||||
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)
|
||||
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)
|
||||
@@ -43,7 +45,7 @@ DEMO_NOW ?= 2024-12-31T00:00:00Z
|
||||
test-chaine check \
|
||||
openapi docker-build db-up db-down db-reset db-logs db-psql db-wait db-ensure-airflow \
|
||||
migrate migrate-test bootstrap-admin services-up demo-data demo-data-force \
|
||||
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 \
|
||||
tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs
|
||||
|
||||
@@ -136,6 +138,14 @@ ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DAT
|
||||
ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV= et NOW= optionnels
|
||||
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
|
||||
cd $(BACKEND) && uv run python -m app.detection.internal_alerts $(if $(SITE),--site-id $(SITE),) $(if $(NOW),--now $(NOW),)
|
||||
|
||||
|
||||
@@ -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, dérive)
|
||||
│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, imports, dérive)
|
||||
│ ├── plugins/ Operateurs et hooks maison
|
||||
│ ├── include/ Requetes SQL et ressources des DAGs
|
||||
│ └── tests/ Tests d'integrite des DAGs
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM docker
|
||||
COPY --from=docker/buildx-bin /buildx /usr/libexec/docker/cli-plugins/docker-buildx
|
||||
RUN docker buildx version
|
||||
|
||||
FROM python:3.14-slim AS builder
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /uvx /bin/
|
||||
@@ -8,10 +13,8 @@ ENV UV_COMPILE_BYTECODE=1 \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --locked --no-install-project --no-dev --no-build
|
||||
COPY uv.lock pyproject.toml /app/
|
||||
RUN uv sync --locked --no-install-project --no-dev
|
||||
|
||||
# Le projet lui-meme n'est pas installe (pas de second `uv sync`) : il tourne depuis /app, le
|
||||
# repertoire de travail, et rien ne lit ses metadonnees. L'installer imposerait de le construire
|
||||
@@ -19,6 +22,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
# l'installation des dependances n'execute aucun script de build (regle Sonar docker:S8541).
|
||||
COPY . /app
|
||||
|
||||
RUN uv sync --locked --no-dev
|
||||
|
||||
FROM python:3.14-slim AS runtime
|
||||
|
||||
|
||||
@@ -50,7 +50,9 @@ SettingsDep = Annotated[Settings, Depends(get_settings)]
|
||||
|
||||
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)]
|
||||
|
||||
|
||||
|
||||
@@ -102,7 +102,9 @@ TAGS: Final[list[dict[str, Any]]] = [
|
||||
|
||||
cookie_de_rafraichissement = APIKeyCookie(
|
||||
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=(
|
||||
"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 "
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -65,6 +65,15 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
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",
|
||||
@@ -78,6 +87,7 @@ def seuils_depuis(args: argparse.Namespace) -> Seuils:
|
||||
fenetre=timedelta(hours=args.window_hours),
|
||||
grace=timedelta(hours=args.grace_hours),
|
||||
min_observations=args.min_observations,
|
||||
seuil_biais=args.bias_threshold,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ class Seuils:
|
||||
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
|
||||
|
||||
|
||||
+23
-23
@@ -213,7 +213,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Cookie de rafraîchissement": []
|
||||
"CookieRafraichissement": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -252,7 +252,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Cookie de rafraîchissement": []
|
||||
"CookieRafraichissement": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -301,7 +301,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -347,7 +347,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -423,7 +423,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -673,7 +673,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -757,7 +757,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -771,7 +771,7 @@
|
||||
"operationId": "update_user_api_v1_users__user_id__patch",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -889,7 +889,7 @@
|
||||
"operationId": "reset_password_api_v1_users__user_id__password_reset_post",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1023,7 +1023,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1037,7 +1037,7 @@
|
||||
"operationId": "get_site_api_v1_sites__site_id__get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1124,7 +1124,7 @@
|
||||
"operationId": "get_current_api_v1_sites__site_id__current_get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1211,7 +1211,7 @@
|
||||
"operationId": "list_alerts_api_v1_alerts_get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1361,7 +1361,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1375,7 +1375,7 @@
|
||||
"operationId": "get_recommendation_api_v1_recommendations__recommendation_id__get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1462,7 +1462,7 @@
|
||||
"operationId": "generate_recommendations_api_v1_recommendations_generate_post",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1588,7 +1588,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1602,7 +1602,7 @@
|
||||
"operationId": "list_readings_api_v1_readings_get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1799,7 +1799,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1855,7 +1855,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1869,7 +1869,7 @@
|
||||
"operationId": "get_drift_api_v1_monitoring_drift_get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -3487,13 +3487,13 @@
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
"Cookie de rafraîchissement": {
|
||||
"CookieRafraichissement": {
|
||||
"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`.",
|
||||
"in": "cookie",
|
||||
"name": "ev_refresh"
|
||||
},
|
||||
"Jeton d'accès": {
|
||||
"JetonAcces": {
|
||||
"type": "http",
|
||||
"scheme": "bearer"
|
||||
}
|
||||
|
||||
@@ -227,24 +227,26 @@ async def test_a_real_token_reaches_exactly_the_routes_of_its_rank(
|
||||
assert ecarts == []
|
||||
|
||||
|
||||
# Contrainte : `operateur` n'ouvre aujourd'hui aucune route de plus que `lecteur`, faute d'écriture
|
||||
# métier dans l'API. Figer l'égalité rend la régression visible le jour où une route d'opérateur
|
||||
# arrive sans que `ROLE_MINIMUM` soit mis à jour.
|
||||
# Contrainte : les deux rangs ne se séparent que sur les routes que `ROLE_MINIMUM` réserve à
|
||||
# `operateur`. Une route d'opérateur ajoutée sans être classée fait diverger les statuts sans
|
||||
# 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
|
||||
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
|
||||
) -> None:
|
||||
lecteur = await authentifie(client, comptes_par_role[Role.LECTEUR])
|
||||
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_operateur = await appelle(client, methode, chemin, headers=operateur)
|
||||
if cote_lecteur.status_code != cote_operateur.status_code:
|
||||
divergences.append((methode, chemin))
|
||||
diverge = cote_lecteur.status_code != cote_operateur.status_code
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
schemes = schema["components"]["securitySchemes"]
|
||||
|
||||
assert schemes["Cookie de rafraîchissement"]["in"] == "cookie"
|
||||
assert schemes["Cookie de rafraîchissement"]["name"] == "ev_refresh"
|
||||
assert schemes["CookieRafraichissement"]["in"] == "cookie"
|
||||
assert schemes["CookieRafraichissement"]["name"] == "ev_refresh"
|
||||
|
||||
|
||||
def test_each_tag_used_by_a_route_is_described(schema: dict[str, Any]) -> None:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)),
|
||||
source=overrides.get("source", "api_current"),
|
||||
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"),
|
||||
raw_data=overrides.get("raw_data", {}),
|
||||
)
|
||||
|
||||
@@ -218,3 +218,54 @@ async def test_drift_compares_the_recent_window_to_the_reference_one(
|
||||
rapports = await service(depot, min_observations=10, mae_plancher=1.0).evaluate(now=INSTANT)
|
||||
|
||||
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 "")
|
||||
|
||||
@@ -106,3 +106,11 @@ def test_main_exits_zero_when_drift_is_detected_without_the_flag(
|
||||
|
||||
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
|
||||
|
||||
@@ -26,20 +26,28 @@ RUN npm run build
|
||||
FROM nginx:1.31-alpine AS runner
|
||||
|
||||
# Copie de la configuration de nginx
|
||||
COPY --chown=root:root --chmod=755 nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --chown=root:root nginx.conf /etc/nginx/nginx.conf
|
||||
RUN chmod 755 /etc/nginx/nginx.conf
|
||||
|
||||
# Copy the static build output from the build stage to Nginx's default HTML serving directory
|
||||
COPY --chown=root:root --chmod=755 --from=builder /app/dist/*/browser /usr/share/nginx/html
|
||||
COPY --chown=root:root --from=builder /app/dist/frontend/browser /usr/share/nginx/html
|
||||
RUN chmod 755 /usr/share/nginx/html
|
||||
|
||||
|
||||
# Create necessary directories with proper permissions for nginx
|
||||
RUN mkdir -p /var/log/nginx /var/cache/nginx && \
|
||||
chown -R nginx:nginx /var/log/nginx /var/cache/nginx /usr/share/nginx/html
|
||||
chown -R nginx:nginx /var/log/nginx /var/cache/nginx /usr/share/nginx/html &&\
|
||||
chmod 755 /var/log/nginx /var/log
|
||||
|
||||
# Permissions appropriées
|
||||
RUN find /usr/share/nginx/html -type f -exec chmod 644 {} \; && \
|
||||
find /usr/share/nginx/html -type d -exec chmod 755 {} \; && \
|
||||
chown -R nginx:nginx /var/log/nginx /var/cache/nginx
|
||||
|
||||
# Use a non-root user for security best practices
|
||||
USER nginx
|
||||
|
||||
# Frontend : port 3000
|
||||
# Backend : port 8000
|
||||
EXPOSE 3000
|
||||
|
||||
# Start Nginx directly with custom config
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
error_log /dev/null warn; # ← Envoyer les logs vers /dev/null
|
||||
pid /tmp/nginx.pid;
|
||||
|
||||
events {
|
||||
@@ -9,15 +9,14 @@ events {
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
access_log /dev/null; # ← Idem pour access logs
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
|
||||
server {
|
||||
listen 3000;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"build": "node node_modules/@angular/cli/bin/ng.js build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"test:ci": "ng test --watch=false"
|
||||
|
||||
+14
-4
@@ -34,12 +34,14 @@ 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:-}
|
||||
|
||||
volumes:
|
||||
- ./etl/airflow/dags:/opt/airflow/dags
|
||||
- ./etl/airflow/plugins:/opt/airflow/plugins
|
||||
@@ -167,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
|
||||
|
||||
+4
-4
@@ -25,7 +25,7 @@ Le choix du modèle est dans l'ADR 0005. Ce document ne les répète pas.
|
||||
|---|---|---|
|
||||
| `load_from_csv(path)` | `ml/data/all_sites_combined.csv` | Chemin de démarrage, tant que la base n'est pas peuplée |
|
||||
| `load_from_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
|
||||
CSV, sans base joignable, et d'obtenir le même comportement une fois la base peuplée. Une
|
||||
@@ -93,7 +93,7 @@ La table `prediction` **n'a pas de contrainte d'unicité sur `(site_id, target_a
|
||||
insère une ligne de plus au lieu d'écraser la précédente. C'est délibéré, et c'est ce qui rend
|
||||
possible la comparaison prévision contre réalisé. La surveillance de dérive s'en sert : elle
|
||||
retient, pour chaque `(site_id, target_at)`, la ligne du run le plus récent, celle-là même que
|
||||
sert `GET /api/v1/predictions`. Voir l'[ADR 0011](adr/0011-surveillance-de-derive-dans-le-backend.md).
|
||||
sert `GET /api/v1/predictions`. Voir l'[ADR 0013](adr/0013-surveillance-de-derive-dans-le-backend.md).
|
||||
|
||||
Trois contraintes de cohérence sont portées par la base et non par le code applicatif :
|
||||
`status = 'available'` exige une `predicted_value` et interdit un `failure_reason` ;
|
||||
@@ -183,7 +183,7 @@ l'entraînement, dont le DAG `ml_train` n'a pas de planification.
|
||||
La surveillance de dérive traverse cette frontière **dans le sens de la table vers le backend**,
|
||||
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 0011](adr/0011-surveillance-de-derive-dans-le-backend.md).
|
||||
l'[ADR 0013](adr/0013-surveillance-de-derive-dans-le-backend.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -194,4 +194,4 @@ l'[ADR 0011](adr/0011-surveillance-de-derive-dans-le-backend.md).
|
||||
- [ADR 0006](adr/0006-moteur-de-regles-dans-le-backend.md) : ce qui consomme les prédictions
|
||||
- [`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
|
||||
- [ADR 0011](adr/0011-surveillance-de-derive-dans-le-backend.md) : la surveillance de dérive
|
||||
- [ADR 0013](adr/0013-surveillance-de-derive-dans-le-backend.md) : la surveillance de dérive
|
||||
|
||||
+3
-1
@@ -17,4 +17,6 @@
|
||||
| [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é |
|
||||
| [0010](adr/0010-terraform-provisionne-github-actions-deploie.md) | Terraform provisionne la machine, GitHub Actions déploie l'application |
|
||||
| [0011](adr/0011-surveillance-de-derive-dans-le-backend.md) | La surveillance de dérive vit dans le backend et écrit sa propre table |
|
||||
| [0011](adr/0011-enervision-procedure-deploiement.md) | Procédure de déploiement, telle qu'exécutée le 22/09/2026 |
|
||||
| [0012](adr/0012-enervision-deploiement-rec-prod-vm-eni.md) | État de la recette et de la production sur la VM ENI |
|
||||
| [0013](adr/0013-surveillance-de-derive-dans-le-backend.md) | La surveillance de dérive vit dans le backend et écrit sa propre table |
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
# EnerVision · procédure de déploiement (22/09/2026)
|
||||
|
||||
Terraform provisionne la machine, GitHub Actions déploie (ADR 0010). Deux environnements Compose
|
||||
sur la VM ENI `10.101.200.37` : `rec` sur la branche `dev`, `prod` sur `main` (ADR 0009).
|
||||
|
||||
| | recette | production |
|
||||
|---|---|---|
|
||||
| Branche, environnement GitHub | `dev`, `rec` | `main`, `prod` |
|
||||
| Dossier, projet Compose | `/srv/enervision/rec`, `enervision-rec` | `/srv/enervision/prod`, `enervision-prod` |
|
||||
| URL | `https://rec.enervision.local:8443` | `https://enervision.local` |
|
||||
| Proxy HTTP / HTTPS | `127.0.0.1:8081` / `8443` | `80` / `443` |
|
||||
| Postgres / Mailpit / Airflow (locaux) | `5434` / `8026` / `8082` | `5433` / `8025` / `8080` |
|
||||
|
||||
## 0. Avant toute commande
|
||||
|
||||
1. **Clé SSH déposée** sur la VM : `ssh-copy-id -i ~/.ssh/id_ed25519.pub root@10.101.200.37`.
|
||||
Terraform ne gère **pas** l'authentification par mot de passe (elle finirait dans le state).
|
||||
2. **L'utilisateur propriétaire existe déjà** sur la VM (ex. `enervision`) : il possède
|
||||
`/srv/enervision` et fait tourner le runner. Terraform échoue tôt s'il manque, il ne le crée pas.
|
||||
3. **Jeton d'enregistrement du runner** : Settings > Actions > Runners > New self-hosted runner.
|
||||
Valable 1 h, une seule inscription, créé par un administrateur du dépôt (ineszang).
|
||||
4. **`main` est en retard de 64 commits** et ne porte ni `deploy.yml`, ni `provision-host.sh`, ni
|
||||
le Terraform, ni l'overlay paramétré (ports et `PUBLIC_ORIGIN` en dur). Tant que `dev` n'est pas
|
||||
remonté dans `main`, seule la recette est déployable : le clone `prod` sera préparé mais son
|
||||
`make stack-up` publierait 80/443 sans les variables, et aucun push sur `main` ne déclencherait
|
||||
de déploiement (le workflow n'y existe pas). **Remonter `dev` → `main` avant de toucher à prod.**
|
||||
|
||||
## 1. Provisionner la machine (depuis le poste)
|
||||
|
||||
```bash
|
||||
cd infra/terraform/environments/vm-eni
|
||||
cp terraform.tfvars.example terraform.tfvars
|
||||
terraform init
|
||||
terraform apply
|
||||
```
|
||||
|
||||
`terraform.tfvars`, ignoré par git, trois valeurs à renseigner :
|
||||
|
||||
```hcl
|
||||
proprietaire = "enervision" # doit exister sur la VM
|
||||
runner_version = "2.330.0" # épingler depuis github.com/actions/runner/releases
|
||||
runner_token = "..." # jeton d'1 h, à retirer du fichier après l'apply
|
||||
```
|
||||
|
||||
Défauts utiles : `ssh_host = "10.101.200.37"`, `ssh_user = "root"`,
|
||||
`ssh_private_key_path = "~/.ssh/id_ed25519"`, `racine = "/srv/enervision"`,
|
||||
`runner_labels = "eni-g3"` (ciblé par `deploy.yml`), `runner_dossier = "/opt/actions-runner"`.
|
||||
|
||||
L'apply fait trois choses, dans cet ordre : Docker + plugin Compose et `usermod -aG docker`,
|
||||
puis `scripts/provision-host.sh`, puis l'installation et l'enregistrement du runner en service.
|
||||
Il ne construit aucune image et ne démarre aucun conteneur : un apply n'interrompt pas la stack.
|
||||
|
||||
Rejouable : un clone existant est réaligné, un `.env` présent n'est **jamais** réécrit, un
|
||||
certificat présent n'est jamais régénéré. Un nouvel apply de la ressource runner redemande un
|
||||
jeton frais (il expire en 1 h).
|
||||
|
||||
## 2. Variables d'environnement
|
||||
|
||||
Un `.env` par dossier, en `600`, généré sur la machine depuis `.env.example`. **Aucun secret ne
|
||||
passe par git ni par GitHub** : le runner n'en reçoit aucun (seul `SONAR_TOKEN` existe côté CI).
|
||||
|
||||
**Générés automatiquement** : `POSTGRES_PASSWORD`, `APP_SECRET_KEY`, `AIRFLOW_FERNET_KEY`,
|
||||
`AIRFLOW_API_SECRET_KEY`, `AIRFLOW_JWT_SECRET`, `AIRFLOW_ADMIN_PASSWORD`, `AIRFLOW_APP_SECRET_KEY`.
|
||||
|
||||
**Fixés par environnement** : `COMPOSE_PROJECT_NAME`, `PUBLIC_HOST`, `PUBLIC_ORIGIN`,
|
||||
`PROXY_HTTP_PORT`, `PROXY_HTTPS_PORT`, `POSTGRES_PORT`, `MAILPIT_UI_PORT`, `AIRFLOW_PORT`.
|
||||
|
||||
**À renseigner à la main**, dans chaque `.env`, avant le premier démarrage :
|
||||
|
||||
```
|
||||
APP_MOCK_API_USERNAME=...
|
||||
APP_MOCK_API_PASSWORD=...
|
||||
```
|
||||
|
||||
Garde-fou : le script refuse d'écrire un `.env` s'il reste un `change_me` hors `APP_MOCK_API_*`
|
||||
(cas vécu d'une clé renommée en amont, `AIRFLOW_WEBSERVER_SECRET_KEY` sous Airflow 3).
|
||||
|
||||
`APP_ENV=prod` et `APP_DEBUG=false` sont en dur dans l'overlay, pas dans le `.env` : la valeur
|
||||
`local` du poste reprendrait le dessus et rouvrirait `/docs` sans cookie `__Secure-`.
|
||||
|
||||
`TS_TUNE_MEMORY=2GB` et `TS_TUNE_NUM_CPUS=2` sont obligatoires : deux TimescaleDB sur 8 Go se
|
||||
réserveraient 25 % de la RAM chacune. La montée à 32 Go est à demander.
|
||||
|
||||
Certificats auto-signés générés par le script (`infra/proxy/tls/`), couvrant le nom d'hôte,
|
||||
`localhost` et l'IP. Let's Encrypt (`make tls-acme`, `ACME_EMAIL`) reste hors d'atteinte sans
|
||||
domaine public résolvable.
|
||||
|
||||
## 3. Premier démarrage (manuel, une seule fois, sur la VM)
|
||||
|
||||
```bash
|
||||
cd /srv/enervision/rec && make stack-up # build + up + alembic upgrade head
|
||||
cd /srv/enervision/prod && make stack-up # seulement après la remontée dev → main
|
||||
```
|
||||
|
||||
`stack-up` refuse de démarrer si le certificat manque ou ne couvre pas `PUBLIC_HOST`, et applique
|
||||
les migrations : sans elles la stack démarrerait verte sur une base sans schéma.
|
||||
|
||||
Premier administrateur, stack démarrée, dans chaque dossier :
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend \
|
||||
python -m app.cli create-admin --email <adresse>
|
||||
```
|
||||
|
||||
Données historiques : `data/raw` n'est pas dans git. Déposer les fichiers dans chaque dossier
|
||||
avant de déclencher le DAG `historical_import`.
|
||||
|
||||
## 4. Réglages GitHub (administrateur du dépôt)
|
||||
|
||||
- Environnement `prod` : branche `main` seule autorisée, **approbation d'un relecteur** requise.
|
||||
- Environnement `rec` : branche `dev` seule autorisée, sans approbation.
|
||||
- Settings > Actions : **« Require approval for all outside collaborators »**. Un runner
|
||||
auto-hébergé sur un dépôt public exécute ce qu'on lui envoie ; `deploy.yml` ne se déclenche
|
||||
jamais sur `pull_request`, et le runner ne tourne jamais en root.
|
||||
|
||||
## 5. Déploiement continu, ensuite
|
||||
|
||||
Un push sur `dev` déploie la recette, un push sur `main` la production après approbation.
|
||||
Le job (runner `eni-g3`) aligne le clone (`fetch`, `checkout`, `reset --hard`), lance
|
||||
`make stack-up`, puis sonde `/api/v1/health/ready` derrière le proxy pendant 3 minutes ; en cas
|
||||
d'échec il publie `ps` et les 50 dernières lignes de `backend` et `proxy`. Pas de `checkout` dans
|
||||
l'espace du runner : `.env`, certificats et volumes doivent survivre d'un déploiement à l'autre.
|
||||
Concurrence par branche, sans annulation.
|
||||
|
||||
Déclenchement manuel possible : `workflow_dispatch`.
|
||||
|
||||
## 6. Vérifier
|
||||
|
||||
```bash
|
||||
curl -k https://localhost:8443/api/v1/health/ready # recette, sur la VM
|
||||
curl -k https://localhost/api/v1/health/ready # production, sur la VM
|
||||
```
|
||||
|
||||
Depuis un poste, ajouter à `/etc/hosts` :
|
||||
|
||||
```
|
||||
10.101.200.37 enervision.local rec.enervision.local
|
||||
```
|
||||
|
||||
Les deux noms sont obligatoires : le cookie `__Secure-ev_refresh` est posé par hôte et non par
|
||||
port ; un seul nom déconnecterait la production à chaque connexion en recette.
|
||||
|
||||
## Pièges à connaître
|
||||
|
||||
- Compose **2.24.4 minimum** : l'overlay emploie `!override` et `!reset`, sans quoi l'API resterait
|
||||
joignable en clair à côté du proxy. Le script le vérifie.
|
||||
- Le runner doit tourner sous le propriétaire de `/srv/enervision` : sinon git refuse les clones
|
||||
(propriété douteuse) et le `.env` en `600` lui échappe. Correctif :
|
||||
`PROPRIETAIRE=<utilisateur> bash scripts/provision-host.sh`.
|
||||
- Chaque environnement reconstruit ses images à partir du même commit : la production n'exécute
|
||||
pas l'artefact validé en recette, mais un second build. Le passage à GHCR lèvera cette limite.
|
||||
- Un `.env` perdu se régénère, mais invalide les sessions et les connexions chiffrées par Airflow :
|
||||
ils ne sont sauvegardés nulle part ailleurs.
|
||||
- Retirer le runner se fait à la main, depuis les paramètres du dépôt : `terraform destroy` ne le
|
||||
désinscrit pas.
|
||||
|
||||
## Références dans le dépôt
|
||||
|
||||
`docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md`,
|
||||
`docs/adr/0010-terraform-provisionne-github-actions-deploie.md`,
|
||||
`docs/architecture/50-cicd.md`, `docs/architecture/10-infra.md`, `infra/README.md`,
|
||||
`scripts/provision-host.sh`, `.github/workflows/deploy.yml`, `docker-compose.prod.yml`.
|
||||
@@ -0,0 +1,120 @@
|
||||
# EnerVision · Recette et production sur la VM ENI, aujourd'hui
|
||||
|
||||
État au lundi 21 septembre 2026, 15h. Cible : deux environnements qui tournent sur la VM
|
||||
`eadl-2025-nantes-g3` (`10.101.200.37`) avant vendredi 25/09 9h, déployés automatiquement depuis
|
||||
GitHub. Ce document donne la solution retenue, ce qu'elle change dans le dépôt, et le déroulé de
|
||||
l'après-midi avec qui fait quoi.
|
||||
|
||||
## 1. La décision en une phrase
|
||||
|
||||
**Deux projets Docker Compose sur la même VM, un par environnement, déployés par un runner GitHub
|
||||
Actions installé sur la VM.** `dev` alimente la recette, `main` alimente la production. Terraform
|
||||
reste ce qu'il est : le module k3s, cible à terme, non utilisé pour cette mise en ligne.
|
||||
|
||||
| | Recette (`rec`) | Production (`prod`) |
|
||||
|---|---|---|
|
||||
| Branche | `dev` | `main` |
|
||||
| Environnement GitHub | `rec` (créé ce midi) | `prod` (créé ce midi) |
|
||||
| Dossier sur la VM | `/srv/enervision/rec` | `/srv/enervision/prod` |
|
||||
| Projet Compose | `enervision-rec` | `enervision-prod` |
|
||||
| URL | `https://rec.enervision.local:8443` | `https://enervision.local` |
|
||||
| Proxy HTTPS | `8443` | `443` |
|
||||
| Proxy HTTP (redirection) | `127.0.0.1:8081`, inutilisé | `80` |
|
||||
| PostgreSQL, Mailpit, Airflow | `127.0.0.1` : `5434`, `8026`, `8082` | `127.0.0.1` : `5433`, `8025`, `8080` |
|
||||
| Certificat | auto-signé, SAN `rec.enervision.local` | auto-signé, SAN `enervision.local` |
|
||||
| Déclenchement | chaque push sur `dev` | push sur `main`, après approbation dans GitHub |
|
||||
|
||||
Les deux noms d'hôte pointent sur la même IP. Deux lignes dans le `/etc/hosts` des postes de
|
||||
l'équipe suffisent. Deux noms distincts sont indispensables : le cookie de rafraîchissement
|
||||
`__Secure-ev_refresh` est posé par hôte, pas par port, et un seul nom ferait se déconnecter la
|
||||
prod à chaque connexion sur la recette.
|
||||
|
||||
## 2. Pourquoi c'est la solution la plus simple
|
||||
|
||||
- **Tout existe déjà.** L'overlay `docker-compose.prod.yml`, le proxy Nginx TLS, les scripts de
|
||||
certificat et `make stack-up` sont écrits et validés sur poste (PR #117, ADR 0007). Il ne
|
||||
manque que quatre variables pour que deux instances cohabitent sur une machine.
|
||||
- **Un projet Compose isole tout.** Volumes, réseau, noms de conteneurs sont préfixés par le nom
|
||||
du projet. Casser la recette ne touche pas la prod, ce qui est la raison d'être d'une recette.
|
||||
- **Le runner sur la VM est la seule façon d'atteindre une IP privée d'école depuis GitHub.** Les
|
||||
runners hébergés par GitHub ne voient pas `10.101.200.37`. Le runner se connecte en sortie
|
||||
vers GitHub, aucun port entrant n'est nécessaire. C'était le choix 16 du dossier EC01 : il
|
||||
redevient tenu.
|
||||
- **La promotion existe déjà dans la stratégie de branches** : `dev` puis `main` par PR. Le
|
||||
même code est déployé en recette, puis en production, sans troisième mécanisme.
|
||||
|
||||
Ce qu'on écarte, et pourquoi :
|
||||
|
||||
| Piste | Pourquoi pas cette semaine |
|
||||
|---|---|
|
||||
| k3s avec deux namespaces | Le cluster serait vide : aucun manifeste, aucun registre d'images, aucun stockage persistant. Trois jours de travail sans valeur visible au J10 |
|
||||
| Terraform de `feat/deploy` (nginx système + copie de fichiers) | Revue postée sur l'issue #21 : huit points bloquants, `rec` et `prod` ne passent pas `terraform validate`. On abandonne cette voie |
|
||||
| Azure ENI pour la prod | Deuxième infrastructure à provisionner, choix à justifier devant le jury (document 03), et rien n'est prêt côté Azure |
|
||||
| Images publiées sur GHCR | Meilleure pratique, mais un registre de plus à authentifier sur la VM. Les images se construisent sur la VM, où le runner tourne déjà. À faire ensuite, issue à ouvrir |
|
||||
| Let's Encrypt | Aucun domaine public ne résout vers la VM. Auto-signé assumé, chemin ACME déjà câblé |
|
||||
|
||||
## 3. Ce qui change dans le dépôt (une PR vers `dev`)
|
||||
|
||||
| Fichier | Changement | Raison |
|
||||
|---|---|---|
|
||||
| `apps/frontend/Dockerfile` | `FROM nginx:1.28-alpine` à la place de `dhi.io/nginx:...` | Le registre Docker Hardened Images demande une authentification. L'image frontend n'a jamais été construite, sur aucun poste : c'est le premier point où `make stack-up` échouerait sur la VM |
|
||||
| `docker-compose.prod.yml` | Ports du proxy en variables `PROXY_HTTP_PORT` et `PROXY_HTTPS_PORT`. Origine publique `PUBLIC_ORIGIN` pour CORS et le lien de réinitialisation. `TS_TUNE_MEMORY` sur la base | Deux proxys ne peuvent pas publier 80 et 443. L'origine de la recette porte un port. Deux TimescaleDB sur 8 Go se réserveraient chacune 2 Go sans réglage |
|
||||
| `.env.example` | `COMPOSE_PROJECT_NAME`, les variables ci-dessus, ports de la recette en commentaire | Le `.env` de chaque dossier est la seule différence entre les deux environnements |
|
||||
| `.github/workflows/deploy.yml` | Nouveau. `on: push` sur `dev` et `main`, `runs-on: [self-hosted, eni-g3]`, `environment: rec` ou `prod`, puis `git reset --hard origin/<branche>` et `make stack-up` dans le dossier de l'environnement | Le D de CI/CD, issue #21 |
|
||||
| `scripts/provision-host.sh` | Nouveau. Vérifie Docker et Compose 2.24.4 ou plus, crée `/srv/enervision/{rec,prod}`, clone les deux branches | Rejouable, et réutilisable par Terraform plus tard |
|
||||
| `docs/adr/0009-...md`, `10-infra.md`, `50-cicd.md`, `infra/proxy/README.md` | Décision, vue infra, vue CI/CD, tableau des ports | Règle du dépôt : la vue change dans la même PR que le composant |
|
||||
|
||||
Ce qui ne change pas : `docker-compose.yml`, la configuration Nginx, `infra/terraform`.
|
||||
|
||||
## 4. Déroulé de l'après-midi
|
||||
|
||||
| # | Qui | Quoi | Durée |
|
||||
|---|---|---|---|
|
||||
| 1 | **ineszang** (seule admin du dépôt) | Environnement `prod` : branche autorisée `main`, un relecteur requis. Environnement `rec` : branche `dev`. Settings > Actions : « Require approval for all outside collaborators ». Générer le jeton d'enregistrement du runner (Settings > Actions > Runners > New self-hosted runner, Linux x64) et le transmettre à Johan | 10 min |
|
||||
| 2 | **Johan** | Déposer sa clé sur la VM : `ssh-copy-id -i ~/.ssh/id_ed25519.pub root@10.101.200.37`, mot de passe du compte administrateur local des postes de l'école | 2 min |
|
||||
| 3 | Johan + Claude | **Fait à 15h** : branche locale `feat/deploy-rec-prod` avec tous les changements du §3, image frontend reconstruite avec succès, fusion Compose vérifiée pour les deux environnements. Reste : commit, push, PR vers `dev` | fait |
|
||||
| 4 | Claude, par SSH | `scripts/provision-host.sh` sur la VM. Écrire les deux `.env` (secrets générés sur la VM, jamais dans git). Certificats : `PUBLIC_HOST=rec.enervision.local PUBLIC_IP=10.101.200.37 make tls-selfsigned` dans `rec`, idem avec `enervision.local` dans `prod`. Puis `make stack-up` dans chaque dossier | 20 min plus la construction des images |
|
||||
| 5 | Johan, sur la VM | Installer le runner sous un utilisateur non-root membre du groupe `docker`, label `eni-g3`, en service systemd (`./config.sh --unattended --labels eni-g3`, `sudo ./svc.sh install && sudo ./svc.sh start`) | 10 min |
|
||||
| 6 | Équipe | Merger la PR dans `dev` : la recette se redéploie seule. Ouvrir la PR `dev` vers `main` : la prod se déploie après approbation dans l'onglet Environments | 15 min |
|
||||
| 7 | Tous | Vérifier depuis un poste de l'équipe, `/etc/hosts` renseigné : connexion, tableau de bord, Airflow par tunnel SSH | 15 min |
|
||||
|
||||
Contrôle en fin de chaîne, depuis la VM :
|
||||
|
||||
```bash
|
||||
curl -k https://localhost/api/v1/health/ready # prod
|
||||
curl -k https://localhost:8443/api/v1/health/ready # rec
|
||||
docker compose -p enervision-prod ps
|
||||
docker compose -p enervision-rec ps
|
||||
```
|
||||
|
||||
## 5. Ce qui peut faire échouer la journée, et la parade
|
||||
|
||||
| Risque | Parade |
|
||||
|---|---|
|
||||
| **8 Go de RAM pour deux stacks complètes** (deux Airflow, deux TimescaleDB, deux API) | Demander dès maintenant le passage à 32 Go, prévu par les consignes. En attendant : `TS_TUNE_MEMORY=2GB` et deux workers gunicorn pour Airflow. Si la RAM ne suit pas, démarrer la recette sans Airflow (`docker compose up -d --scale airflow-webserver=0 --scale airflow-scheduler=0`) |
|
||||
| **Compose trop ancien sur la VM** (les marqueurs `!override` et `!reset` exigent 2.24.4) | `docker compose version` en premier. Sinon installer le paquet `docker-compose-plugin` depuis le dépôt Docker |
|
||||
| **Pas de sortie Internet depuis la VM** | `curl -sI https://github.com` et `docker pull hello-world` avant tout. Sans sortie, ni construction d'image ni runner : déploiement manuel par `scp` d'images, plan B lourd |
|
||||
| **Runner auto-hébergé sur un dépôt public** | Le workflow de déploiement ne s'exécute que sur `push` vers `dev` et `main`, jamais sur `pull_request`. Réglage d'approbation des PR externes (étape 1). Runner sous un utilisateur dédié, jamais root |
|
||||
| **Premier démarrage avec un volume `pgdata` vide** | C'est le cas nominal sur la VM : `db/init` crée les bases `enervision`, `enervision_test` et `airflow`. Ne pas restaurer un volume de poste |
|
||||
| **Le jury accepte mal un certificat auto-signé** | Dire pourquoi avant qu'on le demande : aucun DNS public, ACME câblé et documenté, ADR 0007. Un clic « continuer » dans le navigateur |
|
||||
| **Conflit avec `feat/deploy`** (ineszang y a mergé `dev` à 14h06) | Partager ce document avant de pousser. La PR remplace `feat/deploy`, elle ne s'y ajoute pas |
|
||||
|
||||
## 6. Ce que ça donne pour la grille
|
||||
|
||||
- **EC03, CI/CD** : la chaîne ne s'arrête plus au merge. Deux environnements, déploiement
|
||||
automatique en recette, promotion approuvée en production, journal des déploiements dans
|
||||
l'onglet Environments de GitHub.
|
||||
- **EC04, cloud et sécurisation** : une application déployée et fonctionnelle, une seule surface
|
||||
exposée par environnement, secrets hors de git et hors de GitHub, base et Airflow joignables
|
||||
uniquement par tunnel SSH.
|
||||
- **Dossier EC01** : le choix 16 (runner auto-hébergé, déploiement automatique) passe de « non
|
||||
fait » à « tenu ». Le choix 12 (Ansible) reste non fait, et la réponse est prête : le
|
||||
durcissement de la machine n'est pas automatisé, le script de provisionnement en est la
|
||||
première brique, Terraform pourra l'appeler.
|
||||
|
||||
## 7. Après vendredi, si on continue
|
||||
|
||||
Dans l'ordre de valeur : images construites une fois en CI et publiées sur GHCR, puis déployées
|
||||
par digest (vraie promotion d'artefact). Racine Terraform `environments/eni-g3` qui provisionne
|
||||
la machine et le runner à partir du script. Sauvegarde de `pgdata` par `pg_dump` planifié.
|
||||
Monitoring (issue #26). Et seulement ensuite la bascule k3s, si elle garde un sens.
|
||||
+8
-1
@@ -1,4 +1,4 @@
|
||||
# 0011 - La surveillance de dérive vit dans le backend et écrit sa propre table
|
||||
# 0013 - La surveillance de dérive vit dans le backend et écrit sa propre table
|
||||
|
||||
- Statut : accepté
|
||||
- Date : 2026-09-22
|
||||
@@ -97,6 +97,13 @@ modèle change n'est pas une dérive, c'est une régression de réentraînement.
|
||||
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
|
||||
|
||||
@@ -72,9 +72,9 @@ intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
|
||||
|
||||
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).
|
||||
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.
|
||||
@@ -86,18 +86,20 @@ collecteur ne vient le lire.
|
||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) |
|
||||
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle |
|
||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
|
||||
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive livrée côté backend (`app.monitoring.drift`, table `drift_report`, `GET /monitoring/drift`, DAG `derive`), voir [ADR 0011](../adr/0011-surveillance-de-derive-dans-le-backend.md) |
|
||||
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive livrée côté backend (`app.monitoring.drift`, table `drift_report`, `GET /monitoring/drift`, DAG `derive`), voir [ADR 0013](../adr/0013-surveillance-de-derive-dans-le-backend.md) |
|
||||
| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Provisionnement de la VM par Terraform, qui installe Docker, prépare les deux environnements et enregistre le runner, jamais appliqué ([ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée |
|
||||
| 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. Cinq DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes`, `historical_import` et `derive` (quotidien, surveillance de dérive). Le DAG historique orchestre `app.etl.historical_import` et charge `dataset`, `site` et `reading`. L'orchestration API Mock reste à compléter dans #15 |
|
||||
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver et scheduler avec LocalExecutor via Docker Compose, sur une base PostgreSQL dédiée. Six DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes`, `historical_import`, `mock_api_import` et `derive` (quotidien, surveillance de dérive). L'import historique reste manuel et l'import API Mock s'exécute chaque heure. La réconciliation globale des deux sources reste à compléter dans l'issue #15. |
|
||||
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 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 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 le
|
||||
dataset CSV/JSON sur déclenchement manuel et `mock_api_import` collecte chaque heure les mesures
|
||||
de l'API Mock. Les DAGs `ml_train` et `ml_score` (issue #115), `alertes` (issue #116) et `derive`
|
||||
(issue #45) portent le pipeline ML, la détection d'alertes et la surveillance de dérive. La
|
||||
réconciliation globale des données provenant des deux sources reste à compléter dans l'issue #15.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
|
||||
@@ -10,6 +10,7 @@ dans quel contexte, quelles décisions sont arrêtées, et ce qui manque encore
|
||||
| Deux projets Compose sur la VM ENI, recette et production | Déploiement continu depuis GitHub | `En cours` |
|
||||
| Provisionnement Terraform de la VM | Préparer la machine et enregistrer le runner | `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
|
||||
|
||||
@@ -53,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`) :
|
||||
@@ -86,6 +87,7 @@ 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` | `45 * * * *` | `app.etl.mock_api_import`, dans `/opt/backend/.venv` ; importe l'heure précédant son déclenchement depuis l'API Mock |
|
||||
| `derive` | `30 5 * * *` | `app.monitoring.drift`, dans `/opt/backend/.venv` ; quotidien parce que sa fenêtre couvre 168 h, et sans reprise parce qu'une dérive n'est pas une panne passagère |
|
||||
|
||||
Le DAG `historical_import` réutilise le pipeline historique existant sans dupliquer sa logique.
|
||||
@@ -93,6 +95,17 @@ 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, à 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
|
||||
finir. Aucune dépendance n'est déclarée entre les deux DAGs pour autant, ni `ExternalTaskSensor` ni
|
||||
@@ -149,6 +162,34 @@ est minimale et n'embarque pas la runtime OpenMP dont LightGBM a besoin, sans qu
|
||||
(`OSError: libgomp.so.1`) n'apparaît qu'à la première tâche réellement exécutée, pas à la
|
||||
construction de l'image.
|
||||
|
||||
### MLflow (`ml/`)
|
||||
|
||||
Statut : `Fait`, en local uniquement. Défini par `ml/docker-compose.mlflow.yml`, indépendant
|
||||
du `docker-compose.yml` principal (réseau, volumes et démarrage séparés).
|
||||
|
||||
| Service | Image | Points notables |
|
||||
|---|---|---|
|
||||
| `mlflow-db` | `postgres:17` | Stocke le tracking store MLflow. Mot de passe obligatoire via `MLFLOW_DB_PASSWORD` |
|
||||
| `mlflow` | Construite depuis `ml/` | Expose l'UI et l'API MLflow sur `127.0.0.1:5000`. Artefacts sur volume `mlflow-artifacts`, tracking store sur `mlflow-db` |
|
||||
|
||||
Portée actuelle : environnement de tracking et de registre de modèles pour le développement
|
||||
local uniquement. Ce compose n'est relié ni à `docker-compose.prod.yml`, ni aux deux
|
||||
environnements Compose de la VM ENI, ni à la cible k3s. Le magasin utilisé par Airflow pour
|
||||
`ml_train`/`ml_score` (SQLite, volume `airflow_ml_state`) en est distinct — les deux MLflow ne
|
||||
se voient pas tant que `MLFLOW_TRACKING_URI` n'est pas posé côté Airflow.
|
||||
|
||||
Limite connue : le DAG Airflow `ml_train` enregistre lui aussi une version a chaque execution
|
||||
via `registered_model_name` (magasin SQLite du volume `airflow_ml_state`, distinct de ce
|
||||
serveur). Versions et artefacts s'y accumulent sans politique de nettoyage -- fonctionne en
|
||||
l'etat, mais a surveiller si les entrainements deviennent frequents.
|
||||
|
||||
Pour relier les runs Airflow (`ml_train`, magasin SQLite local) a ce serveur MLflow, positionner
|
||||
`MLFLOW_TRACKING_URI=http://mlflow:5000` dans l'environnement du service `airflow-scheduler` (ou
|
||||
`http://host.docker.internal:5000` si le serveur MLflow tourne hors du reseau Compose principal),
|
||||
et s'assurer que le conteneur Airflow peut joindre le service `mlflow` -- ce qui suppose de les
|
||||
rapprocher sur le meme reseau Docker ou d'exposer MLflow autrement qu'en `127.0.0.1` uniquement
|
||||
(cf. point 1 sur l'exposition du port). Non fait a ce jour : aucun besoin de centraliser les runs
|
||||
d'entrainement Airflow et locaux n'a encore ete identifie.
|
||||
## Machine cible, exécution Docker
|
||||
|
||||
Statut : `Fait`. Défini par l'overlay `docker-compose.prod.yml`, appliqué par-dessus le
|
||||
|
||||
@@ -235,7 +235,7 @@ 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é |
|
||||
| `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 |
|
||||
@@ -246,7 +246,7 @@ d'observations, le service dit qu'il ne sait pas plutôt que de rendre un chiffr
|
||||
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 0011](../adr/0011-surveillance-de-derive-dans-le-backend.md).
|
||||
arbitrages sont dans l'[ADR 0013](../adr/0013-surveillance-de-derive-dans-le-backend.md).
|
||||
|
||||
### Détection d'alertes internes
|
||||
|
||||
@@ -356,8 +356,10 @@ pas prise :
|
||||
| `license_info` | Aucune licence n'est choisie |
|
||||
| `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
|
||||
`Cookie de rafraîchissement` pour `/auth/refresh` et `/auth/logout`. **Le second est purement
|
||||
Deux schémas de sécurité sont déclarés : `JetonAcces` pour le porteur JWT, et
|
||||
`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
|
||||
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.
|
||||
|
||||
@@ -289,6 +289,10 @@ Chaque table remplit un rôle précis dans le traitement et l'exploitation des d
|
||||
| `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d'EnerVision |
|
||||
| `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`.
|
||||
|
||||
Elles servent à l'analyse des données et ne sont pas considérées comme des alertes actuelles.
|
||||
@@ -297,7 +301,7 @@ Les lignes de `drift_report` sont écrites par `app.monitoring.drift`, ordonnanc
|
||||
`derive`. Une ligne dont le `site_id` est `NULL` porte le résultat global, tous sites confondus :
|
||||
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 0011](../adr/0011-surveillance-de-derive-dans-le-backend.md).
|
||||
comparer sont dans l'[ADR 0013](../adr/0013-surveillance-de-derive-dans-le-backend.md).
|
||||
|
||||
Les lignes de `recommendation` sont écrites par le moteur de règles du backend
|
||||
(`app/services/recommendation_rules.py`), déclenché par `POST /api/v1/recommendations/generate`,
|
||||
|
||||
+137
-23
@@ -68,23 +68,36 @@ flowchart TB
|
||||
push --> mv & ms
|
||||
push --> av & ab
|
||||
push --> it
|
||||
push --> sb1 & sb2 --> sscan
|
||||
push --> sb1 & sb2 & sb3 --> 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
|
||||
|
||||
planifie["chaque lundi 3h UTC,<br/>ou à la main"]
|
||||
subgraph dastw["DAST · dast.yml"]
|
||||
zscan["zap<br/>seed + scan actif OWASP ZAP"]
|
||||
end
|
||||
|
||||
planifie --> zscan
|
||||
push -->|"PR sur dast.yml<br/>ou dast-token.sh"| zscan
|
||||
```
|
||||
|
||||
## Déclenchement
|
||||
|
||||
Les six workflows hébergés par GitHub se déclenchent sur `push` **et** sur `pull_request`,
|
||||
filtrés par **chemin** : `backend.yml` sur `apps/backend/**`, `frontend.yml` sur
|
||||
Les six workflows hébergés par GitHub qui vérifient le code se déclenchent sur `push` **et** sur
|
||||
`pull_request`, filtrés par **chemin** : `backend.yml` sur `apps/backend/**`, `frontend.yml` sur
|
||||
`apps/frontend/**`, `ml.yml` sur `ml/**`, `infra.yml` sur `infra/terraform/**`, `airflow.yml` sur
|
||||
`etl/airflow/**` **plus des chemins de `ml/` et de `apps/backend/`**, chacun incluant son propre
|
||||
fichier de workflow dans le filtre pour qu'une modification du pipeline déclenche le pipeline.
|
||||
|
||||
`dast.yml` s'en écarte volontairement (détail dans sa propre section plus bas) : aucun
|
||||
déclenchement sur `push`, seulement `workflow_dispatch`, une planification hebdomadaire, et
|
||||
`pull_request` restreint à ses deux seuls fichiers. Un scan actif est trop long pour tourner à
|
||||
chaque commit.
|
||||
|
||||
Le filtre d'`airflow.yml` mérite un mot : il inclut `ml/pyproject.toml`, `ml/uv.lock`,
|
||||
`ml/enervision_ml/**`, `apps/backend/pyproject.toml`, `apps/backend/uv.lock` et
|
||||
`apps/backend/app/**` parce que l'image Airflow copie le code et les dépendances des deux
|
||||
@@ -108,7 +121,8 @@ environnement.
|
||||
|
||||
## Déploiement
|
||||
|
||||
`deploy.yml` est le septième workflow, et le seul qui ne tourne pas chez GitHub : il s'exécute sur
|
||||
`deploy.yml` est le huitième workflow (`backend`, `frontend`, `ml`, `infra`, `airflow`,
|
||||
`sonarqube`, `dast`, plus lui-même), et le seul qui ne tourne pas chez GitHub : il s'exécute sur
|
||||
un runner auto-hébergé installé sur la VM ENI, label `eni-g3`, parce que les runners hébergés ne
|
||||
joignent pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port
|
||||
entrant n'est ouvert.
|
||||
@@ -195,6 +209,28 @@ avant `alembic upgrade head`.
|
||||
La couverture est **désactivée** sur ce job (`pytest -m integration --no-cov`) : il ne joue qu'une
|
||||
partie de la suite, et son taux n'aurait aucun sens face au seuil de 85 %.
|
||||
|
||||
### Pourquoi le job d'intégration ML installe aussi le backend
|
||||
|
||||
Le schéma de la base n'a qu'une source, les six révisions Alembic de `apps/backend/alembic` : le
|
||||
backend est propriétaire du schéma, `ml/` n'en est que consommateur. Reconstruire ce schéma à la
|
||||
main dans le job ML donnerait un job vert sur une base qui n'est pas la nôtre, exactement l'erreur
|
||||
qu'évite déjà le choix de l'image `timescaledb-ha` plutôt qu'un `postgres` nu. Le job installe
|
||||
donc les deux environnements uv, applique `alembic upgrade head`, puis joue `-m integration` côté
|
||||
`ml/` et `-m chaine` côté backend.
|
||||
|
||||
Conséquence sur le déclenchement : les `paths` de `ml.yml` incluent `apps/backend/alembic/**` et
|
||||
`apps/backend/app/models/**`. Sans eux, une migration qui renomme une colonne de `reading` ne
|
||||
déclencherait pas ce job, le SQL brut du pipeline dériverait du schéma, et **rien ne casserait
|
||||
avant la production**. Le prix est qu'une PR touchant seulement une migration lance aussi le lint
|
||||
et le typage de `ml/` : environ deux minutes de runner, en parallèle. Même arbitrage que le filtre
|
||||
d'`airflow.yml`, qui écoute déjà `ml/**` et `apps/backend/app/**` parce que son image réunit les
|
||||
deux.
|
||||
|
||||
Le marqueur `chaine` est distinct d'`integration` pour une raison mécanique : le job `integration`
|
||||
de `backend.yml` n'installe pas `ml/.venv`, et sélectionnerait sinon un test qui lance les
|
||||
binaires du pipeline. Il est aussi exclu d'`addopts`, sans quoi `make test` échouerait sur tout
|
||||
poste où `ml/` n'est pas installé.
|
||||
|
||||
## SonarCloud, et l'incident qui a immobilisé trois PR
|
||||
|
||||
Le workflow `sonarqube.yml` exécute cinq jobs de préparation (`build-front`, `test-front`,
|
||||
@@ -256,34 +292,112 @@ Ils ne transitent ni par git ni par GitHub, et le runner, qui travaille dans ce
|
||||
à recevoir. Le revers : ils ne sont sauvegardés nulle part ailleurs. Un `.env` perdu se
|
||||
régénère, ce qui invalide les sessions et les connexions chiffrées par Airflow.
|
||||
|
||||
### Pourquoi le job d'intégration ML installe aussi le backend
|
||||
## Scan DAST (OWASP ZAP)
|
||||
|
||||
Le schéma de la base n'a qu'une source, les six révisions Alembic de `apps/backend/alembic` : le
|
||||
backend est propriétaire du schéma, `ml/` n'en est que consommateur. Reconstruire ce schéma à la
|
||||
main dans le job ML donnerait un job vert sur une base qui n'est pas la nôtre, exactement l'erreur
|
||||
qu'évite déjà le choix de l'image `timescaledb-ha` plutôt qu'un `postgres` nu. Le job installe
|
||||
donc les deux environnements uv, applique `alembic upgrade head`, puis joue `-m integration` côté
|
||||
`ml/` et `-m chaine` côté backend.
|
||||
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.
|
||||
|
||||
Conséquence sur le déclenchement : les `paths` de `ml.yml` incluent `apps/backend/alembic/**` et
|
||||
`apps/backend/app/models/**`. Sans eux, une migration qui renomme une colonne de `reading` ne
|
||||
déclencherait pas ce job, le SQL brut du pipeline dériverait du schéma, et **rien ne casserait
|
||||
avant la production**. Le prix est qu'une PR touchant seulement une migration lance aussi le lint
|
||||
et le typage de `ml/` : environ deux minutes de runner, en parallèle. Même arbitrage que le filtre
|
||||
d'`airflow.yml`, qui écoute déjà `ml/**` et `apps/backend/app/**` parce que son image réunit les
|
||||
deux.
|
||||
Le job démarre sur le runner la base (même image TimescaleDB que `docker-compose.yml`, base
|
||||
jetable), applique les migrations, y sème un site et deux relevés (`db/seeds/` est vide, pas
|
||||
encore d'outillage de jeu de données pour la CI ; sans données, `GET /sites` rend `[]`, chaque
|
||||
`/{site_id}` rend 404, et le scan actif ne frappe que des gestionnaires d'erreur), démarre le
|
||||
backend, puis `scripts/dast-token.sh` crée un compte **`lecteur`** et rend son jeton.
|
||||
|
||||
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é.
|
||||
ZAP charge le contrat `/openapi.json` depuis un fichier (`zap-api-scan.py -f openapi -t
|
||||
/zap/wrk/openapi.json`) et en importe les 26 opérations **quel que soit le jeton** : c'est le
|
||||
contrat qui décide de ce qui est exploré, pas l'authentification. Le jeton ne change que les
|
||||
réponses obtenues sur les routes gardées : sans lui, elles répondraient toutes `401` plutôt que
|
||||
de dérouler leur logique. Huit routes n'exigent aucun jeton porteur (les deux sondes, `login`,
|
||||
`refresh`, `logout`, `forgot-password`, `reset-password` et `reset-password/validate`) et
|
||||
répondent donc pareil avec ou sans lui.
|
||||
|
||||
Décisions à savoir défendre :
|
||||
|
||||
- **Le compte du scan est `lecteur`, jamais `admin`.** Un scan actif avec un jeton admin frapperait
|
||||
`POST /users` et la réinitialisation de mots de passe pour de bon. Le script passe par un admin
|
||||
jetable pour créer le lecteur (l'API n'a pas d'inscription publique) puis ne s'en sert plus.
|
||||
- **Un compte neuf est en `must_change_password`**, et toute route gardée le refuse tant que le
|
||||
mot de passe n'est pas changé. Le script fait ce changement et vérifie `GET /sites` = 200 avant
|
||||
de rendre le jeton ; sans cela, tout le scan authentifié ne testerait que des `403`.
|
||||
`POST /auth/password` rend déjà un nouveau jeton valide (l'`iat` tronqué documenté dans
|
||||
`app/api/deps.py` ne le rejette pas comme antérieur à la session) : le script s'en sert
|
||||
directement plutôt que de se reconnecter, deux hachages Argon2id (19456 Kio chacun) et deux
|
||||
allers-retours de refresh-token de moins sur le chemin critique de la CI.
|
||||
- **`APP_ACCESS_TOKEN_TTL_SECONDS=3600`** (plafond de la configuration) : le jeton par défaut
|
||||
dure 15 minutes. `scanner.maxScanDurationInMins=15` (ci-dessous) borne le scan actif très en
|
||||
dessous, marge comprise pour les étapes qui l'entourent.
|
||||
- **Le jeton ne transite ni par `${{ }}` dans le script de l'étape, ni par l'argv de `docker
|
||||
run`.** Le premier finirait en clair dans le fichier de commande que GitHub écrit sur le disque
|
||||
du runner pour toute la durée de l'étape ; le second serait visible par `ps aux` et par
|
||||
`docker inspect zap` tant que le conteneur existe. Il est écrit dans un fichier de
|
||||
configuration ZAP séparé (`-configfile`), monté en lecture seule hors de `/zap/wrk` pour ne
|
||||
jamais atterrir dans l'artefact publié. ZAP journalise malgré tout la valeur de chaque
|
||||
`-config`/`-configfile` chargé à un niveau visible sans `-d` : les copies de `zap.log` et
|
||||
`zap-stdout.log` publiées en artefact sont donc caviardées avant publication.
|
||||
|
||||
**Deux pièges d'autorisation** sur ce fichier de configuration (`zap-auth.conf`), tous les deux
|
||||
propres au montage bind Docker : le conteneur y lit avec son propre uid (1000), distinct de celui
|
||||
du runner qui l'a écrit, sans remappage automatique.
|
||||
|
||||
- Un `chmod 600` seul rend le fichier illisible pour le conteneur (« File not readable :
|
||||
/zap/auth.conf »). ZAP échoue dès le lancement, mais `zap-api-scan.py` attend les `-T` minutes
|
||||
complètes avant d'abandonner : dix minutes qui ressemblent à un scan actif, pour un daemon mort
|
||||
depuis le début. Corrigé par `sudo chown 1000:1000` du fichier avant de le passer à `644`.
|
||||
- Ce `chown` déplace la propriété du fichier hors de l'utilisateur du runner : un `chmod` qui
|
||||
suit sans `sudo` échoue alors (« Operation not permitted »), et le `-e` implicite des étapes
|
||||
bash de GitHub Actions arrête toute l'étape avant même `docker run` — un scan « réussi » en une
|
||||
fraction de seconde, sans le moindre journal ni rapport produit. Les deux commandes doivent
|
||||
passer par `sudo`.
|
||||
|
||||
Les routes d'authentification qui changent l'état du compte (`login`, `password`, `logout-all`,
|
||||
`forgot-password`, `reset-password`) sont exclues du scan actif : elles y déclencheraient la
|
||||
limitation de débit et fermeraient les sessions sans rien apprendre de plus.
|
||||
|
||||
**Un scan vert n'est pas un scan qui a testé quelque chose.** Deux garde-fous, eux, **bloquent** :
|
||||
|
||||
- **Moins de 80% des opérations du contrat importées.** Constaté une première fois : 2 URL sur 26
|
||||
opérations importées, ZAP n'avait envoyé que des requêtes vouées au 404 (l'analyseur de ZAP
|
||||
refusait alors le nom accentué d'un des deux schémas de sécurité du contrat, corrigé depuis en
|
||||
ASCII côté backend). Le seuil est dérivé du contrat (`zap-out/openapi.json`, présent à cette
|
||||
étape) plutôt que d'un nombre fixe : un contrat qui grossit ne doit pas rendre la garde plus
|
||||
permissive qu'elle ne l'était.
|
||||
- **Aucune réponse 2xx.** Constaté une deuxième fois, cause différente : la clé de configuration
|
||||
du nom d'en-tête pour la règle Replacer est `matchstr`, pas `matchstring` (celui-ci n'existe que
|
||||
pour le job d'automatisation ZAP, pas pour `-config`) ; ZAP acceptait la mauvaise clé sans
|
||||
erreur et laissait le nom d'en-tête vide, qu'uvicorn refusait par un `400` sur **toute** requête,
|
||||
y compris les routes publiques. Piège de conception rencontré en corrigeant cette garde : borner
|
||||
le *pourcentage* de 4xx ne marche pas, un scan actif fuzze délibérément un grand nombre
|
||||
d'entrées invalides, si bien qu'un scan sain contre l'API seedée reste à 98% de 4xx avec
|
||||
seulement 1% de 2xx. C'est la forme normale d'un scan actif. Le signal qui distingue vraiment un
|
||||
scan cassé (2xx nul, absent du rapport dans les deux incidents) d'un scan sain (2xx non nul,
|
||||
aussi faible soit-il) est l'absence de succès, pas la part d'échecs. Les deux gardes lisent
|
||||
`zap-out/zap-report.json` (champs structurés `insights[]`), pas le texte libre du rapport
|
||||
Markdown.
|
||||
|
||||
Le journal interne de ZAP (`zap.log`) et sa sortie complète (`zap-stdout.log`) sont publiés dans
|
||||
l'artefact `zap-report` (dossier `zap-logs/`, propriété du runner : `zap-out/` bascule sous l'uid
|
||||
1000 du conteneur ZAP dès que le contrat y est copié, le runner n'y écrit plus ensuite) pour
|
||||
diagnostiquer un futur import raté.
|
||||
|
||||
**Non bloquant pour l'instant** (`continue-on-error`, sur la seule étape du scan) pour ce qui est
|
||||
des alertes elles-mêmes. Le volume d'un premier passage trié est inconnu ; le rapport
|
||||
HTML/JSON/Markdown est publié en artefact `zap-report`, et sa synthèse (jusqu'aux tableaux
|
||||
d'alertes, sans le détail par alerte) dans le résumé du job. Fixer un seuil viendra une fois les
|
||||
alertes triées.
|
||||
|
||||
**Limite à ne pas oublier :** le scan tape la configuration par défaut du backend (`APP_ENV=local`,
|
||||
pas de TLS, pas de reverse proxy). Il remontera des alertes qui n'existent pas derrière le proxy
|
||||
(HSTS absent...) et ne dit **rien** des en-têtes ni du TLS que le proxy pose en production. Un
|
||||
second passage sur la stack complète reste à faire.
|
||||
|
||||
## Ce qui manque, et pourquoi
|
||||
|
||||
| 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 |
|
||||
| DAST (OWASP ZAP) | #41 | Aucune vérification sur l'application en fonctionnement, seulement sur le code et les dépendances |
|
||||
| DAST bloquant | #41 | Le scan ZAP existe mais ne bloque rien : aucun seuil n'est fixé tant que les alertes du premier passage ne sont pas triées |
|
||||
| Tests end to end | #46 | Les parcours utilisateur ne sont pas vérifiés en CI |
|
||||
| Tests de charge | #47 | Aucun garde-fou de performance |
|
||||
| Scan d'image de conteneur | aucune | Les `Dockerfile` sont construits en local, pas analysés |
|
||||
|
||||
@@ -38,6 +38,7 @@ lecture seule ; plusieurs lignes resteront à compléter une fois les endpoints
|
||||
| Caviardage des jetons, empreintes, mots de passe et cookies dans les journaux | `app/core/logging.py` | A09, A02 |
|
||||
| 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 |
|
||||
| Scan dynamique OWASP ZAP de l'API authentifiée (compte `lecteur` jetable), non bloquant, configuration par défaut du backend uniquement (ni TLS ni en-têtes du reverse proxy) | `.github/workflows/dast.yml`, `scripts/dast-token.sh` | A05, API8 Security Misconfiguration |
|
||||
| En-têtes `nosniff`, `DENY`, `no-referrer`, et `no-store` sur les routes d'authentification | `app/api/middleware.py` | A05 |
|
||||
| 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 |
|
||||
|
||||
+15
-9
@@ -663,16 +663,22 @@ mock_api_import.py
|
||||
|
||||
La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP.
|
||||
|
||||
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`. L'orchestration de l'import API Mock et la réconciliation globale des deux
|
||||
sources restent couvertes par l'issue #15.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""DAG d'import périodique des données de l'API Mock EnerVision (issue #15).
|
||||
|
||||
Orchestre le pipeline existant `app.etl.mock_api_import` sans dupliquer sa logique ETL.
|
||||
Chaque exécution traite l'heure précédant son déclenchement.
|
||||
|
||||
Le pipeline backend reste responsable de la validation, de la normalisation, du suivi de la
|
||||
qualité, de l'idempotence et du chargement dans PostgreSQL/TimescaleDB.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from airflow.providers.standard.operators.bash import BashOperator
|
||||
from airflow.sdk import DAG
|
||||
from airflow.timetables.trigger import CronTriggerTimetable
|
||||
|
||||
# Le backend possède son propre environnement uv dans l'image Airflow (ADR 0008).
|
||||
COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m"
|
||||
|
||||
# Le pipeline backend et l'API acceptent au maximum 1 000 lectures par site.
|
||||
# Cette marge évite de perdre silencieusement une lecture si une heure en contient plus de 60.
|
||||
LIMITE_LECTURES = 1000
|
||||
|
||||
# Deux reprises donnent trois tentatives au total. Même dans le pire cas, l'exécution reste
|
||||
# inférieure au pas horaire du DAG.
|
||||
NOMBRE_REPRISES = 2
|
||||
DELAI_ENTRE_REPRISES = timedelta(minutes=2)
|
||||
PLAFOND_PAR_TENTATIVE = timedelta(minutes=10)
|
||||
|
||||
# L'intervalle est déclaré explicitement pour ne pas dépendre de la valeur du paramètre Airflow
|
||||
# `create_cron_data_intervals`. Le déclenchement à :45 laisse quinze minutes avant `ml_score`,
|
||||
# exécuté à l'heure pile, puis avant `alertes`, exécuté à :15.
|
||||
PLANIFICATION = CronTriggerTimetable(
|
||||
"45 * * * *",
|
||||
timezone="UTC",
|
||||
interval=timedelta(hours=1),
|
||||
)
|
||||
|
||||
with DAG(
|
||||
dag_id="mock_api_import",
|
||||
description="Importe chaque heure les données de l'API Mock dans site et reading.",
|
||||
schedule=PLANIFICATION,
|
||||
start_date=datetime(2026, 1, 1),
|
||||
catchup=False,
|
||||
# Deux exécutions simultanées pourraient demander et traiter le même intervalle.
|
||||
max_active_runs=1,
|
||||
tags=["etl", "mock-api"],
|
||||
) as dag:
|
||||
BashOperator(
|
||||
task_id="import_mock_api",
|
||||
bash_command=(
|
||||
f"{COMMANDE_BACKEND} app.etl.mock_api_import "
|
||||
"--start-time \"{{ data_interval_start.strftime('%Y-%m-%dT%H:%M:%S') }}\" "
|
||||
"--end-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:%M:%S') }}\" "
|
||||
f"--limit {LIMITE_LECTURES}"
|
||||
),
|
||||
retries=NOMBRE_REPRISES,
|
||||
retry_delay=DELAI_ENTRE_REPRISES,
|
||||
execution_timeout=PLAFOND_PAR_TENTATIVE,
|
||||
)
|
||||
@@ -1,22 +1,31 @@
|
||||
"""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"
|
||||
|
||||
DAG_IDS = ["ml_train", "ml_score", "alertes", "historical_import", "derive"]
|
||||
DAG_IDS = [
|
||||
"ml_train",
|
||||
"ml_score",
|
||||
"alertes",
|
||||
"historical_import",
|
||||
"mock_api_import",
|
||||
"derive",
|
||||
]
|
||||
TACHES = [
|
||||
("ml_train", "train"),
|
||||
("ml_score", "score"),
|
||||
("alertes", "detection"),
|
||||
("alertes", "recommandations"),
|
||||
("historical_import", "import_historical"),
|
||||
("mock_api_import", "import_mock_api"),
|
||||
("derive", "derive"),
|
||||
]
|
||||
|
||||
@@ -53,6 +62,19 @@ def test_historical_import_has_no_schedule(dagbag: DagBag) -> None:
|
||||
assert dagbag.dags["historical_import"].schedule is None
|
||||
|
||||
|
||||
def test_mock_api_import_uses_an_explicit_hourly_interval(dagbag: DagBag) -> None:
|
||||
timetable = dagbag.dags["mock_api_import"].timetable
|
||||
|
||||
assert isinstance(timetable, CronTriggerTimetable)
|
||||
assert timetable.serialize()["expression"] == "45 * * * *"
|
||||
|
||||
manual_interval = timetable.infer_manual_data_interval(
|
||||
run_after=datetime.fromisoformat("2026-09-22T12:30:00+00:00"),
|
||||
)
|
||||
|
||||
assert manual_interval.end - manual_interval.start == timedelta(hours=1)
|
||||
|
||||
|
||||
def test_ml_train_task_calls_the_training_module(dagbag: DagBag) -> None:
|
||||
tache = dagbag.dags["ml_train"].get_task("train")
|
||||
assert "enervision_ml.train" in tache.bash_command
|
||||
@@ -85,6 +107,20 @@ def test_historical_import_uses_the_expected_source_files(dagbag: DagBag) -> Non
|
||||
assert "--metadata /opt/data/raw/dataset_metadata.json" in commande
|
||||
|
||||
|
||||
def test_mock_api_import_calls_the_existing_backend_module(dagbag: DagBag) -> None:
|
||||
commande = dagbag.dags["mock_api_import"].get_task("import_mock_api").bash_command
|
||||
|
||||
assert "app.etl.mock_api_import" in commande
|
||||
|
||||
|
||||
def test_mock_api_import_uses_the_airflow_data_interval(dagbag: DagBag) -> None:
|
||||
commande = dagbag.dags["mock_api_import"].get_task("import_mock_api").bash_command
|
||||
|
||||
assert "--start-time \"{{ data_interval_start.strftime('%Y-%m-%dT%H:%M:%S') }}\"" in commande
|
||||
assert "--end-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:%M:%S') }}\"" in commande
|
||||
assert "--limit 1000" in commande
|
||||
|
||||
|
||||
@pytest.mark.parametrize("task_id", ["detection", "recommandations"])
|
||||
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).
|
||||
@@ -96,6 +132,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.
|
||||
@@ -137,6 +179,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.
|
||||
@@ -160,6 +210,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
|
||||
|
||||
|
||||
def test_derive_runs_once_a_day(dagbag: DagBag) -> None:
|
||||
assert dagbag.dags["derive"].timetable.expression == "30 5 * * *"
|
||||
|
||||
|
||||
+42
-2
@@ -1,11 +1,31 @@
|
||||
# This file is maintained automatically by "terraform init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/hashicorp/archive" {
|
||||
version = "2.8.1"
|
||||
constraints = "~> 2.4"
|
||||
hashes = [
|
||||
"h1:eehhIUcuegkswQDKArYBAhVV9wQmRVMhyYGaD7kHIj0=",
|
||||
"zh:03de290604114a89fcd45c2e5bc7787d5a1ebfc5f964fb5989306bea7a4c79ec",
|
||||
"zh:0a7d69dc9fbbc48960bc2f04588c8fb1bd92c78a8f306566b7fb17fc4a4f2058",
|
||||
"zh:4df1f3981379c35f1757da957470f7f7724496b57219da3485177cf1647bdf59",
|
||||
"zh:50f0e72ba53bfe6e11b03b7fc899e1f72536354381d302a743a274ae2a3f45f4",
|
||||
"zh:5c4e15a04c98e2a8cafb1cd9632b48ad318051e8462d105b1153006277985c35",
|
||||
"zh:66069e604bcf5c4af0278e15997d9e6bd755c54fb3801d78885838b889729c5f",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:979765db3f42601870ab377104ba70befb029547b278337ec4ada980e3582de6",
|
||||
"zh:b165254da774f49945a73fbccc3ef1b63d70ea00a98fa9e14716665ba80ecaad",
|
||||
"zh:c0bb2697b525da9fec4511f569ed2bd2b42f25d5c1f9fa00f8f3645b50cfc2e9",
|
||||
"zh:c48f6695d12df0d0fa5231f7c1b8d518a4feae91a733fdd35a5804af3a303831",
|
||||
"zh:d589954c93f075180c9f4e2ce91780d5edcb56dfd0d3cda8ba08e13c10b52249",
|
||||
"zh:f5792ed06da65d0daf7ca3711f5399ff78c7cb4400afe53fbce9a926fbde4477",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/null" {
|
||||
version = "3.3.2"
|
||||
constraints = "~> 3.2"
|
||||
hashes = [
|
||||
"h1:IQ1qrkht1sC1nibUR+AJ3ulryyhVDHfCHZhoJi0sg2Y=",
|
||||
"h1:7rn0+p+fbrHfJxNVgTnlKp6C3yu0jENMXffz9Xb9XjQ=",
|
||||
"zh:10ec43b8b7b18d5639238c7fb9e111f6a4b038523dd66c7a426bf27b25fa4c08",
|
||||
"zh:60beb9cc2ad5b871c710860cee75b42850cc6acd43db0d77cb5e00fda7288b55",
|
||||
"zh:62538582d0a4a2f10ad8a8d9a6c3cd3f05af6c6d91c6641ffc78d4f0e8e69b27",
|
||||
@@ -21,3 +41,23 @@ provider "registry.terraform.io/hashicorp/null" {
|
||||
"zh:faa01928c25d2a6ecd9c7eb8b88134cb08de55a6b11ca6c703ac0092845344ba",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/kreuzwerker/docker" {
|
||||
version = "4.5.0"
|
||||
constraints = "~> 4.5.0"
|
||||
hashes = [
|
||||
"h1:eXPMxNcZjz5gC7Nc1oEPJldpI6B5btbvldjLOgJQNbI=",
|
||||
"zh:0ee4e9121632158a9ae1049bdd53c71fd9e06357de915dd193df2c1c55ae8814",
|
||||
"zh:12c6991012b5a548a406e1960c323484f95a9538c2522af15ec82f47fdb832d1",
|
||||
"zh:19c86ae59ed1e062e8402a49ea842f957fcda2f9689e06ecbd9480cc9bd1e41a",
|
||||
"zh:3ac8bc9805acd20c40c0ed474c9f4605aa73b61cf52809e1131e335fcbfe8e49",
|
||||
"zh:5a0e70d143759712fdda109f024a41d59d4758b5615378aa21ee6b959f65a416",
|
||||
"zh:5d3599285f71cc53c88a5802fa6e2f8ba87b9deea03abe5fe8bb2639e9d95654",
|
||||
"zh:a42f573a2526cddc9b8a93637a2c5478bc6e903ff29556ab8b2f1467681d5bde",
|
||||
"zh:c8de50d902cc8457e565a5d959f1e3e49064d31c2f14f7adf5f3ec632cfe3695",
|
||||
"zh:d40543d51eb4e902125d2c5a8a0f425a60edcec6c1dc07c4a89d92335ec05541",
|
||||
"zh:dd3105018ffddc8114083dcf84798c32fdecca7ef708eceaee49eb4b306fc061",
|
||||
"zh:e51e0199700698b962085683af4551d1f982dce37f3b6950da062c65e0145505",
|
||||
"zh:ffe415d3d3deffffdbd3a7173e3a9f90b74601cb6d8acafec1ab701a5b91dbff",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
locals {
|
||||
backend_dir = "../../../../apps/backend"
|
||||
backend_image_name = "enervision-back"
|
||||
backend_image_tag = "latest"
|
||||
backend_container_name = "enervision-backend"
|
||||
}
|
||||
|
||||
resource "docker_image" "backend" {
|
||||
name = "${local.backend_image_name}:${local.backend_image_tag}"
|
||||
|
||||
build {
|
||||
context = local.backend_dir
|
||||
dockerfile = "Dockerfile"
|
||||
}
|
||||
|
||||
triggers = {
|
||||
build_hash = sha256(join("", [
|
||||
for file in fileset(local.backend_dir, "**") :
|
||||
filesha256("${local.backend_dir}/${file}")
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
resource "docker_container" "backend" {
|
||||
image = docker_image.backend.image_id
|
||||
name = local.backend_container_name
|
||||
|
||||
ports {
|
||||
internal = 8000
|
||||
external = var.backend_port
|
||||
}
|
||||
|
||||
volumes {
|
||||
host_path = "/var/log/enervision"
|
||||
container_path = "/var/log/enervision"
|
||||
}
|
||||
|
||||
depends_on = [docker_image.backend]
|
||||
}
|
||||
|
||||
resource "null_resource" "deploy_backend_to_server" {
|
||||
depends_on = [docker_image.backend]
|
||||
|
||||
triggers = {
|
||||
image_id = docker_image.backend.image_id
|
||||
}
|
||||
|
||||
connection {
|
||||
type = "ssh"
|
||||
host = var.ssh_host
|
||||
port = var.ssh_port
|
||||
user = var.ssh_user
|
||||
password = var.ssh_password
|
||||
private_key = try(file(pathexpand(var.ssh_private_key_path)), null)
|
||||
timeout = "5m"
|
||||
}
|
||||
|
||||
provisioner "file" {
|
||||
source = "${local.backend_dir}/Dockerfile"
|
||||
destination = "/tmp/Dockerfile"
|
||||
}
|
||||
provisioner "file" {
|
||||
source = "${local.backend_dir}/"
|
||||
destination = "/tmp/backend/"
|
||||
}
|
||||
|
||||
provisioner "remote-exec" {
|
||||
inline = [
|
||||
"cd /tmp/backend",
|
||||
"sudo docker stop ${local.backend_container_name} 2>/dev/null || true",
|
||||
"sudo docker rm ${local.backend_container_name} 2>/dev/null || true",
|
||||
"sudo docker build -t ${local.backend_image_name}:${local.backend_image_tag} .",
|
||||
"sudo docker run -d --name ${local.backend_container_name} -p ${var.backend_port}:8000 --restart always -v /var/log/enervision:/var/log/enervision ${local.backend_image_name}:${local.backend_image_tag}"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
locals {
|
||||
frontend_environments = {
|
||||
dev = {
|
||||
source_dir = "${path.root}/../../../../apps/frontend/dist/frontend/browser"
|
||||
domain = "dev.enervision"
|
||||
}
|
||||
}
|
||||
|
||||
frontend_dir = "../../../../apps/frontend"
|
||||
image_name = "enervision-front"
|
||||
image_tag = "latest"
|
||||
container_name = "enervision-frontend"
|
||||
|
||||
selected_frontend = local.frontend_environments[var.deployment_environment]
|
||||
}
|
||||
|
||||
provider "docker" {
|
||||
host = var.docker_host
|
||||
}
|
||||
|
||||
# Build le front
|
||||
resource "null_resource" "build_frontend" {
|
||||
triggers = {
|
||||
frontend_files = sha256(join("", [
|
||||
for file in fileset(local.frontend_dir, "src/**,package.json") :
|
||||
filesha256("${local.frontend_dir}/${file}")
|
||||
]))
|
||||
}
|
||||
|
||||
provisioner "local-exec" {
|
||||
command = "cd ${local.frontend_dir} && npm ci && chmod -R +x node_modules/@angular/cli/bin && chmod -R +x node_modules/.bin && npm exec ng build"
|
||||
}
|
||||
}
|
||||
|
||||
# Build l'image docker du front
|
||||
resource "docker_image" "frontend" {
|
||||
name = "${local.image_name}:${local.image_tag}"
|
||||
|
||||
build {
|
||||
context = local.frontend_dir
|
||||
dockerfile = "Dockerfile"
|
||||
}
|
||||
|
||||
triggers = {
|
||||
build_hash = sha256(join("", [
|
||||
for file in fileset(local.frontend_dir, "**") :
|
||||
filesha256("${local.frontend_dir}/${file}")
|
||||
]))
|
||||
}
|
||||
|
||||
depends_on = [null_resource.build_frontend]
|
||||
}
|
||||
|
||||
# Création du container Docker
|
||||
resource "docker_container" "frontend" {
|
||||
image = docker_image.frontend.image_id
|
||||
name = local.container_name
|
||||
|
||||
ports {
|
||||
internal = 3000
|
||||
external = var.frontend_port
|
||||
}
|
||||
|
||||
# Volume pour les logs
|
||||
volumes {
|
||||
host_path = "/var/log/enervision"
|
||||
container_path = "/var/log/nginx"
|
||||
}
|
||||
|
||||
depends_on = [docker_image.frontend]
|
||||
}
|
||||
|
||||
resource "null_resource" "create_network" {
|
||||
provisioner "remote-exec" {
|
||||
connection {
|
||||
type = "ssh"
|
||||
host = var.ssh_host
|
||||
port = var.ssh_port
|
||||
user = var.ssh_user
|
||||
password = var.ssh_password
|
||||
private_key = try(file(pathexpand(var.ssh_private_key_path)), null)
|
||||
timeout = "5m"
|
||||
}
|
||||
|
||||
inline = [
|
||||
"sudo docker network create enervision_default 2>/dev/null || true"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
# Déploiement du container sur le serveur via SSH
|
||||
resource "null_resource" "deploy_to_server" {
|
||||
depends_on = [docker_image.frontend]
|
||||
|
||||
triggers = {
|
||||
image_id = docker_image.frontend.image_id
|
||||
}
|
||||
|
||||
connection {
|
||||
type = "ssh"
|
||||
host = var.ssh_host
|
||||
port = var.ssh_port
|
||||
user = var.ssh_user
|
||||
password = var.ssh_password
|
||||
private_key = try(file(pathexpand(var.ssh_private_key_path)), null)
|
||||
timeout = "5m"
|
||||
}
|
||||
|
||||
# Copie le Dockerfile et nginx.conf
|
||||
provisioner "file" {
|
||||
source = "${local.frontend_dir}/Dockerfile"
|
||||
destination = "/tmp/Dockerfile"
|
||||
}
|
||||
|
||||
provisioner "file" {
|
||||
source = "${local.frontend_dir}/nginx.conf"
|
||||
destination = "/tmp/nginx.conf"
|
||||
}
|
||||
|
||||
# Copie les sources pour le build
|
||||
provisioner "file" {
|
||||
source = "${local.frontend_dir}/"
|
||||
destination = "/tmp/frontend/"
|
||||
}
|
||||
|
||||
# Build et lance le container sur le serveur
|
||||
provisioner "remote-exec" {
|
||||
inline = [
|
||||
"cd /tmp/frontend",
|
||||
"sudo docker stop ${local.container_name} 2>/dev/null || true",
|
||||
"sudo docker rm ${local.container_name} 2>/dev/null || true",
|
||||
"sudo docker build -t ${local.image_name}:${local.image_tag} .",
|
||||
"sudo docker run -d \\",
|
||||
" --name ${local.container_name} \\",
|
||||
" --network enervision_default \\",
|
||||
" --network-alias frontend \\",
|
||||
" -p ${var.frontend_port}:3000 \\",
|
||||
" --restart always \\",
|
||||
" -v /var/log/enervision:/var/log/nginx \\",
|
||||
" ${local.image_name}:${local.image_tag}"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,24 @@ module "k3s" {
|
||||
ssh_port = var.ssh_port
|
||||
ssh_user = var.ssh_user
|
||||
ssh_private_key_path = var.ssh_private_key_path
|
||||
ssh_password = var.ssh_password
|
||||
k3s_version = var.k3s_version
|
||||
k3s_disable_components = var.k3s_disable_components
|
||||
kubeconfig_output_path = var.kubeconfig_output_path
|
||||
}
|
||||
|
||||
provider "vault" {
|
||||
# Adresse du serveur Vault (dev)
|
||||
address = "https://10.101.200.37:8200"
|
||||
|
||||
# Auth par userpass
|
||||
auth_login {
|
||||
path = "auth/userpass/login/${var.vault_username}"
|
||||
parameters = {
|
||||
login = var.vault_username
|
||||
password = var.vault_password
|
||||
}
|
||||
}
|
||||
# Skip verify pour dev uniquement (pas en prod!)
|
||||
# skip_client_verification = true
|
||||
}
|
||||
@@ -21,6 +21,35 @@ variable "ssh_private_key_path" {
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "ssh_password" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "remote_path" {
|
||||
type = string
|
||||
description = "Chemin distant sur le serveur pour le deploiement."
|
||||
default = "/var/www/enervision"
|
||||
}
|
||||
|
||||
variable "docker_host" {
|
||||
type = string
|
||||
default = "npipe:////.//pipe//docker_engine"
|
||||
description = "Host Docker (local ou distant)"
|
||||
}
|
||||
|
||||
variable "frontend_port" {
|
||||
type = number
|
||||
default = 3000
|
||||
description = "Port externe du frontend"
|
||||
}
|
||||
|
||||
variable "backend_port" {
|
||||
type = number
|
||||
default = 8000
|
||||
description = "Port externe du backend"
|
||||
}
|
||||
|
||||
variable "k3s_version" {
|
||||
type = string
|
||||
description = "Version k3s a epingler pour un deploiement reproductible (ex: v1.31.5+k3s1). Voir https://github.com/k3s-io/k3s/releases."
|
||||
@@ -37,3 +66,25 @@ variable "kubeconfig_output_path" {
|
||||
description = "Chemin local ou ecrire le kubeconfig recupere apres installation."
|
||||
default = "./kubeconfig"
|
||||
}
|
||||
|
||||
variable "deployment_environment" {
|
||||
type = string
|
||||
default = "dev"
|
||||
|
||||
validation {
|
||||
condition = contains(["dev", "rec", "prod"], var.deployment_environment)
|
||||
error_message = "L'environnement doit être dev, rec ou prod."
|
||||
}
|
||||
}
|
||||
|
||||
variable "vault_username" {
|
||||
description = "Username Vault"
|
||||
type = string
|
||||
sensitive = false
|
||||
}
|
||||
|
||||
variable "vault_password" {
|
||||
description = "Password Vault"
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
locals {
|
||||
frontend_environments = {
|
||||
prod = {
|
||||
source_dir = "${path.root}/../../../apps/frontend/dist/frontend/browser"
|
||||
domain = "enervision"
|
||||
}
|
||||
}
|
||||
|
||||
frontend_dir = "../../../../apps/frontend"
|
||||
image_name = "enervision-front"
|
||||
image_tag = "latest"
|
||||
container_name = "enervision-frontend"
|
||||
|
||||
selected_frontend = local.frontend_environments[var.deployment_environment]
|
||||
}
|
||||
|
||||
provider "docker" {
|
||||
host = var.docker_host
|
||||
}
|
||||
|
||||
# Build le front
|
||||
resource "null_resource" "build_frontend" {
|
||||
triggers = {
|
||||
frontend_files = sha256(join("", [
|
||||
for file in fileset(local.frontend_dir, "src/**,package.json") :
|
||||
filesha256("${local.frontend_dir}/${file}")
|
||||
]))
|
||||
}
|
||||
|
||||
provisioner "local-exec" {
|
||||
command = "cd ${local.frontend_dir} && npm ci && chmod -R +x node_modules/@angular/cli/bin && chmod -R +x node_modules/.bin && npm exec ng build"
|
||||
}
|
||||
}
|
||||
|
||||
# Build l'image docker du front
|
||||
resource "docker_image" "frontend" {
|
||||
name = "${local.image_name}:${local.image_tag}"
|
||||
|
||||
build {
|
||||
context = local.frontend_dir
|
||||
dockerfile = "Dockerfile"
|
||||
}
|
||||
|
||||
triggers = {
|
||||
build_hash = sha256(join("", [
|
||||
for file in fileset(local.frontend_dir, "**") :
|
||||
filesha256("${local.frontend_dir}/${file}")
|
||||
]))
|
||||
}
|
||||
|
||||
depends_on = [null_resource.build_frontend]
|
||||
}
|
||||
|
||||
# Création du container Docker
|
||||
resource "docker_container" "frontend" {
|
||||
image = docker_image.frontend.image_id
|
||||
name = local.container_name
|
||||
|
||||
ports {
|
||||
internal = 3000
|
||||
external = var.frontend_port
|
||||
}
|
||||
|
||||
# Volume pour les logs
|
||||
volumes {
|
||||
host_path = "/var/log/enervision"
|
||||
container_path = "/var/log/nginx"
|
||||
}
|
||||
|
||||
depends_on = [docker_image.frontend]
|
||||
}
|
||||
|
||||
# Déploiement du container sur le serveur via SSH
|
||||
resource "null_resource" "deploy_to_server" {
|
||||
depends_on = [docker_image.frontend]
|
||||
|
||||
triggers = {
|
||||
image_id = docker_image.frontend.image_id
|
||||
}
|
||||
|
||||
connection {
|
||||
type = "ssh"
|
||||
host = var.ssh_host
|
||||
port = var.ssh_port
|
||||
user = var.ssh_user
|
||||
password = var.ssh_password
|
||||
private_key = try(file(pathexpand(var.ssh_private_key_path)), null)
|
||||
timeout = "5m"
|
||||
}
|
||||
|
||||
# Copie le Dockerfile et nginx.conf
|
||||
provisioner "file" {
|
||||
source = "${local.frontend_dir}/Dockerfile"
|
||||
destination = "/tmp/Dockerfile"
|
||||
}
|
||||
|
||||
provisioner "file" {
|
||||
source = "${local.frontend_dir}/nginx.conf"
|
||||
destination = "/tmp/nginx.conf"
|
||||
}
|
||||
|
||||
# Copie les sources pour le build
|
||||
provisioner "file" {
|
||||
source = "${local.frontend_dir}/"
|
||||
destination = "/tmp/frontend/"
|
||||
}
|
||||
|
||||
# Build et lance le container sur le serveur
|
||||
provisioner "remote-exec" {
|
||||
inline = [
|
||||
"cd /tmp/frontend",
|
||||
"sudo docker stop ${local.container_name} 2>/dev/null || true",
|
||||
"sudo docker rm ${local.container_name} 2>/dev/null || true",
|
||||
"sudo docker build -t ${local.image_name}:${local.image_tag} .",
|
||||
"sudo docker run -d \\",
|
||||
" --name ${local.container_name} \\",
|
||||
" --network enervision_default \\",
|
||||
" --network-alias frontend \\",
|
||||
" -p ${var.frontend_port}:3000 \\",
|
||||
" --restart always \\",
|
||||
" -v /var/log/enervision:/var/log/nginx \\",
|
||||
" ${local.image_name}:${local.image_tag}"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
module "k3s" {
|
||||
source = "../../modules/k3s"
|
||||
|
||||
ssh_host = var.ssh_host
|
||||
ssh_port = var.ssh_port
|
||||
ssh_user = var.ssh_user
|
||||
ssh_private_key_path = var.ssh_private_key_path
|
||||
k3s_version = var.k3s_version
|
||||
k3s_disable_components = var.k3s_disable_components
|
||||
kubeconfig_output_path = var.kubeconfig_output_path
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
output "kubeconfig_path" {
|
||||
description = "Chemin local du kubeconfig recupere apres installation."
|
||||
value = module.k3s.kubeconfig_path
|
||||
}
|
||||
|
||||
output "node_host" {
|
||||
description = "Adresse du serveur sur lequel k3s est installe."
|
||||
value = module.k3s.node_host
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
variable "ssh_host" {
|
||||
type = string
|
||||
description = "Adresse IP ou nom d'hote du serveur on-premise de l'ecole."
|
||||
}
|
||||
|
||||
variable "ssh_port" {
|
||||
type = number
|
||||
description = "Port SSH du serveur."
|
||||
default = 22
|
||||
}
|
||||
|
||||
variable "ssh_user" {
|
||||
type = string
|
||||
description = "Utilisateur SSH utilise pour l'installation."
|
||||
default = "root"
|
||||
}
|
||||
|
||||
variable "ssh_private_key_path" {
|
||||
type = string
|
||||
description = "Chemin local vers la cle privee SSH."
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "k3s_version" {
|
||||
type = string
|
||||
description = "Version k3s a epingler pour un deploiement reproductible (ex: v1.31.5+k3s1). Voir https://github.com/k3s-io/k3s/releases."
|
||||
}
|
||||
|
||||
variable "k3s_disable_components" {
|
||||
type = list(string)
|
||||
description = "Composants embarques k3s a desactiver."
|
||||
default = ["traefik"]
|
||||
}
|
||||
|
||||
variable "kubeconfig_output_path" {
|
||||
type = string
|
||||
description = "Chemin local ou ecrire le kubeconfig recupere apres installation."
|
||||
default = "./kubeconfig"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
terraform {
|
||||
required_version = ">= 1.7"
|
||||
|
||||
required_providers {
|
||||
null = {
|
||||
source = "hashicorp/null"
|
||||
version = "~> 3.2"
|
||||
}
|
||||
}
|
||||
|
||||
backend "local" {
|
||||
path = "terraform.tfstate"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
locals {
|
||||
frontend_environments = {
|
||||
rec = {
|
||||
source_dir = "${path.root}/../../../apps/frontend/dist/frontend-rec/browser"
|
||||
domain = "rec.enervision"
|
||||
}
|
||||
}
|
||||
|
||||
selected_frontend = local.frontend_environments[var.deployment_environment]
|
||||
}
|
||||
|
||||
resource "null_resource" "frontend" {
|
||||
depends_on = [module.k3s]
|
||||
|
||||
triggers = {
|
||||
environment = var.deployment_environment
|
||||
|
||||
build_hash = sha256(join("", [
|
||||
for file in fileset("${path.root}/../../../apps/frontend/dist/frontend/browser", "**") :
|
||||
filesha256("${path.root}/../../../apps/frontend/dist/frontend/browser/${file}")
|
||||
]))
|
||||
}
|
||||
|
||||
// Variables pour la connexion SSH
|
||||
connection {
|
||||
type = "ssh"
|
||||
host = var.ssh_host
|
||||
port = var.ssh_port
|
||||
user = var.ssh_user
|
||||
private_key = file(pathexpand(var.ssh_private_key_path))
|
||||
}
|
||||
|
||||
// Lancement de script en SSH avec remote-exec
|
||||
// Installation de Nginx et initialisation du répertoire du frontend
|
||||
provisioner "remote-exec" {
|
||||
inline = [
|
||||
"sudo apt-get update",
|
||||
"sudo apt-get install -y nginx",
|
||||
"sudo mkdir -p /var/www/enervision",
|
||||
"sudo rm -rf /var/www/enervision/*"
|
||||
]
|
||||
}
|
||||
|
||||
// Copie des fichiers vers le serveur
|
||||
provisioner "file" {
|
||||
source = "${path.root}/../../../apps/frontend/dist/frontend/browser/"
|
||||
destination = "/tmp/enervision-frontend"
|
||||
}
|
||||
|
||||
// Déplacement des fichiers
|
||||
provisioner "remote-exec" {
|
||||
inline = [
|
||||
"sudo cp -r /tmp/enervision-frontend/* /var/www/enervision/",
|
||||
"sudo chown -R www-data:www-data /var/www/enervision"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
module "k3s" {
|
||||
source = "../../modules/k3s"
|
||||
|
||||
ssh_host = var.ssh_host
|
||||
ssh_port = var.ssh_port
|
||||
ssh_user = var.ssh_user
|
||||
ssh_private_key_path = var.ssh_private_key_path
|
||||
k3s_version = var.k3s_version
|
||||
k3s_disable_components = var.k3s_disable_components
|
||||
kubeconfig_output_path = var.kubeconfig_output_path
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
output "kubeconfig_path" {
|
||||
description = "Chemin local du kubeconfig recupere apres installation."
|
||||
value = module.k3s.kubeconfig_path
|
||||
}
|
||||
|
||||
output "node_host" {
|
||||
description = "Adresse du serveur sur lequel k3s est installe."
|
||||
value = module.k3s.node_host
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
variable "ssh_host" {
|
||||
type = string
|
||||
description = "Adresse IP ou nom d'hote du serveur on-premise de l'ecole."
|
||||
}
|
||||
|
||||
variable "ssh_port" {
|
||||
type = number
|
||||
description = "Port SSH du serveur."
|
||||
default = 22
|
||||
}
|
||||
|
||||
variable "ssh_user" {
|
||||
type = string
|
||||
description = "Utilisateur SSH utilise pour l'installation."
|
||||
default = "root"
|
||||
}
|
||||
|
||||
variable "ssh_private_key_path" {
|
||||
type = string
|
||||
description = "Chemin local vers la cle privee SSH."
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "k3s_version" {
|
||||
type = string
|
||||
description = "Version k3s a epingler pour un deploiement reproductible (ex: v1.31.5+k3s1). Voir https://github.com/k3s-io/k3s/releases."
|
||||
}
|
||||
|
||||
variable "k3s_disable_components" {
|
||||
type = list(string)
|
||||
description = "Composants embarques k3s a desactiver."
|
||||
default = ["traefik"]
|
||||
}
|
||||
|
||||
variable "kubeconfig_output_path" {
|
||||
type = string
|
||||
description = "Chemin local ou ecrire le kubeconfig recupere apres installation."
|
||||
default = "./kubeconfig"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
terraform {
|
||||
required_version = ">= 1.7"
|
||||
|
||||
required_providers {
|
||||
null = {
|
||||
source = "hashicorp/null"
|
||||
version = "~> 3.2"
|
||||
}
|
||||
}
|
||||
|
||||
backend "local" {
|
||||
path = "terraform.tfstate"
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,15 @@ terraform {
|
||||
required_version = ">= 1.7"
|
||||
|
||||
required_providers {
|
||||
null = {
|
||||
source = "hashicorp/null"
|
||||
version = "~> 3.2"
|
||||
archive = {
|
||||
source = "hashicorp/archive"
|
||||
version = "~> 2.4"
|
||||
}
|
||||
|
||||
docker = {
|
||||
source = "kreuzwerker/docker"
|
||||
version = "~> 4.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "local" {
|
||||
path = "terraform.tfstate"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ locals {
|
||||
# l'adresse, le port, l'utilisateur et le chemin de la cle : jamais la cle ni un mot de passe.
|
||||
resource "null_resource" "k3s_install" {
|
||||
triggers = {
|
||||
ssh_host = var.ssh_host
|
||||
ssh_port = tostring(var.ssh_port)
|
||||
ssh_user = var.ssh_user
|
||||
ssh_password = var.ssh_password
|
||||
k3s_version = var.k3s_version
|
||||
disable_components = join(",", var.k3s_disable_components)
|
||||
sudo_prefix = local.sudo_prefix
|
||||
ssh_host = var.ssh_host
|
||||
ssh_port = tostring(var.ssh_port)
|
||||
ssh_user = var.ssh_user
|
||||
@@ -22,9 +29,9 @@ resource "null_resource" "k3s_install" {
|
||||
connection {
|
||||
type = "ssh"
|
||||
host = self.triggers.ssh_host
|
||||
port = tonumber(self.triggers.ssh_port)
|
||||
port = self.triggers.ssh_port
|
||||
user = self.triggers.ssh_user
|
||||
private_key = file(pathexpand(self.triggers.ssh_key_path))
|
||||
password = self.triggers.ssh_password
|
||||
}
|
||||
|
||||
provisioner "remote-exec" {
|
||||
@@ -53,10 +60,13 @@ resource "null_resource" "fetch_kubeconfig" {
|
||||
}
|
||||
|
||||
provisioner "local-exec" {
|
||||
interpreter = ["bash", "-c"]
|
||||
interpreter = ["PowerShell", "-NoProfile", "-Command"]
|
||||
|
||||
command = <<-EOT
|
||||
ssh -i "${var.ssh_private_key_path}" -p ${var.ssh_port} -o StrictHostKeyChecking=accept-new ${var.ssh_user}@${var.ssh_host} '${local.kubeconfig_cmd}' \
|
||||
| sed 's/127.0.0.1/${var.ssh_host}/' > "${var.kubeconfig_output_path}"
|
||||
$content = ssh -i "${pathexpand(var.ssh_private_key_path)}" -p ${var.ssh_port} -o StrictHostKeyChecking=accept-new ${var.ssh_user}@${var.ssh_host} "cat /etc/rancher/k3s/k3s.yaml"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
$content -replace "127.0.0.1", "${var.ssh_host}" |
|
||||
Set-Content -Path "${var.kubeconfig_output_path}" -Encoding UTF8
|
||||
EOT
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ variable "ssh_private_key_path" {
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "ssh_password" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "k3s_version" {
|
||||
type = string
|
||||
description = "Version k3s a epingler pour un deploiement reproductible (ex: v1.31.5+k3s1). Voir https://github.com/k3s-io/k3s/releases."
|
||||
|
||||
@@ -2,9 +2,9 @@ terraform {
|
||||
required_version = ">= 1.7"
|
||||
|
||||
required_providers {
|
||||
null = {
|
||||
source = "hashicorp/null"
|
||||
version = "~> 3.2"
|
||||
provider = {
|
||||
source = "hashicorp/archive"
|
||||
version = "~> 2.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.venv
|
||||
data
|
||||
mlruns
|
||||
mlflow.db*
|
||||
models
|
||||
.env
|
||||
@@ -0,0 +1 @@
|
||||
MLFLOW_DB_PASSWORD=change-me
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM python:3.14-slim
|
||||
RUN pip install --no-cache-dir --only-binary :all: mlflow==3.16.1 psycopg2-binary==2.9.13
|
||||
RUN useradd --create-home --uid 1000 mlflow \
|
||||
&& mkdir /mlartifacts \
|
||||
&& chown mlflow /mlartifacts
|
||||
USER mlflow
|
||||
EXPOSE 5000
|
||||
@@ -57,6 +57,50 @@ validation. La coupure est **chronologique**, jamais un tirage aleatoire de lign
|
||||
aleatoire laisserait des lignes de validation "voir" des lignes d'entrainement via leurs
|
||||
lags/moyennes glissantes, une fuite qui masquerait un surapprentissage.
|
||||
|
||||
## Serveur MLflow (conteneur)
|
||||
|
||||
Premiere utilisation : copier `.env.example` en `.env` et y choisir un mot de passe PostgreSQL
|
||||
(lettres et chiffres uniquement). Le fichier `.env` est ignore par git.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Un serveur MLflow (PostgreSQL pour les metadonnees, volume pour les artefacts) se lance avec
|
||||
Docker. Prerequis : Docker Desktop demarre.
|
||||
|
||||
```bash
|
||||
make mlflow-up
|
||||
```
|
||||
|
||||
La cible vérifie que `MLFLOW_DB_PASSWORD` (définie dans `ml/.env`) ne contient que des lettres et
|
||||
des chiffres avant de démarrer le serveur : ce mot de passe est interpolé directement dans l'URI
|
||||
PostgreSQL (`postgresql://mlflow:${MLFLOW_DB_PASSWORD}@...`), un caractère spécial la rendrait
|
||||
invalide sans message d'erreur clair.
|
||||
|
||||
Interface : http://localhost:5000. Entrainer vers ce serveur :
|
||||
|
||||
```
|
||||
uv run python -m enervision_ml.train --csv data/all_sites_combined.csv --mlflow-tracking-uri http://localhost:5000
|
||||
```
|
||||
|
||||
Arreter : `docker compose -f docker-compose.mlflow.yml down` (ajouter `-v` pour effacer aussi les
|
||||
runs et les modeles).
|
||||
|
||||
Pour voir les runs dans l'interface (MLflow 3.x) :
|
||||
|
||||
- Passer le selecteur en haut a gauche sur **Model training**. Le mode **GenAI** affiche des
|
||||
traces LLM et reste vide pour un entrainement LightGBM.
|
||||
- **Runs** liste les entrainements, **Models** les artefacts de modele de chaque run (tous nommes
|
||||
`model`), et **Model registry** les versions numerotees de `consumption-forecast-lightgbm`.
|
||||
|
||||
Limites : l'identifiant PostgreSQL du compose est fixe a `mlflow`, le mot de passe vient de la
|
||||
variable obligatoire `MLFLOW_DB_PASSWORD` (aucune valeur par defaut, le compose refuse de
|
||||
demarrer sans elle) -- ce mot de passe est choisi lors de la copie de `.env.example`, il ne
|
||||
convient donc qu'au developpement local tel quel. Un deploiement partage demandera des secrets,
|
||||
de l'authentification et un stockage d'artefacts dedie (S3/MinIO). Le port 5000 doit etre libre : arreter `mlflow ui` avant,
|
||||
ou changer le mapping (`"5001:5000"`) dans le compose.
|
||||
|
||||
## Scoring
|
||||
|
||||
```bash
|
||||
@@ -83,6 +127,13 @@ section 2 :
|
||||
fichier : `train.py` reecrit toujours le meme chemin a chaque entrainement, donc le nom seul ne
|
||||
distinguerait pas deux versions du modele.
|
||||
|
||||
**Le scoring ne lit pas le Model Registry.** Le fichier charge par `--model` est local
|
||||
(`models/lightgbm-consumption.txt`), independant des versions enregistrees dans le
|
||||
**Model registry** MLflow (`consumption-forecast-lightgbm`). `train.py` enregistre bien une
|
||||
version a chaque entrainement (tracabilite), mais aucun alias (`champion` par exemple) n'est
|
||||
pose, et `enervision_ml.score` ne les lit pas. Le registre sert aujourd'hui a la tracabilite des
|
||||
entrainements, pas au deploiement du modele utilise en scoring.
|
||||
|
||||
En mode `--csv`, rien n'est ecrit en base : c'est un instantane historique fige (l'heure "future"
|
||||
calculee a partir de la fin du CSV n'existe dans aucune base reelle), utile pour valider le
|
||||
pipeline sans base joignable.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
services:
|
||||
mlflow-db:
|
||||
image: postgres:17
|
||||
environment:
|
||||
POSTGRES_USER: mlflow
|
||||
POSTGRES_PASSWORD: ${MLFLOW_DB_PASSWORD:?definir MLFLOW_DB_PASSWORD dans ml/.env}
|
||||
POSTGRES_DB: mlflow
|
||||
volumes:
|
||||
- mlflow-db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U mlflow"]
|
||||
interval: 5s
|
||||
retries: 10
|
||||
|
||||
mlflow:
|
||||
build: .
|
||||
depends_on:
|
||||
mlflow-db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "127.0.0.1:5000:5000"
|
||||
volumes:
|
||||
- mlflow-artifacts:/mlartifacts
|
||||
environment:
|
||||
MLFLOW_DB_PASSWORD: ${MLFLOW_DB_PASSWORD}
|
||||
entrypoint: [ "/bin/sh", "-c" ]
|
||||
command:
|
||||
- exec mlflow server --host 0.0.0.0 --port 5000 --backend-store-uri "postgresql://mlflow:$$MLFLOW_DB_PASSWORD@mlflow-db:5432/mlflow" --artifacts-destination /mlartifacts --serve-artifacts
|
||||
|
||||
volumes:
|
||||
mlflow-db-data:
|
||||
mlflow-artifacts:
|
||||
@@ -43,8 +43,8 @@ NUMERIC_COLUMNS = [
|
||||
"capacity_kw",
|
||||
]
|
||||
|
||||
# Piege : `reading.is_working_hours` est nullable et entre dans les features. Une seule lecture a
|
||||
# NULL rend la colonne `object`, que LightGBM refuse ("pandas dtypes must be int, float or bool").
|
||||
# Piege : `is_working_hours` est nullable et entre dans les features. Toujours `float64`, jamais
|
||||
# `bool` : `astype(bool)` ferait un `True` d'une absence, et les deux chargeurs divergeraient.
|
||||
FLAG_COLUMNS = ["is_working_hours"]
|
||||
|
||||
_READING_QUERY = text(
|
||||
@@ -113,10 +113,15 @@ def load_recent_from_database(
|
||||
|
||||
|
||||
def load_from_csv(csv_path: Path) -> pd.DataFrame:
|
||||
"""Lit le jeu de donnees CSV historique (chemin de demarrage, hors base)."""
|
||||
"""Lit le jeu de donnees CSV historique (chemin de demarrage, hors base).
|
||||
|
||||
`is_working_hours` passe par `_typer` comme le chemin base, et non par un `astype(bool)` : le
|
||||
fichier livre porte cette colonne en `0`/`1`, donc une case vide arrive en `NaN` et `astype`
|
||||
la rendrait `True` sans rien signaler. Les deux chargeurs rendent ainsi le meme schema, ce que
|
||||
`docs/ML-START.md` promet.
|
||||
"""
|
||||
frame = pd.read_csv(csv_path, parse_dates=["timestamp"])
|
||||
frame["capacity_kw"] = float("nan")
|
||||
frame["is_working_hours"] = frame["is_working_hours"].astype(bool)
|
||||
|
||||
return _typer(frame[OUTPUT_COLUMNS])
|
||||
|
||||
@@ -131,12 +136,18 @@ def _typer(frame: pd.DataFrame) -> pd.DataFrame:
|
||||
n'importe quelle autre colonne mesuree entierement absente sur une fenetre de scoring, pas
|
||||
seulement `capacity_kw`.
|
||||
|
||||
Les colonnes de `FLAG_COLUMNS` sont en outre ramenees a `float64` : ce sont des drapeaux
|
||||
nullables, et c'est le seul dtype qui survive a l'absence sans inventer de valeur. Sans cela,
|
||||
le meme chargeur rendrait `bool`, `int64` ou `float64` selon le contenu de la fenetre lue.
|
||||
|
||||
Piege additionnel : `NUMERIC_COLUMNS` inclut `consumption_kwh`, la cible du modele, pas
|
||||
seulement des variables explicatives. Une valeur non numerique y devient donc silencieusement
|
||||
`NaN` aussi bien a l'entrainement (ou `train.py` l'exclura ensuite via son `dropna`) qu'au
|
||||
scoring -- ce n'est pas un effet de bord limite aux colonnes mesurees.
|
||||
"""
|
||||
typee = frame.copy()
|
||||
for colonne in (*NUMERIC_COLUMNS, *FLAG_COLUMNS):
|
||||
for colonne in NUMERIC_COLUMNS:
|
||||
typee[colonne] = pd.to_numeric(typee[colonne], errors="coerce")
|
||||
for colonne in FLAG_COLUMNS:
|
||||
typee[colonne] = pd.to_numeric(typee[colonne], errors="coerce").astype("float64")
|
||||
return typee
|
||||
|
||||
@@ -181,7 +181,11 @@ def _log_to_mlflow(
|
||||
)
|
||||
mlflow.log_metrics({f"model_{cle}": valeur for cle, valeur in model_metrics.items()})
|
||||
mlflow.log_metrics({f"baseline_{cle}": valeur for cle, valeur in baseline_metrics.items()})
|
||||
mlflow.lightgbm.log_model(booster, name="model")
|
||||
mlflow.lightgbm.log_model(
|
||||
booster,
|
||||
name="model",
|
||||
registered_model_name="consumption-forecast-lightgbm",
|
||||
)
|
||||
mlflow.log_artifact(str(model_output))
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from enervision_ml.data import NUMERIC_COLUMNS, load_from_csv
|
||||
|
||||
@@ -53,3 +54,44 @@ def test_load_from_csv_always_types_capacity_kw_as_float(tmp_path: Path) -> None
|
||||
|
||||
assert frame["capacity_kw"].dtype == "float64"
|
||||
assert pd.isna(frame["capacity_kw"].iloc[0])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("present", ["1", "True"], ids=["entier", "booleen_textuel"])
|
||||
def test_load_from_csv_keeps_a_missing_is_working_hours_as_nan(
|
||||
tmp_path: Path, present: str
|
||||
) -> None:
|
||||
# Une case vide vaut "on ne sait pas", que LightGBM sait traiter. La rendre `True` inventerait
|
||||
# une heure ouvree, et le modele apprendrait sur une valeur que personne n'a mesuree.
|
||||
csv_path = write_csv(
|
||||
tmp_path,
|
||||
f"SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,{present},office",
|
||||
"SITE001,2026-01-01T01:00:00,11.5,15.2,50.5,0.0,,office",
|
||||
)
|
||||
|
||||
frame = load_from_csv(csv_path)
|
||||
|
||||
assert frame["is_working_hours"].iloc[0] == 1
|
||||
assert pd.isna(frame["is_working_hours"].iloc[1])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"valeurs",
|
||||
[("1", "0"), ("True", "False")],
|
||||
ids=["entier", "booleen_textuel"],
|
||||
)
|
||||
def test_load_from_csv_always_types_is_working_hours_as_float(
|
||||
tmp_path: Path, valeurs: tuple[str, str]
|
||||
) -> None:
|
||||
# Le dtype ne doit pas dependre de l'ecriture du fichier ni de la presence d'un trou : c'est
|
||||
# ce qui rend comparable le schema des deux chargeurs, cf. `test_data_integration.py`.
|
||||
present, absent = valeurs
|
||||
csv_path = write_csv(
|
||||
tmp_path,
|
||||
f"SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,{present},office",
|
||||
f"SITE001,2026-01-01T01:00:00,11.5,15.2,50.5,0.0,{absent},office",
|
||||
)
|
||||
|
||||
frame = load_from_csv(csv_path)
|
||||
|
||||
assert frame["is_working_hours"].dtype == "float64"
|
||||
assert list(frame["is_working_hours"]) == [1.0, 0.0]
|
||||
|
||||
@@ -142,6 +142,21 @@ def test_load_recent_from_database_types_a_null_is_working_hours_as_float64(
|
||||
assert list(frame["is_working_hours"].isna()) == [True, False]
|
||||
|
||||
|
||||
def test_load_recent_from_database_types_is_working_hours_as_float64_even_without_a_null(
|
||||
connexion_ml: Connection,
|
||||
) -> None:
|
||||
# Sans cette garantie, le dtype dependrait du contenu de la fenetre lue : `bool` ici, `float64`
|
||||
# des qu'une seule lecture est a NULL, et le schema des deux chargeurs cesserait d'etre egal.
|
||||
site_id = insere_site(connexion_ml)
|
||||
insere_lectures(connexion_ml, site_id, heures=2, fin=ANCRAGE)
|
||||
|
||||
frame = load_recent_from_database(
|
||||
connexion_ml, since=ANCRAGE - timedelta(hours=2), until=ANCRAGE
|
||||
)
|
||||
|
||||
assert frame["is_working_hours"].dtype == "float64"
|
||||
|
||||
|
||||
def test_both_loaders_produce_the_same_columns_in_the_same_order(
|
||||
connexion_ml: Connection, tmp_path: Path
|
||||
) -> None:
|
||||
|
||||
@@ -3,6 +3,7 @@ from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from enervision_ml.features import TARGET_COLUMN, build_features, feature_columns
|
||||
from enervision_ml.train import chronological_split, prepare_dataset, train
|
||||
@@ -74,3 +75,19 @@ def test_train_runs_end_to_end_on_synthetic_data_and_beats_a_dummy_baseline(
|
||||
assert model_metrics["n_observations"] > 0
|
||||
assert model_metrics["mae"] >= 0
|
||||
assert baseline_metrics["n_observations"] == model_metrics["n_observations"]
|
||||
assert model_metrics["mae"] < baseline_metrics["mae"]
|
||||
|
||||
|
||||
def test_train_raises_when_the_validation_window_is_empty(tmp_path: Path) -> None:
|
||||
depart = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
frame = make_frame("site-a", heures=50, depart=depart) # trop court pour un lag de 168h
|
||||
csv_path = tmp_path / "trop_court.csv"
|
||||
frame.to_csv(csv_path, index=False)
|
||||
|
||||
with pytest.raises(ValueError, match="Fenetre d'entrainement ou de validation vide"):
|
||||
train(
|
||||
csv_path=csv_path,
|
||||
model_output=tmp_path / "model.txt",
|
||||
test_fraction=0.2,
|
||||
tracking_uri=f"sqlite:///{tmp_path / 'mlflow.db'}",
|
||||
)
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
# Scripts
|
||||
|
||||
Outillage local du monorepo. Les taches courantes passent par le `Makefile` racine.
|
||||
|
||||
## dast-token.sh
|
||||
|
||||
Prépare le scan DAST (`.github/workflows/dast.yml`) : sur une API déjà démarrée, crée un compte
|
||||
`lecteur` jetable, lui fait passer le changement de mot de passe obligatoire et écrit son jeton
|
||||
d'accès sur la sortie standard. À lancer depuis `apps/backend`, contre une base **jetable** (il y
|
||||
crée deux comptes) : `BASE_URL=http://localhost:8000 ../../scripts/dast-token.sh`. Nécessite `curl`,
|
||||
`jq` et `openssl`.
|
||||
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prépare le scan DAST : crée un compte `lecteur` sur une API déjà démarrée, lui fait passer le
|
||||
# changement de mot de passe obligatoire, et écrit son jeton d'accès sur la sortie standard.
|
||||
#
|
||||
# Piège : un compte neuf est en `must_change_password`, et toute route gardée le refuse tant que
|
||||
# le mot de passe n'a pas été changé. Sans cette étape, ZAP ne verrait que 403 sur les routes
|
||||
# gardées et le scan ne testerait rien de l'API authentifiée.
|
||||
#
|
||||
# Contrainte : le compte du scan est `lecteur`, jamais `admin`. Un scan actif avec un jeton admin
|
||||
# frapperait POST /users ou la réinitialisation de mots de passe pour de bon.
|
||||
#
|
||||
# L'administrateur n'existe que pour créer ce compte (l'API n'a pas d'inscription publique).
|
||||
# À lancer depuis apps/backend, dans un environnement où DATABASE_URL et APP_SECRET_KEY visent
|
||||
# une base JETABLE : le script y crée deux comptes.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://localhost:8000}"
|
||||
API="$BASE_URL/api/v1"
|
||||
SUFFIXE="$(openssl rand -hex 4)"
|
||||
EMAIL_ADMIN="dast-admin-$SUFFIXE@enervision.fr"
|
||||
EMAIL_LECTEUR="dast-lecteur-$SUFFIXE@enervision.fr"
|
||||
|
||||
# Classes exigées par le validateur : majuscule, minuscule, chiffre, caractère spécial.
|
||||
nouveau_mot_de_passe() { echo "Dast-$(openssl rand -hex 12)-Aa1!"; }
|
||||
|
||||
# Tout ce qui n'est pas la sortie finale part sur stderr : la sortie standard ne porte que le jeton.
|
||||
journal() { echo "dast-token: $*" >&2; }
|
||||
|
||||
connexion() {
|
||||
local email="$1" mot_de_passe="$2"
|
||||
curl -fsS -X POST "$API/auth/login" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -n --arg e "$email" --arg p "$mot_de_passe" '{email:$e, password:$p}')" \
|
||||
| jq -r '.access_token'
|
||||
}
|
||||
|
||||
# Rend le nouveau jeton d'accès : `/auth/password` en émet un (avec l'`iat` de la session en
|
||||
# cours, cf. le piège documenté dans `app/api/deps.py`), pas seulement une confirmation. S'y fier
|
||||
# évite une reconnexion, donc un second hachage Argon2id (19456 Kio) et un aller-retour de
|
||||
# refresh-token superflus sur le chemin critique de la CI.
|
||||
changer_mot_de_passe() {
|
||||
local jeton="$1" ancien="$2" nouveau="$3"
|
||||
curl -fsS -X POST "$API/auth/password" \
|
||||
-H "Authorization: Bearer $jeton" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -n --arg a "$ancien" --arg n "$nouveau" '{current_password:$a, new_password:$n}')" \
|
||||
| jq -r '.access_token'
|
||||
}
|
||||
|
||||
journal "création de l'administrateur $EMAIL_ADMIN"
|
||||
if ! SORTIE="$(uv run --frozen --no-sync --no-build python -m app.cli create-admin --email "$EMAIL_ADMIN" --generate)"; then
|
||||
journal "la création de l'administrateur a échoué :"
|
||||
journal "$SORTIE"
|
||||
exit 1
|
||||
fi
|
||||
MDP_ADMIN="$(sed -n 's/^Mot de passe généré, il ne sera plus affiché : //p' <<<"$SORTIE")"
|
||||
[[ -n "$MDP_ADMIN" ]] || { journal "mot de passe administrateur introuvable dans la sortie :"; journal "$SORTIE"; exit 1; }
|
||||
|
||||
JETON="$(connexion "$EMAIL_ADMIN" "$MDP_ADMIN")"
|
||||
NOUVEAU_ADMIN="$(nouveau_mot_de_passe)"
|
||||
JETON="$(changer_mot_de_passe "$JETON" "$MDP_ADMIN" "$NOUVEAU_ADMIN")"
|
||||
|
||||
journal "création du lecteur $EMAIL_LECTEUR"
|
||||
REPONSE="$(curl -fsS -X POST "$API/users" -H "Authorization: Bearer $JETON" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(jq -n --arg e "$EMAIL_LECTEUR" '{email:$e, role:"lecteur"}')")"
|
||||
MDP_TEMPORAIRE="$(jq -r '.temporary_password // empty' <<<"$REPONSE")"
|
||||
[[ -n "$MDP_TEMPORAIRE" ]] || { journal "mot de passe temporaire introuvable dans la réponse de POST /users :"; journal "$REPONSE"; exit 1; }
|
||||
|
||||
JETON="$(connexion "$EMAIL_LECTEUR" "$MDP_TEMPORAIRE")"
|
||||
NOUVEAU_LECTEUR="$(nouveau_mot_de_passe)"
|
||||
JETON="$(changer_mot_de_passe "$JETON" "$MDP_TEMPORAIRE" "$NOUVEAU_LECTEUR")"
|
||||
|
||||
# Vérifie que le jeton ouvre bien une route gardée avant de le rendre.
|
||||
CODE="$(curl -sS -o /dev/null -w '%{http_code}' "$API/sites" -H "Authorization: Bearer $JETON")"
|
||||
[[ "$CODE" == "200" ]] || { journal "GET /sites répond $CODE avec le jeton du lecteur, attendu 200"; exit 1; }
|
||||
|
||||
journal "jeton du lecteur prêt"
|
||||
echo "$JETON"
|
||||
Reference in New Issue
Block a user