From b00c39277bf5ca18da53300a058b29cb989da651 Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 23 Sep 2026 14:45:38 +0200 Subject: [PATCH 01/10] fix(backend,airflow,ml): cloture la reconciliation entre les deux sources de lectures (#15) --- apps/backend/app/etl/mock_api_import.py | 32 ++++++++ .../backend/tests/etl/test_mock_api_import.py | 77 +++++++++++++++++++ docs/architecture/00-vue-ensemble.md | 19 +++-- docs/architecture/40-data.md | 37 ++++++++- etl/airflow/dags/mock_api_import.py | 12 ++- etl/airflow/tests/test_dags.py | 2 +- ml/enervision_ml/data.py | 14 +++- ml/tests/conftest.py | 43 +++++++++-- ml/tests/test_data_integration.py | 54 ++++++++++++- 9 files changed, 269 insertions(+), 21 deletions(-) diff --git a/apps/backend/app/etl/mock_api_import.py b/apps/backend/app/etl/mock_api_import.py index 65fcea5..c2c105c 100644 --- a/apps/backend/app/etl/mock_api_import.py +++ b/apps/backend/app/etl/mock_api_import.py @@ -19,6 +19,7 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine from app.core.config import get_settings +from app.etl.historical_import import SOURCE_NAME as SOURCE_CSV SOURCE_HISTORY = "api_history" @@ -244,6 +245,35 @@ def build_reading_row( } +# `uq_reading_source` autorise deux lignes au même (site_id, timestamp) dès que `source` diffère : +# sans ce garde-fou, importer une fenêtre déjà couverte par le dataset historique (source='csv') +# dupliquerait silencieusement chaque point plutôt que de lever une erreur, et rien côté lecture +# (pipeline ML, GET /readings) ne saurait laquelle des deux lectures retenir. +OVERLAP_CHECK = text( + "SELECT count(*) FROM reading WHERE source = :source_csv " + "AND timestamp >= :start_time AND timestamp < :end_time" +) + + +async def refuse_if_overlaps_historical_dataset( + connection: AsyncConnection, + start_time: datetime, + end_time: datetime, +) -> None: + resultat = await connection.execute( + OVERLAP_CHECK, + {"source_csv": SOURCE_CSV, "start_time": start_time, "end_time": end_time}, + ) + nombre = resultat.scalar_one() + + if nombre > 0: + raise ValueError( + f"La fenêtre [{start_time.isoformat()}, {end_time.isoformat()}) recouvre " + f"{nombre} lecture(s) déjà importée(s) du dataset historique (source='{SOURCE_CSV}') : " + "import refusé pour éviter un doublon inter-source." + ) + + # Le conflit vise l'index unique uq_reading_source plutôt que la table entière : sans cible # nommée, DO NOTHING avalerait aussi une violation de clé primaire. READING_INSERT = text( @@ -345,6 +375,8 @@ async def import_mock_api_history( try: async with engine.begin() as connection: + await refuse_if_overlaps_historical_dataset(connection, start_time, end_time) + await upsert_sites( connection, sites, diff --git a/apps/backend/tests/etl/test_mock_api_import.py b/apps/backend/tests/etl/test_mock_api_import.py index fa654bc..562699f 100644 --- a/apps/backend/tests/etl/test_mock_api_import.py +++ b/apps/backend/tests/etl/test_mock_api_import.py @@ -357,6 +357,31 @@ async def test_upsert_sites_with_empty_list_does_nothing() -> None: connection.execute.assert_not_awaited() +async def test_refuse_if_overlaps_historical_dataset_lets_a_clear_window_through() -> None: + connection = AsyncMock() + connection.execute.return_value.scalar_one = MagicMock(return_value=0) + + await mock_api_import.refuse_if_overlaps_historical_dataset( + connection, + datetime.fromisoformat("2026-01-01T00:00:00"), + datetime.fromisoformat("2026-01-01T01:00:00"), + ) + + connection.execute.assert_awaited_once() + + +async def test_refuse_if_overlaps_historical_dataset_rejects_a_window_already_in_the_csv() -> None: + connection = AsyncMock() + connection.execute.return_value.scalar_one = MagicMock(return_value=5) + + with pytest.raises(ValueError, match="doublon inter-source"): + await mock_api_import.refuse_if_overlaps_historical_dataset( + connection, + datetime.fromisoformat("2023-06-15T12:00:00"), + datetime.fromisoformat("2023-06-15T13:00:00"), + ) + + async def test_import_mock_api_history_dry_run_does_not_write( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -454,6 +479,7 @@ async def test_import_mock_api_history_loads_data( ) connection = AsyncMock() + connection.execute.return_value.scalar_one = MagicMock(return_value=0) transaction_context = MagicMock() transaction_context.__aenter__ = AsyncMock( @@ -502,7 +528,58 @@ async def test_import_mock_api_history_loads_data( [make_site()], ) + assert connection.execute.await_count == 2 + dernier_appel = connection.execute.await_args_list[-1] + assert dernier_appel.args[0] is READING_INSERT + engine.dispose.assert_awaited_once() + + +async def test_import_mock_api_history_refuses_when_it_overlaps_the_historical_dataset( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def handler(request: Request) -> Response: + if request.url.path == "/api/v1/sites": + return Response(status_code=200, json=[make_site()]) + + if request.url.path == "/api/v1/readings": + return Response(status_code=200, json=[make_reading()]) + + return Response(status_code=404) + + client = AsyncClient(transport=MockTransport(handler), base_url="https://mock.test") + + monkeypatch.setattr(mock_api_import, "create_mock_api_client", lambda: client) + monkeypatch.setattr( + mock_api_import, + "get_settings", + lambda: SimpleNamespace(database_url="postgresql+asyncpg://test:test@localhost/test"), + ) + + connection = AsyncMock() + connection.execute.return_value.scalar_one = MagicMock(return_value=3) + + transaction_context = MagicMock() + transaction_context.__aenter__ = AsyncMock(return_value=connection) + transaction_context.__aexit__ = AsyncMock(return_value=None) + + engine = MagicMock() + engine.begin.return_value = transaction_context + engine.dispose = AsyncMock() + + monkeypatch.setattr(mock_api_import, "create_async_engine", MagicMock(return_value=engine)) + upsert_sites_mock = AsyncMock() + monkeypatch.setattr(mock_api_import, "upsert_sites", upsert_sites_mock) + + with pytest.raises(ValueError, match="doublon inter-source"): + await mock_api_import.import_mock_api_history( + start_time=datetime.fromisoformat("2023-06-15T12:00:00"), + end_time=datetime.fromisoformat("2023-06-15T13:00:00"), + limit=60, + dry_run=False, + ) + connection.execute.assert_awaited_once() + upsert_sites_mock.assert_not_awaited() engine.dispose.assert_awaited_once() diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 5d4ddb6..1aece1f 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -70,11 +70,17 @@ Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API, intercepteur répond à sa place tant que les endpoints n'existent pas. Voir [30-frontend.md](30-frontend.md). -Le lien `airflow --> db` est maintenant en trait plein : cinq DAGs tournent, deux pour +Le lien `airflow --> db` est maintenant en trait plein : six 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), `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. +génération des recommandations (issue #116), un pour la surveillance de dérive (issue #45), +`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 entre les deux sources de lectures (issue +#15) est tranchée : le trou entre la fin de l'historique (31/12/2024) et le début de l'ingestion +API Mock est accepté comme définitivement perdu, aucune mesure réelle n'existant pour cette +période. `mock_api_import` refuse toute fenêtre qui recouvrirait des lectures déjà importées du +CSV plutôt que de laisser les deux sources dupliquer silencieusement un même instant, et le +pipeline ML déduplique par construction (`DISTINCT ON`, source `csv` préférée) au cas où un +recouvrement se produirait malgré tout — voir [40-data.md](40-data.md). Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun collecteur ne vient le lire. @@ -89,7 +95,7 @@ collecteur ne vient le lire. | ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive 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 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. | +| 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. Réconciliation entre les deux sources (issue #15) : trou temporel accepté, recouvrement refusé à l'ingestion et dédupliqué en défense côté ML, voir [40-data.md](40-data.md). | | CI/CD | GitHub Actions | `.github/workflows` | `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 @@ -99,7 +105,8 @@ Statut : `En cours`. **Le chemin de lecture tourne** entre la base, l'API et 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. +réconciliation entre les deux sources de lectures (issue #15) est close : voir +[40-data.md](40-data.md) pour le détail du garde-fou d'ingestion et de la déduplication ML. ```mermaid sequenceDiagram diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index 335bafd..9ca6706 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -441,6 +441,16 @@ Les paramètres de ligne de commande disponibles pour l'import sont : --dry-run ``` +**Piège sur `--limit`** : l'API ne renvoie pas un flux à un rythme naturel, elle répartit +exactement `limit` lectures, espacées uniformément, sur toute la fenêtre `[start_time, end_time)` +demandée. Une fenêtre d'une heure avec `limit=1000` renvoie donc 1000 lectures espacées de 3,6 +secondes à l'intérieur de cette heure, pas une lecture horaire — vérifié empiriquement en +interrogeant directement l'API. Le seul réglage qui produise une lecture par heure, alignée sur +l'heure et cohérente avec le grain horaire du reste du schéma (`period_minutes=60`, historique +CSV à une ligne par heure), est `limit` = nombre d'heures de la fenêtre. Le DAG `mock_api_import` +interroge toujours une fenêtre d'1h (`interval=timedelta(hours=1)`, voir +[10-infra.md](10-infra.md)), donc `limit=1`. + ### Flux d'ingestion API Mock ```text @@ -492,7 +502,7 @@ réponse est donc traitée comme une entrée hostile, conformément à API10 dan [la traçabilité OWASP](owasp-traceabilite.md). Le risque premier n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. -Quatre garde-fous, tous dans `mock_api_import.py` : +Cinq garde-fous, tous dans `mock_api_import.py` : | Garde-fou | Mise en œuvre | |---|---| @@ -500,6 +510,7 @@ Quatre garde-fous, tous dans `mock_api_import.py` : | Taille de tableau plafonnée | `MAX_SITES` sites, et au plus `--limit` mesures par site | | Bornes physiques | `PHYSICAL_BOUNDS`, une plage par grandeur | | Frontière d'anti-corruption | `build_site_row()` et `build_reading_row()`, qui ne recopient que les champs attendus | +| Refus de recouvrir l'historique | `refuse_if_overlaps_historical_dataset()`, voir ci-dessous | Une valeur hors bornes, d'un type inattendu, `NaN` ou infinie devient `NULL`. Elle laisse sa trace dans `null_reasons` sous la forme `out_of_physical_bounds:`, et `data_quality` @@ -510,6 +521,30 @@ d'origine intacte : rien n'est perdu, seule son exploitation est bornée. Le plafond de taille s'applique après désérialisation de la réponse. Borner le corps HTTP lui-même demanderait une lecture en flux, et reste à faire. +### Réconciliation entre les deux sources (issue #15) + +`historical_import` (source `csv`) et `mock_api_import` (source `api_history`) écrivent toutes +deux dans `reading`. Trois décisions ferment cette réconciliation : + +- **Le trou temporel est accepté.** Le dataset historique s'arrête au 31/12/2024, et + `mock_api_import` n'importe que l'heure précédant chaque déclenchement : rien ne comble + automatiquement la période intermédiaire, et rien ne le pourra jamais — aucune mesure réelle + n'existe pour ces instants. +- **Le recouvrement est refusé à l'ingestion.** `uq_reading_source` autorise deux lignes au même + `(site_id, timestamp)` dès que `source` diffère : rien dans le schéma n'empêche donc un import + Mock API manuel avec une fenêtre passée (le script accepte `--start-time`/`--end-time` + arbitraires) de dupliquer un point déjà couvert par le CSV. `import_mock_api_history()` appelle + `refuse_if_overlaps_historical_dataset()` avant toute écriture : si la fenêtre demandée recouvre + au moins une lecture `source='csv'`, l'import est refusé (`ValueError`) plutôt que d'écrire un + doublon inter-source silencieux. +- **Le pipeline ML déduplique en défense.** Le garde-fou ci-dessus protège l'ingestion, pas + la lecture : si un recouvrement se produisait malgré tout (import direct en base, contournement + du script), `ml/enervision_ml/data.py` ne doit pas casser silencieusement l'hypothèse de + `build_features` (« une ligne par `(site_id, timestamp)` »). `load_from_database()` et + `load_recent_from_database()` utilisent donc `SELECT DISTINCT ON (site_id, timestamp)`, `source + = 'csv'` gagnant sur `'api_history'` en cas d'égalité — l'historique étant une source vérifiée, + l'API Mock une entrée hostile (cf. ci-dessus). + ### Qualité des données de l'API Mock Les valeurs `NULL` ne sont pas remplacées pendant l'ingestion. diff --git a/etl/airflow/dags/mock_api_import.py b/etl/airflow/dags/mock_api_import.py index f0043d0..e649be5 100644 --- a/etl/airflow/dags/mock_api_import.py +++ b/etl/airflow/dags/mock_api_import.py @@ -18,9 +18,15 @@ 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 +# L'API Mock ne renvoie pas un flux au rythme naturel : elle répartit exactement `limit` +# lectures, espacées uniformément, sur toute la fenêtre demandée (vérifié empiriquement : +# une fenêtre d'1h avec `limit=1000` renvoie 1000 lectures espacées de 3,6s à l'intérieur de +# cette heure, pas une lecture horaire). Comme la fenêtre de ce DAG est toujours 1h +# (`interval=timedelta(hours=1)` ci-dessous), `limit=1` est ce qui produit une lecture par +# heure, alignée sur l'heure, cohérente avec le grain horaire du reste du schéma +# (`period_minutes=60`, historique CSV à 1 ligne/heure). Un `limit` plus grand ici fabriquerait +# des lectures infra-horaires, incompatibles avec les lags positionnels de `build_features`. +LIMITE_LECTURES = 1 # Deux reprises donnent trois tentatives au total. Même dans le pire cas, l'exécution reste # inférieure au pas horaire du DAG. diff --git a/etl/airflow/tests/test_dags.py b/etl/airflow/tests/test_dags.py index d03dd97..4e4e077 100644 --- a/etl/airflow/tests/test_dags.py +++ b/etl/airflow/tests/test_dags.py @@ -118,7 +118,7 @@ def test_mock_api_import_uses_the_airflow_data_interval(dagbag: DagBag) -> None: 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 + assert "--limit 1" in commande @pytest.mark.parametrize("task_id", ["detection", "recommandations"]) diff --git a/ml/enervision_ml/data.py b/ml/enervision_ml/data.py index e74da91..3fa64d2 100644 --- a/ml/enervision_ml/data.py +++ b/ml/enervision_ml/data.py @@ -47,9 +47,15 @@ NUMERIC_COLUMNS = [ # `bool` : `astype(bool)` ferait un `True` d'une absence, et les deux chargeurs divergeraient. FLAG_COLUMNS = ["is_working_hours"] +# `uq_reading_source` autorise deux lignes au meme (site_id, timestamp) des que `source` differe +# (cf. `app/etl/mock_api_import.py`, qui refuse desormais d'importer une fenetre deja couverte par +# le CSV, mais ne protege pas le sens inverse). `build_features` suppose une ligne par +# (site_id, timestamp) sans doublon : le `DISTINCT ON` l'impose plutot que de la supposer. +# 'csv' gagne sur 'api_history' en cas de recouvrement, l'historique etant une source verifiee +# alors que l'API Mock est traitee comme une entree hostile (cf. OWASP API10). _READING_QUERY = text( """ - SELECT + SELECT DISTINCT ON (r.site_id, r.timestamp) r.site_id, r.timestamp, r.consumption_kwh, @@ -61,14 +67,14 @@ _READING_QUERY = text( s.capacity_kw FROM reading r JOIN site s ON s.site_id = r.site_id - ORDER BY r.site_id, r.timestamp + ORDER BY r.site_id, r.timestamp, (r.source = 'csv') DESC, r.reading_id DESC """ ) _RECENT_READING_QUERY = text( """ - SELECT + SELECT DISTINCT ON (r.site_id, r.timestamp) r.site_id, r.timestamp, r.consumption_kwh, @@ -81,7 +87,7 @@ _RECENT_READING_QUERY = text( FROM reading r JOIN site s ON s.site_id = r.site_id WHERE r.timestamp >= :since AND r.timestamp <= :until - ORDER BY r.site_id, r.timestamp + ORDER BY r.site_id, r.timestamp, (r.source = 'csv') DESC, r.reading_id DESC """ ) diff --git a/ml/tests/conftest.py b/ml/tests/conftest.py index 33f65b9..b2b7a49 100644 --- a/ml/tests/conftest.py +++ b/ml/tests/conftest.py @@ -16,7 +16,7 @@ from collections.abc import Iterator from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from pathlib import Path -from typing import Any +from typing import Any, cast from uuid import uuid4 import lightgbm as lgb @@ -44,20 +44,30 @@ _INSERT_SITE = text( """ ) -# `source = 'api_history'` impose `dataset_id IS NULL` (ck_reading_dataset_source), ce qui evite -# de creer une ligne `dataset`. `raw_data` est NOT NULL, d'ou le litteral jsonb. +# `source = 'api_history'` impose `dataset_id IS NULL` (ck_reading_dataset_source) : le defaut +# `dataset_id=None` evite de creer une ligne `dataset` pour la plupart des tests. `source='csv'` +# impose l'inverse, d'ou `insere_dataset()` quand un test a besoin de cette source precise. +# `raw_data` est NOT NULL, d'ou le litteral jsonb. _INSERT_READING = text( """ INSERT INTO reading ( - site_id, timestamp, source, consumption_kwh, temperature_celsius, + site_id, timestamp, source, dataset_id, consumption_kwh, temperature_celsius, humidity_percent, solar_irradiance_wm2, is_working_hours, raw_data ) VALUES ( - :site_id, :timestamp, :source, :consumption_kwh, :temperature_celsius, + :site_id, :timestamp, :source, :dataset_id, :consumption_kwh, :temperature_celsius, :humidity_percent, :solar_irradiance_wm2, :is_working_hours, '{}'::jsonb ) """ ) +_INSERT_DATASET = text( + """ + INSERT INTO dataset (dataset_name, archive_sha256, storage_uri, source_timezone, metadata) + VALUES (:dataset_name, :archive_sha256, :storage_uri, 'UTC', '{}'::jsonb) + RETURNING dataset_id + """ +) + _SELECT_PREDICTIONS = text( """ SELECT target_at, predicted_value, status, failure_reason, model_reference @@ -111,6 +121,25 @@ def insere_site( return site_id +def insere_dataset(connexion: Connection) -> int: + """Ligne `dataset` minimale, requise pour inserer une lecture `source='csv'` + + (`ck_reading_dataset_source` impose `dataset_id IS NOT NULL` pour cette seule source). + """ + marque = uuid4().hex + return cast( + int, + connexion.execute( + _INSERT_DATASET, + { + "dataset_name": f"jeu de test {marque}", + "archive_sha256": marque.rjust(64, "0"), + "storage_uri": f"file:///test/{marque}.csv", + }, + ).scalar_one(), + ) + + def insere_lectures( connexion: Connection, site_id: str, @@ -119,6 +148,7 @@ def insere_lectures( fin: datetime, valeur: float = 50.0, source: str = "api_history", + dataset_id: int | None = None, is_working_hours: bool | None = True, ) -> list[datetime]: """Grille horaire contigue finissant a `fin`, incluse. @@ -134,6 +164,7 @@ def insere_lectures( "site_id": site_id, "timestamp": instant, "source": source, + "dataset_id": dataset_id, "consumption_kwh": valeur + math.sin(rang / 12.0) * 10.0, "temperature_celsius": 15.0, "humidity_percent": 50.0, @@ -153,6 +184,7 @@ def insere_lecture( instant: datetime, consumption_kwh: float | None = 50.0, source: str = "api_history", + dataset_id: int | None = None, is_working_hours: bool | None = True, ) -> None: """Une lecture isolee, quand le test pilote sa valeur plutot que sa forme.""" @@ -162,6 +194,7 @@ def insere_lecture( "site_id": site_id, "timestamp": instant, "source": source, + "dataset_id": dataset_id, "consumption_kwh": consumption_kwh, "temperature_celsius": 15.0, "humidity_percent": 50.0, diff --git a/ml/tests/test_data_integration.py b/ml/tests/test_data_integration.py index 3b5ea1a..9555d02 100644 --- a/ml/tests/test_data_integration.py +++ b/ml/tests/test_data_integration.py @@ -11,7 +11,7 @@ from enervision_ml.data import ( load_from_database, load_recent_from_database, ) -from tests.conftest import ANCRAGE, insere_lecture, insere_lectures, insere_site +from tests.conftest import ANCRAGE, insere_dataset, insere_lecture, insere_lectures, insere_site pytestmark = pytest.mark.integration @@ -39,6 +39,58 @@ def test_load_from_database_joins_the_site_attributes_to_every_reading( assert set(mien["capacity_kw"]) == {250.0} +def test_load_from_database_deduplicates_two_sources_at_the_same_instant( + connexion_ml: Connection, +) -> None: + # `uq_reading_source` autorise deux lignes au meme (site_id, timestamp) des que `source` + # differe : le garde-fou vit dans `mock_api_import.py`, pas dans le schema. Le chargeur ML + # doit donc imposer lui-meme "une ligne par (site_id, timestamp)", pas la supposer. + site_id = insere_site(connexion_ml) + dataset_id = insere_dataset(connexion_ml) + insere_lecture( + connexion_ml, site_id, instant=ANCRAGE, consumption_kwh=10.0, source="api_history" + ) + insere_lecture( + connexion_ml, + site_id, + instant=ANCRAGE, + consumption_kwh=99.0, + source="csv", + dataset_id=dataset_id, + ) + + frame = load_from_database(connexion_ml) + + mien = frame[frame["site_id"] == site_id] + assert len(mien) == 1 + assert mien["consumption_kwh"].iloc[0] == 99.0 + + +def test_load_recent_from_database_prefers_csv_when_two_sources_share_an_instant( + connexion_ml: Connection, +) -> None: + site_id = insere_site(connexion_ml) + dataset_id = insere_dataset(connexion_ml) + insere_lecture( + connexion_ml, site_id, instant=ANCRAGE, consumption_kwh=10.0, source="api_history" + ) + insere_lecture( + connexion_ml, + site_id, + instant=ANCRAGE, + consumption_kwh=99.0, + source="csv", + dataset_id=dataset_id, + ) + + frame = load_recent_from_database( + connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3) + ) + + assert len(frame) == 1 + assert frame["consumption_kwh"].iloc[0] == 99.0 + + def test_load_recent_from_database_excludes_readings_before_the_since_bound( connexion_ml: Connection, ) -> None: From b78322bd6178ce57cf215b63e5ff6352741e7096 Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 23 Sep 2026 15:19:39 +0200 Subject: [PATCH 02/10] fix(backend,airflow,ml): applique les corrections de revue sur la PR #162 --- apps/backend/app/etl/mock_api_import.py | 159 +++++++---- .../backend/tests/etl/test_mock_api_import.py | 252 +++++++++++------- docs/architecture/00-vue-ensemble.md | 2 +- docs/architecture/40-data.md | 59 ++-- etl/airflow/dags/mock_api_import.py | 23 +- etl/airflow/tests/test_dags.py | 3 +- ml/tests/test_data_integration.py | 19 +- 7 files changed, 328 insertions(+), 189 deletions(-) diff --git a/apps/backend/app/etl/mock_api_import.py b/apps/backend/app/etl/mock_api_import.py index c2c105c..ca24d2b 100644 --- a/apps/backend/app/etl/mock_api_import.py +++ b/apps/backend/app/etl/mock_api_import.py @@ -1,17 +1,19 @@ # Contrainte : la réponse de l'API Mock est une entrée hostile, pas une source de confiance. # Voir OWASP API10 dans docs/architecture/owasp-traceabilite.md. Rien de ce qu'elle renvoie # n'atteint la base sans passer par build_site_row() ou build_reading_row() : seuls les champs -# attendus sont recopiés, les grandeurs physiques sont bornées par PHYSICAL_BOUNDS et la taille -# des tableaux est plafonnée par MAX_SITES et par --limit. Une valeur hors bornes devient NULL -# et laisse sa trace dans null_reasons plutôt que de lever : le mock émet des anomalies par -# construction, et raw_data conserve de toute façon la réponse d'origine intacte. +# attendus sont recopiés, les grandeurs physiques sont bornées par PHYSICAL_BOUNDS, la taille des +# tableaux est plafonnée par MAX_SITES et par limit_for_window() (dérivé de la fenêtre, jamais +# fourni par l'appelant), et les lectures dont le timestamp déborde de la fenêtre demandée sont +# écartées (fetch_readings). Une valeur hors bornes devient NULL et laisse sa trace dans +# null_reasons plutôt que de lever : le mock émet des anomalies par construction, et raw_data +# conserve de toute façon la réponse d'origine intacte. from __future__ import annotations import argparse import asyncio import json -from datetime import datetime +from datetime import UTC, datetime from typing import Any import httpx @@ -182,6 +184,21 @@ async def upsert_sites( ) +def _timestamp_in_window( + reading: dict[str, Any], start_time: datetime, end_time: datetime +) -> bool: + valeur = reading.get("timestamp") + if not isinstance(valeur, str): + return False + + try: + instant = parse_datetime(valeur) + except ValueError: + return False + + return start_time <= instant < end_time + + async def fetch_readings( client: httpx.AsyncClient, site_id: str, @@ -209,7 +226,22 @@ async def fetch_readings( if len(payload) > limit: raise ValueError(f"La réponse /api/v1/readings dépasse la limite demandée de {limit}.") - return payload + # Le garde-fou `refuse_if_overlaps_historical_dataset` ne vérifie que la fenêtre demandée : + # une réponse (bug du mock, ou hostile) dont les `timestamp` débordent de + # `[start_time, end_time)` contournerait ce contrôle et écrirait exactement le doublon + # inter-source qu'il doit empêcher. Écarter ces lectures ici rend le contrôle par fenêtre + # suffisant. + dans_la_fenetre = [ + lecture + for lecture in payload + if isinstance(lecture, dict) and _timestamp_in_window(lecture, start_time, end_time) + ] + + if len(dans_la_fenetre) != len(payload): + ecartees = len(payload) - len(dans_la_fenetre) + print(f"{site_id}: {ecartees} lecture(s) hors fenêtre écartée(s).") + + return dans_la_fenetre def build_reading_row( @@ -247,8 +279,9 @@ def build_reading_row( # `uq_reading_source` autorise deux lignes au même (site_id, timestamp) dès que `source` diffère : # sans ce garde-fou, importer une fenêtre déjà couverte par le dataset historique (source='csv') -# dupliquerait silencieusement chaque point plutôt que de lever une erreur, et rien côté lecture -# (pipeline ML, GET /readings) ne saurait laquelle des deux lectures retenir. +# dupliquerait silencieusement chaque point plutôt que de lever une erreur. Ce garde-fou protège +# l'ingestion ; il ne dit rien de la lecture (`GET /readings` renvoie les deux lignes en cas de +# doublon malgré tout, cf. la section réconciliation de 40-data.md). OVERLAP_CHECK = text( "SELECT count(*) FROM reading WHERE source = :source_csv " "AND timestamp >= :start_time AND timestamp < :end_time" @@ -332,41 +365,41 @@ def build_reading_batch( return [build_reading_row(reading) for reading in readings] +def limit_for_window(start_time: datetime, end_time: datetime) -> int: + """Nombre de lectures à demander pour que l'API Mock en rende une par heure, alignée. + + L'API ne renvoie pas un flux à un rythme naturel : elle répartit exactement `limit` lectures, + espacées uniformément, sur toute la fenêtre `[start_time, end_time)` demandée (vérifié + empiriquement). `limit` = nombre d'heures de la fenêtre est donc le seul réglage cohérent avec + le grain horaire du reste du schéma (`period_minutes=60`, historique CSV à une ligne/heure) ; + un `limit` plus grand fabriquerait des lectures infra-horaires, incompatibles avec les lags + positionnels de `build_features`. La fenêtre doit donc couvrir un nombre entier d'heures. + """ + duree = end_time - start_time + heures, reste = divmod(duree.total_seconds(), 3600) + + if reste != 0: + raise ValueError( + "La fenêtre doit couvrir un nombre entier d'heures pour obtenir une lecture par " + f"heure alignée : [{start_time.isoformat()}, {end_time.isoformat()}) n'en couvre pas." + ) + + if heures > MAX_LIMIT: + raise ValueError( + f"La fenêtre demandée couvre {int(heures)}h, au-delà du plafond de {MAX_LIMIT} " + "lectures accepté par l'API Mock." + ) + + return int(heures) + + async def import_mock_api_history( start_time: datetime, end_time: datetime, - limit: int, dry_run: bool, ) -> None: settings = get_settings() - - async with create_mock_api_client() as client: - sites = await fetch_sites(client) - - print(f"Sites récupérés : {len(sites)}") - - all_readings: list[dict[str, Any]] = [] - - for site in sites: - site_id = read_text(site, "site_id") - - readings = await fetch_readings( - client=client, - site_id=site_id, - start_time=start_time, - end_time=end_time, - limit=limit, - ) - - print(f"{site_id}: {len(readings)} lectures") - - all_readings.extend(readings) - - print(f"Lectures récupérées : {len(all_readings)}") - - if dry_run: - print("Dry-run terminé : aucune donnée écrite.") - return + limit = limit_for_window(start_time, end_time) engine = create_async_engine( str(settings.database_url), @@ -374,9 +407,40 @@ async def import_mock_api_history( ) try: - async with engine.begin() as connection: + # Garde-fou d'abord, y compris en dry-run : il est en lecture seule, et annoncer un + # succès pour une fenêtre que l'import réel refusera serait trompeur. + async with engine.connect() as connection: await refuse_if_overlaps_historical_dataset(connection, start_time, end_time) + async with create_mock_api_client() as client: + sites = await fetch_sites(client) + + print(f"Sites récupérés : {len(sites)}") + + all_readings: list[dict[str, Any]] = [] + + for site in sites: + site_id = read_text(site, "site_id") + + readings = await fetch_readings( + client=client, + site_id=site_id, + start_time=start_time, + end_time=end_time, + limit=limit, + ) + + print(f"{site_id}: {len(readings)} lectures") + + all_readings.extend(readings) + + print(f"Lectures récupérées : {len(all_readings)}") + + if dry_run: + print("Dry-run terminé : aucune donnée écrite.") + return + + async with engine.begin() as connection: await upsert_sites( connection, sites, @@ -397,7 +461,14 @@ async def import_mock_api_history( def parse_datetime(value: str) -> datetime: - return datetime.fromisoformat(value.replace("Z", "+00:00")) + # Sans fuseau, l'API le traite comme reçu, telle quelle, mais l'encodeur `timestamptz` + # d'asyncpg lirait un datetime naif dans le fuseau *local du processus* (correct dans le + # conteneur Airflow en UTC, décalé de 1-2h pour un import manuel lancé depuis un poste en + # Europe/Paris). Poser `tzinfo=UTC` explicitement, même pattern que `_vers_utc()` dans + # `app/services/reading.py`, garantit que la borne envoyée à l'API et celle comparée en SQL + # (refuse_if_overlaps_historical_dataset) désignent le même instant. + instant = datetime.fromisoformat(value.replace("Z", "+00:00")) + return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC) def parse_args() -> argparse.Namespace: @@ -415,12 +486,6 @@ def parse_args() -> argparse.Namespace: type=parse_datetime, ) - parser.add_argument( - "--limit", - type=int, - default=MAX_LIMIT, - ) - parser.add_argument( "--dry-run", action="store_true", @@ -432,9 +497,6 @@ def parse_args() -> argparse.Namespace: def main() -> None: args = parse_args() - if args.limit < 1 or args.limit > MAX_LIMIT: - raise ValueError(f"--limit doit être compris entre 1 et {MAX_LIMIT}.") - if args.start_time >= args.end_time: raise ValueError("--start-time doit être antérieur à --end-time.") @@ -442,7 +504,6 @@ def main() -> None: import_mock_api_history( start_time=args.start_time, end_time=args.end_time, - limit=args.limit, dry_run=args.dry_run, ) ) diff --git a/apps/backend/tests/etl/test_mock_api_import.py b/apps/backend/tests/etl/test_mock_api_import.py index 562699f..132a718 100644 --- a/apps/backend/tests/etl/test_mock_api_import.py +++ b/apps/backend/tests/etl/test_mock_api_import.py @@ -1,6 +1,6 @@ import json import sys -from datetime import datetime +from datetime import UTC, datetime from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -110,8 +110,8 @@ async def test_fetch_readings_sends_expected_query_parameters() -> None: transport = MockTransport(handler) - start_time = datetime.fromisoformat("2024-06-15T12:00:00") - end_time = datetime.fromisoformat("2024-06-15T13:00:00") + start_time = datetime.fromisoformat("2024-06-15T12:00:00+00:00") + end_time = datetime.fromisoformat("2024-06-15T13:00:00+00:00") async with AsyncClient( transport=transport, @@ -127,11 +127,61 @@ async def test_fetch_readings_sends_expected_query_parameters() -> None: assert len(readings) == 1 assert captured_params["site_id"] == "SITE001" - assert captured_params["start_time"] == "2024-06-15T12:00:00" - assert captured_params["end_time"] == "2024-06-15T13:00:00" + assert captured_params["start_time"] == "2024-06-15T12:00:00+00:00" + assert captured_params["end_time"] == "2024-06-15T13:00:00+00:00" assert captured_params["limit"] == "60" +async def test_fetch_readings_discards_a_reading_outside_the_requested_window() -> None: + # Le garde-fou `refuse_if_overlaps_historical_dataset` ne vérifie que la fenêtre demandée : + # une réponse dont un `timestamp` déborde de `[start_time, end_time)` (bug du mock, ou + # hostile) contournerait ce contrôle si elle atteignait la base telle quelle. + dans_la_fenetre = make_reading() + dans_la_fenetre["timestamp"] = "2024-06-15T12:00:00Z" + + hors_fenetre = make_reading() + hors_fenetre["timestamp"] = "2023-01-01T00:00:00Z" + + def handler(request: Request) -> Response: + return Response(status_code=200, json=[dans_la_fenetre, hors_fenetre]) + + async with AsyncClient( + transport=MockTransport(handler), + base_url="https://mock.test", + ) as client: + readings = await fetch_readings( + client=client, + site_id="SITE001", + start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"), + limit=2, + ) + + assert readings == [dans_la_fenetre] + + +async def test_fetch_readings_discards_a_reading_with_an_unparseable_timestamp() -> None: + invalide = make_reading() + invalide["timestamp"] = "pas une date" + + def handler(request: Request) -> Response: + return Response(status_code=200, json=[invalide]) + + async with AsyncClient( + transport=MockTransport(handler), + base_url="https://mock.test", + ) as client: + readings = await fetch_readings( + client=client, + site_id="SITE001", + start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"), + limit=1, + ) + + assert readings == [] + + async def test_fetch_readings_rejects_non_list_response() -> None: def handler(request: Request) -> Response: return Response( @@ -152,8 +202,8 @@ async def test_fetch_readings_rejects_non_list_response() -> None: await fetch_readings( client=client, site_id="SITE001", - start_time=datetime.fromisoformat("2024-06-15T12:00:00"), - end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"), limit=60, ) @@ -175,8 +225,8 @@ async def test_fetch_readings_raises_on_http_error() -> None: await fetch_readings( client=client, site_id="SITE999", - start_time=datetime.fromisoformat("2024-06-15T12:00:00"), - end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"), limit=60, ) @@ -363,8 +413,8 @@ async def test_refuse_if_overlaps_historical_dataset_lets_a_clear_window_through await mock_api_import.refuse_if_overlaps_historical_dataset( connection, - datetime.fromisoformat("2026-01-01T00:00:00"), - datetime.fromisoformat("2026-01-01T01:00:00"), + datetime.fromisoformat("2026-01-01T00:00:00+00:00"), + datetime.fromisoformat("2026-01-01T01:00:00+00:00"), ) connection.execute.assert_awaited_once() @@ -377,11 +427,57 @@ async def test_refuse_if_overlaps_historical_dataset_rejects_a_window_already_in with pytest.raises(ValueError, match="doublon inter-source"): await mock_api_import.refuse_if_overlaps_historical_dataset( connection, - datetime.fromisoformat("2023-06-15T12:00:00"), - datetime.fromisoformat("2023-06-15T13:00:00"), + datetime.fromisoformat("2023-06-15T12:00:00+00:00"), + datetime.fromisoformat("2023-06-15T13:00:00+00:00"), ) +def test_limit_for_window_returns_one_per_hour() -> None: + limite = mock_api_import.limit_for_window( + datetime.fromisoformat("2026-09-02T12:00:00+00:00"), + datetime.fromisoformat("2026-09-23T12:00:00+00:00"), + ) + + assert limite == 21 * 24 + + +def test_limit_for_window_rejects_a_partial_hour() -> None: + with pytest.raises(ValueError, match="nombre entier d'heures"): + mock_api_import.limit_for_window( + datetime.fromisoformat("2026-09-02T12:00:00+00:00"), + datetime.fromisoformat("2026-09-02T12:30:00+00:00"), + ) + + +def test_limit_for_window_rejects_a_window_above_the_api_cap() -> None: + with pytest.raises(ValueError, match="au-delà du plafond"): + mock_api_import.limit_for_window( + datetime.fromisoformat("2020-01-01T00:00:00+00:00"), + datetime.fromisoformat("2020-03-01T00:00:00+00:00"), + ) + + +def _mock_engine(*, overlap_count: int = 0) -> tuple[MagicMock, AsyncMock]: + """Engine dont `.connect()` (garde-fou) et `.begin()` (écriture) rendent tous deux la même + connexion, dont `scalar_one()` renvoie `overlap_count` : `import_mock_api_history` ouvre + désormais le garde-fou via `.connect()`, y compris en dry-run.""" + connection = AsyncMock() + connection.execute.return_value.scalar_one = MagicMock(return_value=overlap_count) + + def _context() -> MagicMock: + context = MagicMock() + context.__aenter__ = AsyncMock(return_value=connection) + context.__aexit__ = AsyncMock(return_value=None) + return context + + engine = MagicMock() + engine.connect.return_value = _context() + engine.begin.return_value = _context() + engine.dispose = AsyncMock() + + return engine, connection + + async def test_import_mock_api_history_dry_run_does_not_write( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -421,22 +517,24 @@ async def test_import_mock_api_history_dry_run_does_not_write( ), ) - create_engine_mock = MagicMock() + engine, connection = _mock_engine(overlap_count=0) monkeypatch.setattr( mock_api_import, "create_async_engine", - create_engine_mock, + MagicMock(return_value=engine), ) await mock_api_import.import_mock_api_history( - start_time=datetime.fromisoformat("2024-06-15T12:00:00"), - end_time=datetime.fromisoformat("2024-06-15T13:00:00"), - limit=60, + start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"), dry_run=True, ) - create_engine_mock.assert_not_called() + # Le garde-fou tourne quand même (lecture seule), mais aucune écriture n'a lieu. + connection.execute.assert_awaited_once() + engine.begin.assert_not_called() + engine.dispose.assert_awaited_once() async def test_import_mock_api_history_loads_data( @@ -478,24 +576,8 @@ async def test_import_mock_api_history_loads_data( ), ) - connection = AsyncMock() - connection.execute.return_value.scalar_one = MagicMock(return_value=0) - - transaction_context = MagicMock() - transaction_context.__aenter__ = AsyncMock( - return_value=connection, - ) - transaction_context.__aexit__ = AsyncMock( - return_value=None, - ) - - engine = MagicMock() - engine.begin.return_value = transaction_context - engine.dispose = AsyncMock() - - create_engine_mock = MagicMock( - return_value=engine, - ) + engine, connection = _mock_engine(overlap_count=0) + create_engine_mock = MagicMock(return_value=engine) upsert_sites_mock = AsyncMock() @@ -512,9 +594,8 @@ async def test_import_mock_api_history_loads_data( ) await mock_api_import.import_mock_api_history( - start_time=datetime.fromisoformat("2024-06-15T12:00:00"), - end_time=datetime.fromisoformat("2024-06-15T13:00:00"), - limit=60, + start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"), dry_run=False, ) @@ -528,6 +609,7 @@ async def test_import_mock_api_history_loads_data( [make_site()], ) + # Un appel pour le garde-fou (via .connect()), un pour READING_INSERT (via .begin()). assert connection.execute.await_count == 2 dernier_appel = connection.execute.await_args_list[-1] assert dernier_appel.args[0] is READING_INSERT @@ -537,49 +619,29 @@ async def test_import_mock_api_history_loads_data( async def test_import_mock_api_history_refuses_when_it_overlaps_the_historical_dataset( monkeypatch: pytest.MonkeyPatch, ) -> None: - def handler(request: Request) -> Response: - if request.url.path == "/api/v1/sites": - return Response(status_code=200, json=[make_site()]) - - if request.url.path == "/api/v1/readings": - return Response(status_code=200, json=[make_reading()]) - - return Response(status_code=404) - - client = AsyncClient(transport=MockTransport(handler), base_url="https://mock.test") - - monkeypatch.setattr(mock_api_import, "create_mock_api_client", lambda: client) monkeypatch.setattr( mock_api_import, "get_settings", lambda: SimpleNamespace(database_url="postgresql+asyncpg://test:test@localhost/test"), ) - connection = AsyncMock() - connection.execute.return_value.scalar_one = MagicMock(return_value=3) - - transaction_context = MagicMock() - transaction_context.__aenter__ = AsyncMock(return_value=connection) - transaction_context.__aexit__ = AsyncMock(return_value=None) - - engine = MagicMock() - engine.begin.return_value = transaction_context - engine.dispose = AsyncMock() - + engine, connection = _mock_engine(overlap_count=3) monkeypatch.setattr(mock_api_import, "create_async_engine", MagicMock(return_value=engine)) - upsert_sites_mock = AsyncMock() - monkeypatch.setattr(mock_api_import, "upsert_sites", upsert_sites_mock) + + # Le garde-fou tourne avant tout appel à l'API Mock : create_mock_api_client() ne doit + # jamais être invoqué pour une fenêtre refusée. + create_client_mock = MagicMock() + monkeypatch.setattr(mock_api_import, "create_mock_api_client", create_client_mock) with pytest.raises(ValueError, match="doublon inter-source"): await mock_api_import.import_mock_api_history( - start_time=datetime.fromisoformat("2023-06-15T12:00:00"), - end_time=datetime.fromisoformat("2023-06-15T13:00:00"), - limit=60, + start_time=datetime.fromisoformat("2023-06-15T12:00:00+00:00"), + end_time=datetime.fromisoformat("2023-06-15T13:00:00+00:00"), dry_run=False, ) connection.execute.assert_awaited_once() - upsert_sites_mock.assert_not_awaited() + create_client_mock.assert_not_called() engine.dispose.assert_awaited_once() @@ -593,6 +655,23 @@ def test_parse_datetime_accepts_z_suffix() -> None: ) +def test_parse_datetime_attaches_utc_to_a_naive_string() -> None: + # `--start-time`/`--end-time` du DAG sont formatés sans fuseau (Jinja `strftime`) : sans ce + # comportement, l'encodeur `timestamptz` d'asyncpg lirait le datetime naïf dans le fuseau + # *local du processus*, pas UTC, et le garde-fou comparerait une autre fenêtre que celle + # envoyée à l'API. + result = mock_api_import.parse_datetime("2024-06-15T12:00:00") + + assert result == datetime.fromisoformat("2024-06-15T12:00:00+00:00") + assert result.tzinfo is UTC + + +def test_parse_datetime_keeps_a_non_utc_offset_as_is() -> None: + result = mock_api_import.parse_datetime("2024-06-15T12:00:00+02:00") + + assert result == datetime.fromisoformat("2024-06-15T12:00:00+02:00") + + def test_parse_args_reads_cli_parameters( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -605,8 +684,6 @@ def test_parse_args_reads_cli_parameters( "2024-06-15T12:00:00Z", "--end-time", "2024-06-15T13:00:00Z", - "--limit", - "60", "--dry-run", ], ) @@ -619,34 +696,9 @@ def test_parse_args_reads_cli_parameters( assert args.end_time == datetime.fromisoformat( "2024-06-15T13:00:00+00:00", ) - assert args.limit == 60 assert args.dry_run is True -def test_main_rejects_limit_out_of_bounds( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - sys, - "argv", - [ - "mock_api_import", - "--start-time", - "2024-06-15T12:00:00Z", - "--end-time", - "2024-06-15T13:00:00Z", - "--limit", - "0", - ], - ) - - with pytest.raises( - ValueError, - match="--limit doit être compris entre 1 et 1000", - ): - mock_api_import.main() - - def test_main_rejects_invalid_period( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -659,8 +711,6 @@ def test_main_rejects_invalid_period( "2024-06-15T14:00:00Z", "--end-time", "2024-06-15T13:00:00Z", - "--limit", - "60", ], ) @@ -689,7 +739,6 @@ def test_main_runs_import( lambda: SimpleNamespace( start_time=start_time, end_time=end_time, - limit=60, dry_run=True, ), ) @@ -705,7 +754,6 @@ def test_main_runs_import( import_mock.assert_awaited_once_with( start_time=start_time, end_time=end_time, - limit=60, dry_run=True, ) @@ -750,8 +798,8 @@ async def test_fetch_readings_rejects_a_response_above_the_requested_limit() -> await fetch_readings( client=client, site_id="SITE001", - start_time=datetime.fromisoformat("2024-06-15T12:00:00"), - end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"), limit=2, ) diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index bdef60d..7a0666d 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -80,7 +80,7 @@ API Mock est accepté comme définitivement perdu, aucune mesure réelle n'exist période. `mock_api_import` refuse toute fenêtre qui recouvrirait des lectures déjà importées du CSV plutôt que de laisser les deux sources dupliquer silencieusement un même instant, et le pipeline ML déduplique par construction (`DISTINCT ON`, source `csv` préférée) au cas où un -recouvrement se produirait malgré tout — voir [40-data.md](40-data.md). +recouvrement se produirait malgré tout, voir [40-data.md](40-data.md). Les liens de la supervision sont en trait plein depuis le 23/09 (issue #26) : Prometheus scrute `/metrics` avec un jeton, Grafana lit Prometheus et, par un rôle en lecture seule, les tables diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index 9ca6706..88dc21d 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -437,19 +437,23 @@ Les paramètres de ligne de commande disponibles pour l'import sont : ```text --start-time --end-time ---limit --dry-run ``` -**Piège sur `--limit`** : l'API ne renvoie pas un flux à un rythme naturel, elle répartit -exactement `limit` lectures, espacées uniformément, sur toute la fenêtre `[start_time, end_time)` -demandée. Une fenêtre d'une heure avec `limit=1000` renvoie donc 1000 lectures espacées de 3,6 -secondes à l'intérieur de cette heure, pas une lecture horaire — vérifié empiriquement en -interrogeant directement l'API. Le seul réglage qui produise une lecture par heure, alignée sur -l'heure et cohérente avec le grain horaire du reste du schéma (`period_minutes=60`, historique -CSV à une ligne par heure), est `limit` = nombre d'heures de la fenêtre. Le DAG `mock_api_import` -interroge toujours une fenêtre d'1h (`interval=timedelta(hours=1)`, voir -[10-infra.md](10-infra.md)), donc `limit=1`. +**Piège sur `limit`, corrigé dans le code plutôt que documenté** : l'API ne renvoie pas un flux à +un rythme naturel, elle répartit exactement `limit` lectures, espacées uniformément, sur toute la +fenêtre `[start_time, end_time)` demandée (vérifié empiriquement en interrogeant directement +l'API). Une fenêtre d'une heure avec `limit=1000`, le réglage d'origine, renvoyait donc 1000 +lectures espacées de 3,6 secondes à l'intérieur de cette heure, pas une lecture horaire, +incompatible avec les lags positionnels de `build_features`. Plutôt que documenter la règle +« `limit` = nombre d'heures de la fenêtre » et compter sur chaque appelant pour la respecter, +`limit_for_window()` la porte : `import_mock_api_history()` calcule `limit` depuis la fenêtre +reçue, refuse une fenêtre qui ne couvre pas un nombre entier d'heures, et refuse un intervalle de +plus de 1000 heures (le plafond `limit` de l'API). `--limit` n'existe donc plus côté CLI. Le DAG +`mock_api_import` interroge toujours une fenêtre d'1h (`interval=timedelta(hours=1)`, voir +[10-infra.md](10-infra.md)) : la fenêtre `[:45, :45)` place chaque lecture à :45, pas à :00 (la +première lecture atterrit au début de la fenêtre demandée), un décalage constant sans effet sur +les lags positionnels ni sur les jointures en aval. ### Flux d'ingestion API Mock @@ -528,22 +532,43 @@ deux dans `reading`. Trois décisions ferment cette réconciliation : - **Le trou temporel est accepté.** Le dataset historique s'arrête au 31/12/2024, et `mock_api_import` n'importe que l'heure précédant chaque déclenchement : rien ne comble - automatiquement la période intermédiaire, et rien ne le pourra jamais — aucune mesure réelle - n'existe pour ces instants. + automatiquement la période intermédiaire, et rien ne le pourra jamais, aucune mesure réelle + n'existe pour ces instants. Conséquence pour le ML, pas nouvelle mais que ce trou rend + définitive : `build_features()` calcule ses lags par `shift(n)` positionnel, et `train.py` + n'écarte que les lignes où `lag_168h` est `NaN`. Pour un site présent dans les deux sources, les + 168 premières lectures `api_history` qui suivent le trou héritent donc de lags et de moyennes + glissantes calculés sur décembre 2024 (et tant que l'ingestion a moins de 7 jours, c'est le cas + de toutes les lectures). Même effet, plus ponctuel, pour chaque heure que le DAG manque + (`mock_api_import` en échec, Airflow arrêté). Aucun garde-fou ne détecte aujourd'hui un lag + calculé sur un écart réel différent de celui attendu ; issue de suivi à ouvrir. - **Le recouvrement est refusé à l'ingestion.** `uq_reading_source` autorise deux lignes au même `(site_id, timestamp)` dès que `source` diffère : rien dans le schéma n'empêche donc un import Mock API manuel avec une fenêtre passée (le script accepte `--start-time`/`--end-time` arbitraires) de dupliquer un point déjà couvert par le CSV. `import_mock_api_history()` appelle - `refuse_if_overlaps_historical_dataset()` avant toute écriture : si la fenêtre demandée recouvre - au moins une lecture `source='csv'`, l'import est refusé (`ValueError`) plutôt que d'écrire un - doublon inter-source silencieux. + `refuse_if_overlaps_historical_dataset()` avant toute écriture, y compris en `--dry-run` (le + contrôle est en lecture seule) et avant le moindre appel à l'API Mock : si la fenêtre demandée + recouvre au moins une lecture `source='csv'`, l'import est refusé (`ValueError`) plutôt que + d'écrire un doublon inter-source silencieux. Le contrôle ne porte que sur la fenêtre demandée, + pas sur les lectures reçues : `fetch_readings()` écarte donc toute lecture dont le `timestamp` + déborde de `[start_time, end_time)`, pour qu'une réponse hors fenêtre (bug du mock, ou hostile) + ne puisse pas le contourner. Ce contrôle compare des instants, pas des chaînes : `parse_datetime()` + pose `tzinfo=UTC` sur une entrée sans fuseau (même pattern que `_vers_utc()` dans + `app/services/reading.py`), sans quoi l'encodeur `timestamptz` d'asyncpg lirait un datetime naïf + dans le fuseau local du **processus**, correct dans le conteneur Airflow (UTC) mais décalé pour + un import manuel lancé depuis un poste en Europe/Paris. - **Le pipeline ML déduplique en défense.** Le garde-fou ci-dessus protège l'ingestion, pas la lecture : si un recouvrement se produisait malgré tout (import direct en base, contournement du script), `ml/enervision_ml/data.py` ne doit pas casser silencieusement l'hypothèse de `build_features` (« une ligne par `(site_id, timestamp)` »). `load_from_database()` et `load_recent_from_database()` utilisent donc `SELECT DISTINCT ON (site_id, timestamp)`, `source - = 'csv'` gagnant sur `'api_history'` en cas d'égalité — l'historique étant une source vérifiée, - l'API Mock une entrée hostile (cf. ci-dessus). + = 'csv'` gagnant sur `'api_history'` en cas d'égalité, l'historique étant une source vérifiée, + l'API Mock une entrée hostile (cf. ci-dessus). **Cette préférence est spécifique au chargeur + ML.** `GET /readings` renvoie les deux lignes sans les fusionner, et `DriftRepository` / + `ReadingRepository.latest_by_site()` / `.latest_for_site()` départagent par `reading_id` le plus + grand (en pratique la ligne insérée en dernier, pas forcément `csv`) : en cas de recouvrement, la + dérive comparerait alors une prévision à une valeur différente de celle sur laquelle le modèle a + appris. Pas d'incohérence aujourd'hui tant que le recouvrement reste refusé à l'ingestion ; à + aligner si ce garde-fou devait un jour être contourné. ### Qualité des données de l'API Mock diff --git a/etl/airflow/dags/mock_api_import.py b/etl/airflow/dags/mock_api_import.py index e649be5..f81bc60 100644 --- a/etl/airflow/dags/mock_api_import.py +++ b/etl/airflow/dags/mock_api_import.py @@ -18,16 +18,6 @@ from airflow.timetables.trigger import CronTriggerTimetable # Le backend possède son propre environnement uv dans l'image Airflow (ADR 0008). COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m" -# L'API Mock ne renvoie pas un flux au rythme naturel : elle répartit exactement `limit` -# lectures, espacées uniformément, sur toute la fenêtre demandée (vérifié empiriquement : -# une fenêtre d'1h avec `limit=1000` renvoie 1000 lectures espacées de 3,6s à l'intérieur de -# cette heure, pas une lecture horaire). Comme la fenêtre de ce DAG est toujours 1h -# (`interval=timedelta(hours=1)` ci-dessous), `limit=1` est ce qui produit une lecture par -# heure, alignée sur l'heure, cohérente avec le grain horaire du reste du schéma -# (`period_minutes=60`, historique CSV à 1 ligne/heure). Un `limit` plus grand ici fabriquerait -# des lectures infra-horaires, incompatibles avec les lags positionnels de `build_features`. -LIMITE_LECTURES = 1 - # 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 @@ -36,7 +26,11 @@ 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. +# exécuté à l'heure pile, puis avant `alertes`, exécuté à :15. Conséquence vérifiée empiriquement +# sur l'API Mock (cf. `limit_for_window()` dans `app.etl.mock_api_import`) : la fenêtre importée +# est `[:45, :45)`, donc chaque lecture atterrit à :45, pas à :00, un décalage constant sur +# toute la série, sans effet sur les lags positionnels ni sur les jointures en aval (`ml_score` +# vise la dernière lecture + 1h, `derive` joint à l'égalité), cf. 40-data.md. PLANIFICATION = CronTriggerTimetable( "45 * * * *", timezone="UTC", @@ -58,8 +52,11 @@ with DAG( 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}" + "--end-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:%M:%S') }}\"" + # Pas de --limit : app.etl.mock_api_import.limit_for_window() le dérive de la + # fenêtre (une lecture/heure), et refuse une fenêtre qui ne couvre pas un nombre + # entier d'heures ou dépasse le plafond de l'API. Porter la règle dans le code, + # pas dans ce DAG, évite qu'un appel manuel oublie de la respecter. ), retries=NOMBRE_REPRISES, retry_delay=DELAI_ENTRE_REPRISES, diff --git a/etl/airflow/tests/test_dags.py b/etl/airflow/tests/test_dags.py index 4e4e077..7227486 100644 --- a/etl/airflow/tests/test_dags.py +++ b/etl/airflow/tests/test_dags.py @@ -118,7 +118,8 @@ def test_mock_api_import_uses_the_airflow_data_interval(dagbag: DagBag) -> None: 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 1" in commande + # Pas de --limit : app.etl.mock_api_import.limit_for_window() le dérive de la fenêtre. + assert "--limit" not in commande @pytest.mark.parametrize("task_id", ["detection", "recommandations"]) diff --git a/ml/tests/test_data_integration.py b/ml/tests/test_data_integration.py index 9555d02..0273c19 100644 --- a/ml/tests/test_data_integration.py +++ b/ml/tests/test_data_integration.py @@ -45,11 +45,13 @@ def test_load_from_database_deduplicates_two_sources_at_the_same_instant( # `uq_reading_source` autorise deux lignes au meme (site_id, timestamp) des que `source` # differe : le garde-fou vit dans `mock_api_import.py`, pas dans le schema. Le chargeur ML # doit donc imposer lui-meme "une ligne par (site_id, timestamp)", pas la supposer. + # + # `csv` est inseree en premier (reading_id le plus bas) et `api_history` en second (le plus + # haut) : un depart par `reading_id DESC` seul choisirait `api_history` a tort. Seule la + # preference explicite pour `source='csv'` fait gagner le bon reading_id ici, et le test + # cesserait de proteger cette regle si l'ordre d'insertion etait inverse. site_id = insere_site(connexion_ml) dataset_id = insere_dataset(connexion_ml) - insere_lecture( - connexion_ml, site_id, instant=ANCRAGE, consumption_kwh=10.0, source="api_history" - ) insere_lecture( connexion_ml, site_id, @@ -58,6 +60,9 @@ def test_load_from_database_deduplicates_two_sources_at_the_same_instant( source="csv", dataset_id=dataset_id, ) + insere_lecture( + connexion_ml, site_id, instant=ANCRAGE, consumption_kwh=10.0, source="api_history" + ) frame = load_from_database(connexion_ml) @@ -69,11 +74,10 @@ def test_load_from_database_deduplicates_two_sources_at_the_same_instant( def test_load_recent_from_database_prefers_csv_when_two_sources_share_an_instant( connexion_ml: Connection, ) -> None: + # Meme ordre d'insertion que ci-dessus, et pour la meme raison : `csv` doit gagner malgre un + # `reading_id` plus bas que celui d'`api_history`. site_id = insere_site(connexion_ml) dataset_id = insere_dataset(connexion_ml) - insere_lecture( - connexion_ml, site_id, instant=ANCRAGE, consumption_kwh=10.0, source="api_history" - ) insere_lecture( connexion_ml, site_id, @@ -82,6 +86,9 @@ def test_load_recent_from_database_prefers_csv_when_two_sources_share_an_instant source="csv", dataset_id=dataset_id, ) + insere_lecture( + connexion_ml, site_id, instant=ANCRAGE, consumption_kwh=10.0, source="api_history" + ) frame = load_recent_from_database( connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3) From 14eed08ff5e4f9af380aaa19978a5b31d5dc460b Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 23 Sep 2026 15:36:46 +0200 Subject: [PATCH 03/10] fix(mock_api): remove merge conflict markers --- apps/backend/app/etl/mock_api_import.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/backend/app/etl/mock_api_import.py b/apps/backend/app/etl/mock_api_import.py index ca24d2b..71e2e32 100644 --- a/apps/backend/app/etl/mock_api_import.py +++ b/apps/backend/app/etl/mock_api_import.py @@ -184,9 +184,7 @@ async def upsert_sites( ) -def _timestamp_in_window( - reading: dict[str, Any], start_time: datetime, end_time: datetime -) -> bool: +def _timestamp_in_window(reading: dict[str, Any], start_time: datetime, end_time: datetime) -> bool: valeur = reading.get("timestamp") if not isinstance(valeur, str): return False From 49175d8ff77c9e056c4153c02c693cd0a890810a Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 23 Sep 2026 15:55:12 +0200 Subject: [PATCH 04/10] fix: conflict --- apps/backend/app/etl/mock_api_import.py | 42 +++++++++++++------ .../backend/tests/etl/test_mock_api_import.py | 31 ++++++++++++-- docs/architecture/10-infra.md | 6 ++- docs/architecture/40-data.md | 29 +++++++------ 4 files changed, 77 insertions(+), 31 deletions(-) diff --git a/apps/backend/app/etl/mock_api_import.py b/apps/backend/app/etl/mock_api_import.py index 71e2e32..dc1ddb6 100644 --- a/apps/backend/app/etl/mock_api_import.py +++ b/apps/backend/app/etl/mock_api_import.py @@ -367,28 +367,44 @@ def limit_for_window(start_time: datetime, end_time: datetime) -> int: """Nombre de lectures à demander pour que l'API Mock en rende une par heure, alignée. L'API ne renvoie pas un flux à un rythme naturel : elle répartit exactement `limit` lectures, - espacées uniformément, sur toute la fenêtre `[start_time, end_time)` demandée (vérifié - empiriquement). `limit` = nombre d'heures de la fenêtre est donc le seul réglage cohérent avec - le grain horaire du reste du schéma (`period_minutes=60`, historique CSV à une ligne/heure) ; - un `limit` plus grand fabriquerait des lectures infra-horaires, incompatibles avec les lags - positionnels de `build_features`. La fenêtre doit donc couvrir un nombre entier d'heures. + espacées uniformément, sur toute la fenêtre `[start_time, end_time)` demandée, la première + au tout début de la fenêtre (vérifié empiriquement). Deux façons d'obtenir une lecture + alignée sur l'heure : + + - une fenêtre d'exactement N heures (`start_time` sur l'heure) donne, avec `limit=N`, N + lectures espacées d'1h pile, la première à `start_time` : c'est le chemin du backfill + manuel (plusieurs jours d'historique en un seul appel). + - une fenêtre plus courte qu'une heure, ou qui n'est pas un multiple entier d'heure, ne peut + espacer plusieurs lectures d'1h pile (l'espacement de l'API vaut toujours + `durée / limit`) : seule `limit=1` reste alignée, la lecture unique atterrissant à + `start_time`. C'est le chemin du DAG horaire, dont la fenêtre part de l'heure pile qui + précède son déclenchement jusqu'à l'instant du déclenchement lui-même (`:45`), donc plus + courte qu'une heure. + + Dans les deux cas, `start_time` doit tomber pile sur l'heure : c'est elle qui ancre + l'alignement, jamais `end_time`. Un `limit` plus grand que celui rendu ici fabriquerait des + lectures infra-horaires, incompatibles avec les lags positionnels de `build_features`. """ + if start_time.minute or start_time.second or start_time.microsecond: + raise ValueError( + f"La fenêtre doit démarrer pile sur l'heure : {start_time.isoformat()} ne l'est pas." + ) + duree = end_time - start_time heures, reste = divmod(duree.total_seconds(), 3600) - if reste != 0: - raise ValueError( - "La fenêtre doit couvrir un nombre entier d'heures pour obtenir une lecture par " - f"heure alignée : [{start_time.isoformat()}, {end_time.isoformat()}) n'en couvre pas." - ) + # Fenêtre plus courte qu'une heure, ou pas un multiple entier : aucun `limit` supérieur à 1 + # n'espacerait ses lectures d'1h pile (l'espacement vaut toujours durée / limit). Seule la + # lecture unique, ancrée sur `start_time`, reste alignée. + limit = int(heures) if reste == 0 and heures >= 1 else 1 - if heures > MAX_LIMIT: + if limit > MAX_LIMIT: raise ValueError( - f"La fenêtre demandée couvre {int(heures)}h, au-delà du plafond de {MAX_LIMIT} " + f"La fenêtre demandée couvre {limit}h, au-delà du plafond de {MAX_LIMIT} " "lectures accepté par l'API Mock." ) - return int(heures) + return limit async def import_mock_api_history( diff --git a/apps/backend/tests/etl/test_mock_api_import.py b/apps/backend/tests/etl/test_mock_api_import.py index 132a718..5156077 100644 --- a/apps/backend/tests/etl/test_mock_api_import.py +++ b/apps/backend/tests/etl/test_mock_api_import.py @@ -441,11 +441,34 @@ def test_limit_for_window_returns_one_per_hour() -> None: assert limite == 21 * 24 -def test_limit_for_window_rejects_a_partial_hour() -> None: - with pytest.raises(ValueError, match="nombre entier d'heures"): +def test_limit_for_window_falls_back_to_one_reading_under_an_hour() -> None: + # Le DAG horaire (`:45`) demande desormais [heure pile precedente, instant du declenchement) : + # une fenetre plus courte qu'une heure, dont l'espacement `duree/limit` ne peut jamais valoir + # 1h pile pour plus d'une lecture. Seule `limit=1`, ancree sur `start_time`, reste alignee. + limite = mock_api_import.limit_for_window( + datetime.fromisoformat("2026-09-02T12:00:00+00:00"), + datetime.fromisoformat("2026-09-02T12:45:00+00:00"), + ) + + assert limite == 1 + + +def test_limit_for_window_falls_back_to_one_reading_for_a_non_whole_hour_span() -> None: + # Meme raisonnement pour une fenetre de plus d'une heure mais qui n'en est pas un multiple + # entier : aucun `limit > 1` ne donnerait un espacement d'1h pile. + limite = mock_api_import.limit_for_window( + datetime.fromisoformat("2026-09-02T12:00:00+00:00"), + datetime.fromisoformat("2026-09-02T13:30:00+00:00"), + ) + + assert limite == 1 + + +def test_limit_for_window_rejects_a_start_time_not_on_the_hour() -> None: + with pytest.raises(ValueError, match="pile sur l'heure"): mock_api_import.limit_for_window( - datetime.fromisoformat("2026-09-02T12:00:00+00:00"), - datetime.fromisoformat("2026-09-02T12:30:00+00:00"), + datetime.fromisoformat("2026-09-02T12:05:00+00:00"), + datetime.fromisoformat("2026-09-02T13:05:00+00:00"), ) diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md index 9b47cc7..b35e56f 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -99,8 +99,10 @@ 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 +déclenchement manuel, mais la fenêtre transmise au script backend part de l'heure pile qui +précède le déclenchement (pas de l'intervalle Airflow tel quel), pour que la mesure importée +tombe à :00 et non à :45, voir [40-data.md](40-data.md). Le pipeline charge la mesure dans les +tables communes `site` et `reading`. Le décalage à `:45` laisse quinze minutes avant le scoring exécuté à l'heure pile, puis quinze minutes supplémentaires avant les alertes à `:15`. `max_active_runs=1` empêche deux exécutions du DAG de se chevaucher. diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index 88dc21d..91ae347 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -442,18 +442,23 @@ Les paramètres de ligne de commande disponibles pour l'import sont : **Piège sur `limit`, corrigé dans le code plutôt que documenté** : l'API ne renvoie pas un flux à un rythme naturel, elle répartit exactement `limit` lectures, espacées uniformément, sur toute la -fenêtre `[start_time, end_time)` demandée (vérifié empiriquement en interrogeant directement -l'API). Une fenêtre d'une heure avec `limit=1000`, le réglage d'origine, renvoyait donc 1000 -lectures espacées de 3,6 secondes à l'intérieur de cette heure, pas une lecture horaire, -incompatible avec les lags positionnels de `build_features`. Plutôt que documenter la règle -« `limit` = nombre d'heures de la fenêtre » et compter sur chaque appelant pour la respecter, -`limit_for_window()` la porte : `import_mock_api_history()` calcule `limit` depuis la fenêtre -reçue, refuse une fenêtre qui ne couvre pas un nombre entier d'heures, et refuse un intervalle de -plus de 1000 heures (le plafond `limit` de l'API). `--limit` n'existe donc plus côté CLI. Le DAG -`mock_api_import` interroge toujours une fenêtre d'1h (`interval=timedelta(hours=1)`, voir -[10-infra.md](10-infra.md)) : la fenêtre `[:45, :45)` place chaque lecture à :45, pas à :00 (la -première lecture atterrit au début de la fenêtre demandée), un décalage constant sans effet sur -les lags positionnels ni sur les jointures en aval. +fenêtre `[start_time, end_time)` demandée, la première au tout début de la fenêtre (vérifié +empiriquement en interrogeant directement l'API). Une fenêtre d'une heure avec `limit=1000`, le +réglage d'origine, renvoyait donc 1000 lectures espacées de 3,6 secondes à l'intérieur de cette +heure, pas une lecture horaire, incompatible avec les lags positionnels de `build_features`. +Plutôt que documenter la règle « `limit` = nombre d'heures de la fenêtre » et compter sur chaque +appelant pour la respecter, `limit_for_window()` la porte : `import_mock_api_history()` calcule +`limit` depuis la fenêtre reçue, refuse une fenêtre dont `start_time` ne tombe pas pile sur +l'heure (c'est elle qui ancre l'alignement), et refuse un intervalle de plus de 1000 heures (le +plafond `limit` de l'API). `--limit` n'existe donc plus côté CLI. Deux formes de fenêtre sont +gérées : un multiple entier d'heures (`limit` = ce nombre d'heures, une lecture par heure +espacée d'1h pile, chemin du backfill manuel) ou une fenêtre plus courte qu'une heure, ou qui +n'en est pas un multiple entier (`limit=1`, seule valeur qui reste alignée quand l'espacement +`durée / limit` ne peut valoir 1h pile). Le DAG `mock_api_import` est dans ce second cas : il +demande la fenêtre `[heure pile précédant le déclenchement, instant du déclenchement)`, plus +courte qu'une heure, plutôt que l'intervalle Airflow `[data_interval_start, data_interval_end)` +tel quel (`[:45, :45)`) qui aurait placé l'unique lecture à :45, hors de la grille horaire du +reste du schéma. ### Flux d'ingestion API Mock From e0089537d0eecec8f0e501ccc73e6822d3ab7e7b Mon Sep 17 00:00:00 2001 From: Valentin Date: Wed, 23 Sep 2026 16:27:03 +0200 Subject: [PATCH 05/10] =?UTF-8?q?feat(garage):=20configuration=20Docker=20?= =?UTF-8?q?Compose=20et=20tests=20de=20fum=C3=A9e=20S3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++ garage/docker-compose.yml | 17 ++++++++++ garage/garage.toml.example | 20 +++++++++++ garage/tests/test_garage_smoke.py | 55 +++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+) create mode 100644 garage/docker-compose.yml create mode 100644 garage/garage.toml.example create mode 100644 garage/tests/test_garage_smoke.py diff --git a/.gitignore b/.gitignore index a0a805b..6c15e6f 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,6 @@ infra/proxy/acme/ *.swp .DS_Store Thumbs.db + +# Docker garage +garage/garage.toml diff --git a/garage/docker-compose.yml b/garage/docker-compose.yml new file mode 100644 index 0000000..b91b6a9 --- /dev/null +++ b/garage/docker-compose.yml @@ -0,0 +1,17 @@ +services: + garage: + image: dxflrs/garage:v1.0.1 + container_name: enervision-garage + ports: + - "127.0.0.1:3900:3900" + - "127.0.0.1:3901:3901" + - "127.0.0.1:3903:3903" + volumes: + - ./garage.toml:/etc/garage.toml:ro + - garage-meta:/var/lib/garage/meta + - garage-data:/var/lib/garage/data + restart: unless-stopped + +volumes: + garage-meta: + garage-data: diff --git a/garage/garage.toml.example b/garage/garage.toml.example new file mode 100644 index 0000000..b90b729 --- /dev/null +++ b/garage/garage.toml.example @@ -0,0 +1,20 @@ +metadata_dir = "/var/lib/garage/meta" +data_dir = "/var/lib/garage/data" +db_engine = "sqlite" + +replication_factor = 1 + +rpc_bind_addr = "[::]:3901" +rpc_public_addr = "127.0.0.1:3901" +# Générer la vôtre : python -c "import secrets; print(secrets.token_hex(32))" +rpc_secret = "change_me" + +[s3_api] +s3_region = "garage" +api_bind_addr = "[::]:3900" +root_domain = ".s3.garage.localhost" + +[admin] +api_bind_addr = "[::]:3903" +# Générer la vôtre avec la même commande que rpc_secret +admin_token = "change_me" diff --git a/garage/tests/test_garage_smoke.py b/garage/tests/test_garage_smoke.py new file mode 100644 index 0000000..da4b9b4 --- /dev/null +++ b/garage/tests/test_garage_smoke.py @@ -0,0 +1,55 @@ +""" +Tests de fumée pour Garage — vérifie que le cluster local accepte +vraiment des écritures et les rend intactes, pas juste qu'il démarre. +""" +import os +import uuid + +import boto3 +import pytest + +ENDPOINT_URL = "http://127.0.0.1:3900" +ACCESS_KEY = os.environ["GARAGE_ACCESS_KEY"] +SECRET_KEY = os.environ["GARAGE_SECRET_KEY"] +BUCKET = "enervision-raw-test" + + +@pytest.fixture +def client(): + return boto3.client( + "s3", + endpoint_url=ENDPOINT_URL, + aws_access_key_id=ACCESS_KEY, + aws_secret_access_key=SECRET_KEY, + region_name="garage", + ) + + +def test_bucket_exists(client): + response = client.list_buckets() + noms = [b["Name"] for b in response["Buckets"]] + assert BUCKET in noms + + +def test_upload_and_download_roundtrip(client): + cle = f"test-{uuid.uuid4()}.txt" + contenu = b"contenu de test EnerVision" + + client.put_object(Bucket=BUCKET, Key=cle, Body=contenu) + + reponse = client.get_object(Bucket=BUCKET, Key=cle) + recupere = reponse["Body"].read() + + assert recupere == contenu + + client.delete_object(Bucket=BUCKET, Key=cle) + + +def test_deleted_object_is_really_gone(client): + cle = f"test-suppression-{uuid.uuid4()}.txt" + client.put_object(Bucket=BUCKET, Key=cle, Body=b"a supprimer") + + client.delete_object(Bucket=BUCKET, Key=cle) + + with pytest.raises(client.exceptions.NoSuchKey): + client.get_object(Bucket=BUCKET, Key=cle) From e53c7e441ca0814faf982bc162763efc0781bee0 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 24 Sep 2026 10:30:04 +0200 Subject: [PATCH 06/10] =?UTF-8?q?feat(infra):=20d=C3=A9ploie=20Garage=20pa?= =?UTF-8?q?r=20environnement,=20secrets=20par=20le=20.env,=20fum=C3=A9e=20?= =?UTF-8?q?S3=20en=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reprend l'amorce de la PR 164 et l'intègre à la stack : service `garage` (dxflrs/garage v2.4.1, `--single-node --default-bucket`) dans docker-compose.yml, garage.toml versionné sans secret, ports sur 127.0.0.1 décalés par environnement dans provision-host.sh, garde des six clés GARAGE_* dans le Makefile, cible Prometheus avec jeton, tests de fumée déplacés dans tests/garage et joués par le job compose d'infra.yml contre le vrai conteneur, SSE-C compris. Variables APP_S3_* et APP_READING_RETENTION_DAYS posées sur airflow-scheduler pour le DAG `retention`. ADR 0019. Closes #24 --- .env.example | 25 +++++ .github/workflows/ci.yml | 2 + .github/workflows/infra.yml | 33 +++++++ .gitignore | 3 - Makefile | 16 ++- README.md | 7 +- docker-compose.yml | 45 +++++++++ ...bjet-garage-et-cycle-de-vie-des-mesures.md | 97 +++++++++++++++++++ docs/architecture/00-vue-ensemble.md | 7 ++ docs/architecture/60-observabilite.md | 4 + garage/docker-compose.yml | 17 ---- garage/garage.toml.example | 20 ---- garage/tests/test_garage_smoke.py | 55 ----------- infra/garage/README.md | 25 +++++ infra/garage/garage.toml | 22 +++++ monitoring/README.md | 4 +- monitoring/prometheus/prometheus.yml | 7 ++ scripts/provision-host.sh | 18 +++- tests/garage/test_smoke.py | 70 +++++++++++++ 19 files changed, 372 insertions(+), 105 deletions(-) create mode 100644 docs/adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md delete mode 100644 garage/docker-compose.yml delete mode 100644 garage/garage.toml.example delete mode 100644 garage/tests/test_garage_smoke.py create mode 100644 infra/garage/README.md create mode 100644 infra/garage/garage.toml create mode 100644 tests/garage/test_smoke.py diff --git a/.env.example b/.env.example index 78243da..56198db 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,31 @@ AIRFLOW_ADMIN_EMAIL=admin@enervision.fr # python -c "import secrets; print(secrets.token_urlsafe(48))" AIRFLOW_APP_SECRET_KEY=change_me +# Garage, stockage objet S3 par environnement (ADR 0019) : un conteneur par projet Compose, publié +# sur 127.0.0.1 seulement. Les six secrets ci-dessous sont exigés par `make services-up` et +# `make stack-up` ; scripts/provision-host.sh les génère sur la VM. +# 32 octets en hexadécimal, rien d'autre n'est accepté : openssl rand -hex 32 +GARAGE_RPC_SECRET=change_me +# Jetons de l'API d'administration et de /metrics (port 3903). Même générateur qu'APP_SECRET_KEY. +GARAGE_ADMIN_TOKEN=change_me +GARAGE_METRICS_TOKEN=change_me +# Clé S3 créée au premier démarrage (`--default-bucket`). Identifiant : echo "GK$(openssl rand -hex 12)" +# Secret : openssl rand -hex 32. Ne plus le changer ensuite, Garage refuserait de démarrer. +GARAGE_ACCESS_KEY=change_me +GARAGE_SECRET_KEY=change_me +GARAGE_BUCKET=enervision-archives +# Ports S3 et admin sur 127.0.0.1. Recette : 3910 et 3913, dev : 3920 et 3923. +GARAGE_S3_PORT=3900 +GARAGE_ADMIN_PORT=3903 + +# Rétention des mesures (ADR 0019, 0020) : le DAG `retention` exporte chaque nuit vers Garage les +# chunks de `reading` plus vieux que cette borne, puis les supprime. L'historique de démonstration +# s'arrête fin 2024 : sous 21 mois, la démo disparaîtrait. +READING_RETENTION_DAYS=1095 +# Clé SSE-C des archives, 32 octets en base64 : openssl rand -base64 32. La perdre rend les +# archives illisibles ; la sauvegarder hors de la VM. +GARAGE_SSE_KEY=change_me + # Stack complète derrière le reverse proxy (docker-compose.prod.yml). # PUBLIC_HOST alimente l'origine CORS, le lien de réinitialisation et le certificat. PUBLIC_HOST=enervision.local diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e2f4ad..57f46e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,8 @@ jobs: - "docker-compose*.yml" - ".env.example" - "infra/front/**" + - "infra/garage/**" + - "tests/garage/**" - "monitoring/**" - ".github/workflows/infra.yml" workflows: diff --git a/.github/workflows/infra.yml b/.github/workflows/infra.yml index 3c7f221..30afda9 100644 --- a/.github/workflows/infra.yml +++ b/.github/workflows/infra.yml @@ -67,6 +67,16 @@ jobs: - name: Prépare un .env d'exemple run: cp .env.example .env + # Garage refuse un rpc_secret qui n'est pas 32 octets hexadécimaux : `change_me` ne suffit pas. + - name: Génère les secrets Garage du .env + run: | + sed -i -e "s|^GARAGE_RPC_SECRET=.*|GARAGE_RPC_SECRET=$(openssl rand -hex 32)|" \ + -e "s|^GARAGE_ADMIN_TOKEN=.*|GARAGE_ADMIN_TOKEN=$(openssl rand -hex 32)|" \ + -e "s|^GARAGE_METRICS_TOKEN=.*|GARAGE_METRICS_TOKEN=$(openssl rand -hex 32)|" \ + -e "s|^GARAGE_ACCESS_KEY=.*|GARAGE_ACCESS_KEY=GK$(openssl rand -hex 12)|" \ + -e "s|^GARAGE_SECRET_KEY=.*|GARAGE_SECRET_KEY=$(openssl rand -hex 32)|" \ + -e "s|^GARAGE_SSE_KEY=.*|GARAGE_SSE_KEY=$(openssl rand -base64 32)|" .env + - name: Valide la stack de développement run: docker compose config --quiet @@ -91,6 +101,29 @@ jobs: - name: Valide les tableaux de bord Grafana run: for tableau in monitoring/grafana/dashboards/*.json; do jq empty "$tableau"; done + # Action tierce, épinglée sur le commit du tag (règle Sonar githubactions:S7637). + - name: Installe uv + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + with: + enable-cache: false + + # Même image et même healthcheck qu'en prod : `--wait` ne rend la main qu'une fois le S3 prêt. + - name: Démarre Garage + run: docker compose up -d --wait --wait-timeout 120 garage + + - name: Fumée S3 sur Garage, SSE-C compris + run: | + set -a; . ./.env; set +a + uvx --with boto3==1.43.101 pytest==9.1.1 tests/garage -q + + - name: Journaux de Garage en cas d'échec + if: failure() + run: docker compose logs --tail=100 garage + + - name: Arrête Garage + if: always() + run: docker compose down --volumes + workflows: name: Analyse des workflows if: inputs.workflows diff --git a/.gitignore b/.gitignore index 6c15e6f..a0a805b 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,3 @@ infra/proxy/acme/ *.swp .DS_Store Thumbs.db - -# Docker garage -garage/garage.toml diff --git a/Makefile b/Makefile index 9d1e036..19c3ae8 100644 --- a/Makefile +++ b/Makefile @@ -41,11 +41,19 @@ SUPERVISION := $(findstring monitoring,$(COMPOSE_PROFILES) $(call env-val,COMPOS SERVICES_SUPERVISION := prometheus alertmanager grafana postgres-exporter node-exporter cadvisor GRAFANA_PORT := $(or $(strip $(call env-val,GRAFANA_PORT)),3001) PROMETHEUS_PORT := $(or $(strip $(call env-val,PROMETHEUS_PORT)),9090) -supervision-garde = for cle in APP_METRICS_TOKEN GRAFANA_ADMIN_PASSWORD SUPERVISION_DB_PASSWORD; do \ +supervision-garde = for cle in APP_METRICS_TOKEN GRAFANA_ADMIN_PASSWORD SUPERVISION_DB_PASSWORD GARAGE_METRICS_TOKEN; do \ sed -n "s/^$$cle=//p" .env 2>/dev/null | tail -1 | grep -q . \ || { echo "$$cle manquant dans .env, requis par la supervision (cf. .env.example)"; exit 1; }; \ done MONITORING := docker compose --profile monitoring + +# Piege : l'image Garage n'a pas de shell, elle ne peut pas porter sa garde comme grafana ou +# airflow-init. Un secret vide ou laisse a change_me la ferait redemarrer en boucle (ADR 0019). +CLES_GARAGE := GARAGE_RPC_SECRET GARAGE_ADMIN_TOKEN GARAGE_METRICS_TOKEN GARAGE_ACCESS_KEY GARAGE_SECRET_KEY GARAGE_SSE_KEY +garage-garde = for cle in $(CLES_GARAGE); do \ + sed -n "s/^$$cle=//p" .env 2>/dev/null | tail -1 | grep -qv '^change_me$$' \ + || { echo "$$cle manquant ou laisse a change_me dans .env, requis par Garage (cf. .env.example)"; exit 1; }; \ + done PROMTOOL := $(MONITORING) run --rm --no-deps --entrypoint promtool prometheus # Piege : `e2e-prepare` ajoute trois sites `demo-*` et des comptes `test-*` a la base visee. Elle @@ -101,8 +109,9 @@ dev: services-up migrate demo-data ## Lance toute la stack : base, Mailpit, Airf $(MAKE) --no-print-directory dev-frontend & \ wait -services-up: ## Démarre les services conteneurisés dont `make dev` dépend (base, Mailpit, Airflow) - docker compose up -d db mailpit +services-up: ## Démarre les services conteneurisés dont `make dev` dépend (base, Mailpit, Garage, Airflow) + @$(garage-garde) + docker compose up -d db mailpit garage @$(MAKE) --no-print-directory db-wait @$(MAKE) --no-print-directory db-ensure-airflow docker compose up -d airflow-init airflow-apiserver airflow-scheduler airflow-dag-processor @@ -212,6 +221,7 @@ stack-up: ## Démarre la stack derrière le reverse proxy, puis migre la base. P @openssl x509 -in infra/proxy/tls/fullchain.pem -noout -checkhost "$(PUBLIC_HOST)" >/dev/null \ || { echo "Le certificat ne couvre pas $(PUBLIC_HOST). Relancer make tls-selfsigned PUBLIC_HOST=$(PUBLIC_HOST) FORCE=1"; exit 1; } @$(if $(SUPERVISION),$(supervision-garde),true) + @$(garage-garde) $(COMPOSE_PROD) up -d --build $(COMPOSE_PROD) exec -T backend alembic upgrade head @$(if $(SUPERVISION),$(MAKE) --no-print-directory db-ensure-supervision,true) diff --git a/README.md b/README.md index d31830d..42f2eb5 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m | Reverse proxy | Nginx, TLS | `infra/proxy` | En place | | CI/CD | GitHub Actions | `.github/workflows` | En place | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | En place, profil Compose | +| Stockage objet | Garage (S3), un par environnement | `infra/garage` | En place, archives de `reading` | | Tests e2e et de charge | Playwright, k6 | `tests` | En place | | ML | LightGBM, MLflow | `ml` | En place | @@ -57,6 +58,7 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans │ ├── include/ Requetes SQL et ressources des DAGs │ └── tests/ Tests d'integrite des DAGs ├── infra/ +│ ├── garage/ Stockage objet S3 : configuration sans secret │ ├── proxy/ Reverse proxy Nginx : terminaison TLS et routage │ └── terraform/ │ ├── modules/ Modules reutilisables @@ -68,6 +70,7 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans │ └── alertmanager/ Routage des alertes ├── tests/ │ ├── e2e/ Parcours Playwright contre la stack +│ ├── garage/ Tests de fumée S3 joués par la CI contre Garage │ └── load/ Scenarios de charge k6 ├── docs/ ADR et vues d'architecture └── scripts/ Outillage local @@ -101,7 +104,9 @@ et frontend en rechargement a chaud sur le poste. Le `.env` doit porter les cles Airflow avant le premier `make dev` : `AIRFLOW_FERNET_KEY`, `AIRFLOW_API_SECRET_KEY`, `AIRFLOW_JWT_SECRET`, `AIRFLOW_APP_SECRET_KEY` et `AIRFLOW_ADMIN_PASSWORD`. Sans elles `airflow-init` refuse de demarrer, et `airflow-apiserver`, -`airflow-scheduler` et `airflow-dag-processor` avec lui. +`airflow-scheduler` et `airflow-dag-processor` avec lui. Il doit aussi porter les six clés +`GARAGE_*` (rpc, jetons, clé S3, clé SSE-C) : `make services-up` refuse sinon de démarrer Garage, +où le DAG `retention` archive les mesures anciennes ([ADR 0019](docs/adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)). Les cibles d'origine restent disponibles pour ne demarrer qu'une partie : `make db-up`, `make airflow-up`, `make dev-backend`, `make dev-frontend`. diff --git a/docker-compose.yml b/docker-compose.yml index 5365bb4..3517e55 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -82,6 +82,37 @@ services: - "${MAILPIT_UI_PORT:-8025}:8025" restart: unless-stopped + # Piège : image `FROM scratch`, sans shell : healthcheck en forme exec, et aucune garde shell sur + # les secrets. Un GARAGE_RPC_SECRET vide ou non hexadécimal fait échouer Garage lui-même, message + # explicite dans ses journaux ; `make services-up` et `make stack-up` vérifient le .env avant. + # Piège : GARAGE_SECRET_KEY ne se change pas sur un volume `garage_meta` déjà peuplé, Garage + # refuse alors de démarrer. Rotation par `garage key` ou par recréation du volume (ADR 0019). + garage: + image: dxflrs/garage:v2.4.1 + command: ["/garage", "server", "--single-node", "--default-bucket"] + environment: + GARAGE_RPC_SECRET: ${GARAGE_RPC_SECRET:-} + GARAGE_ADMIN_TOKEN: ${GARAGE_ADMIN_TOKEN:-} + GARAGE_METRICS_TOKEN: ${GARAGE_METRICS_TOKEN:-} + GARAGE_DEFAULT_ACCESS_KEY: ${GARAGE_ACCESS_KEY:-} + GARAGE_DEFAULT_SECRET_KEY: ${GARAGE_SECRET_KEY:-} + GARAGE_DEFAULT_BUCKET: ${GARAGE_BUCKET:-enervision-archives} + volumes: + - ./infra/garage/garage.toml:/etc/garage.toml:ro + - garage_meta:/var/lib/garage/meta + - garage_data:/var/lib/garage/data + ports: + - "127.0.0.1:${GARAGE_S3_PORT:-3900}:3900" + - "127.0.0.1:${GARAGE_ADMIN_PORT:-3903}:3903" + healthcheck: + test: ["CMD", "/garage", "health", "-q"] + interval: 15s + timeout: 5s + retries: 6 + start_period: 20s + mem_limit: 256m + restart: unless-stopped + backend: build: ./apps/backend depends_on: @@ -178,6 +209,15 @@ services: APP_MOCK_API_USERNAME: ${APP_MOCK_API_USERNAME:-} APP_MOCK_API_PASSWORD: ${APP_MOCK_API_PASSWORD:-} APP_MOCK_API_TIMEOUT_SECONDS: ${APP_MOCK_API_TIMEOUT_SECONDS:-10} + # Le DAG `retention` archive les chunks de `reading` sur le Garage du projet (ADR 0019), + # chiffrés par la clé SSE-C du .env (ADR 0020). Vides, `app.etl.reading_retention` refuse seul. + APP_S3_ENDPOINT_URL: http://garage:3900 + APP_S3_REGION: garage + APP_S3_ACCESS_KEY: ${GARAGE_ACCESS_KEY:-} + APP_S3_SECRET_KEY: ${GARAGE_SECRET_KEY:-} + APP_S3_BUCKET: ${GARAGE_BUCKET:-enervision-archives} + APP_S3_SSE_KEY: ${GARAGE_SSE_KEY:-} + APP_READING_RETENTION_DAYS: ${READING_RETENTION_DAYS:-1095} depends_on: db: condition: service_healthy @@ -209,6 +249,7 @@ services: - prometheus_data:/prometheus secrets: - metrics_token + - garage_metrics_token ports: - "127.0.0.1:${PROMETHEUS_PORT:-9090}:9090" mem_limit: 512m @@ -318,6 +359,8 @@ services: volumes: pgdata: + garage_meta: + garage_data: airflow_logs: airflow_ml_state: prometheus_data: @@ -328,3 +371,5 @@ volumes: secrets: metrics_token: environment: APP_METRICS_TOKEN + garage_metrics_token: + environment: GARAGE_METRICS_TOKEN diff --git a/docs/adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md b/docs/adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md new file mode 100644 index 0000000..03e01ef --- /dev/null +++ b/docs/adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md @@ -0,0 +1,97 @@ +# 0019 - Stockage objet Garage par environnement, et cycle de vie des mesures : export puis suppression + +- Statut : accepté +- Date : 2026-09-24 + +## Contexte + +Les issues #24 « Déployer MinIO » et #36 « Politique de rétention + export vers MinIO » datent du +cadrage du 14/09. Au 24/09, la hypertable `reading` grossit d'une lecture par site et par heure +sans qu'aucune politique ne la borne, et `docs/architecture/40-data.md` classe rétention et +compression parmi les cibles non faites. Aucun stockage objet ne tourne. + +La PR 164 a posé une amorce : un projet Compose à part dans `garage/`, l'image `dxflrs/garage:v1.0.1`, +des secrets dans un `garage.toml` gitignoré et des tests de fumée boto3 que rien ne jouait. Rien +n'était branché sur les trois environnements de la VM ([ADR 0009](0009-deux-environnements-compose-sur-la-vm-eni.md), +[ADR 0017](0017-environnement-dev-a-la-demande.md)), ni sur la CI, ni sur la supervision. + +Contrainte propre au projet : le jeu historique s'arrête au 31/12/2024 et `make demo-data` s'y +ancre. Une rétention sous vingt-et-un mois effacerait la démonstration. + +## Décision + +**Garage plutôt que MinIO**, en `v2.4.1`. Un binaire statique de quelques dizaines de Mo, une +API S3 suffisante pour boto3, des métriques Prometheus natives, et depuis la `v2.3.0` un mode +`--single-node --default-bucket` qui crée layout, clé et bucket au premier démarrage à partir de +trois variables d'environnement : aucun conteneur d'initialisation, aucune séquence CLI à rejouer. + +**Un Garage par projet Compose.** Le service `garage` vit dans `docker-compose.yml`, comme `db` +et `mailpit`. Chaque environnement a le sien, ses volumes `garage_meta` et `garage_data`, ses +secrets et ses ports sur `127.0.0.1` : S3 `3900`, `3910`, `3920` et admin `3903`, `3913`, `3923` +pour prod, recette et dev. Le RPC n'est pas publié. Rien ne passe par le proxy. + +**`infra/garage/garage.toml` est versionné sans secret.** `GARAGE_RPC_SECRET` (32 octets +hexadécimaux), `GARAGE_ADMIN_TOKEN` et `GARAGE_METRICS_TOKEN` arrivent par l'environnement, comme +les autres secrets du `.env`, générés par `scripts/provision-host.sh`. L'image est `FROM scratch`, +sans shell : la garde sur les secrets vit dans le `Makefile` (`garage-garde`, appelée par +`services-up` et `stack-up`), et le healthcheck est `garage health -q`. + +**Nœud unique assumé.** `replication_factor = 1` et moteur `sqlite`, avec un instantané des +métadonnées toutes les six heures. La documentation de Garage réserve ce facteur aux +déploiements de test : ici la machine est unique, la redondance n'existe pour aucun autre service, +et le coffre LUKS de l'[ADR 0020](0020-chiffrement-au-repos-coffre-luks-et-sse-c.md) porte les +volumes. LMDB, le moteur par défaut, se corrompt à l'arrêt brutal et rien ne le reconstruirait. + +**La rétention de `reading` est un traitement du backend, ordonnancé par Airflow.** Le DAG +`retention` lance chaque nuit `app.etl.reading_retention` ([ADR 0008](0008-airflow-execute-le-code-du-backend.md)), +qui, pour chaque chunk entièrement plus vieux que `READING_RETENTION_DAYS` (1095 jours par défaut) : + +1. lit ses lignes par la hypertable (`WHERE timestamp >= range_start AND timestamp < range_end`) ; +2. les sérialise en CSV gzip reproductible, les colonnes `jsonb` et `text[]` en JSON ; +3. les dépose sur Garage sous `reading//reading__.csv.gz`, chiffrées par SSE-C, + avec le sha256 et le nombre de lignes en métadonnées ; un objet déjà présent avec le même sha + n'est pas réécrit ; +4. relit l'objet et compare son sha256 ; +5. supprime ce seul chunk par `drop_chunks(older_than => range_end, newer_than => range_start)`, + dans une transaction dédiée et courte. + +`add_retention_policy` de TimescaleDB est écartée : son travail de fond supprimerait sans avoir +exporté. `db/migrations/` reste vide pour la même raison. + +**Supervision.** Prometheus scrute `garage:3903/metrics` avec `GARAGE_METRICS_TOKEN` passé en +secret Compose. `CibleInjoignable` couvre son indisponibilité, aucune règle nouvelle. + +**CI.** Le job « Validation des fichiers Compose et de la supervision » démarre le vrai conteneur +avec des secrets générés, attend son healthcheck et joue `tests/garage/test_smoke.py` : bucket +présent, aller-retour, suppression effective, et lecture refusée sans clé SSE-C. + +## Alternatives écartées + +| Écartée | Raison | +|---|---| +| MinIO | Plus lourd, licence AGPL, orientation vers l'offre commerciale ; l'équipe préfère un composant qu'elle peut lire en entier. Le titre des issues date du cadrage, la décision a changé depuis. | +| Un Garage partagé entre les trois environnements | Un troisième projet Compose et des réseaux externes à déclarer, le couplage que l'ADR 0009 évite. | +| `add_retention_policy` TimescaleDB, plus un export séparé | Deux horloges indépendantes : un export en retard d'une semaine perd les données que la politique a déjà supprimées. | +| Export Parquet | Une dépendance binaire de plus (`pyarrow`) dans l'image Airflow et le backend, pour un gain nul sur 120 000 lignes ; le CSV gzip est le format d'origine du jeu historique. | +| Commande de restauration | Hors périmètre du J6. La procédure manuelle tient en trois commandes : `get_object` avec la clé SSE-C, `gunzip`, `COPY reading FROM STDIN CSV HEADER` ; `uq_reading_source` refuse les doublons. | +| Compression TimescaleDB des chunks chauds | Autre chantier, sans lien avec l'export. | + +## Conséquences + +- **Premier passage en prod** (24/09/2026, borne à trois ans) : les chunks de janvier à septembre + 2023 sont archivés puis supprimés, environ quarante objets. La démonstration ancrée fin 2024 + et l'entraînement du modèle (quinze mois d'historique plus 2026) ne sont pas touchés. +- **`drop_chunks` verrouille `site` et `dataset`** en exclusif jusqu'au COMMIT : le DAG tourne à + 03h20, entre `alertes` (:15) et `derive` (05h30), et chaque suppression est une transaction + propre. +- **Secrets.** `.env.example` gagne `GARAGE_RPC_SECRET`, `GARAGE_ADMIN_TOKEN`, + `GARAGE_METRICS_TOKEN`, `GARAGE_ACCESS_KEY`, `GARAGE_SECRET_KEY`, `GARAGE_BUCKET`, + `GARAGE_S3_PORT`, `GARAGE_ADMIN_PORT`, `GARAGE_SSE_KEY` et `READING_RETENTION_DAYS`. + `provision-host.sh` les génère et réaligne les `.env` de la VM : il doit être rejoué avant le + premier déploiement qui suit ce changement, sinon `make stack-up` s'arrête sur la garde. +- **Rotation.** `GARAGE_SECRET_KEY` ne se change pas sur un volume peuplé : Garage refuse de + démarrer. Passer par `garage key` en CLI, ou recréer le volume d'un environnement jetable. +- **Perte de `GARAGE_SSE_KEY` = archives illisibles.** La clé est sauvegardée hors de la VM. +- **Postes de développement.** `make dev` exige désormais les clés `GARAGE_*` dans le `.env`, + comme il exigeait déjà les clés Airflow. +- **Métriques Garage** visibles dans Prometheus ; aucun tableau Grafana dédié pour l'instant. diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 7a0666d..92ece14 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -97,6 +97,7 @@ ailleurs ([ADR 0016](../adr/0016-supervision-en-profil-compose.md), | 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 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 | +| Stockage objet | Garage, S3 | `infra/garage`, `docker-compose.yml` | `Fait` | Un Garage par environnement, `--single-node --default-bucket`, secrets par l'environnement, ports sur `127.0.0.1`, fumée S3 et SSE-C en CI. Reçoit les archives CSV gzip du DAG `retention`, chiffrées SSE-C, avant `drop_chunks` ([ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md), [ADR 0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md)) | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Fait` | Profil Compose `monitoring`, actif en prod : Prometheus et trois exporteurs (PostgreSQL, hôte, conteneurs), neuf règles d'alerte testées par `promtool`, Alertmanager vers Mailpit, trois tableaux de bord Grafana provisionnés. Voir [60-observabilite.md](60-observabilite.md) | | 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. Réconciliation entre les deux sources (issue #15) : trou temporel accepté, recouvrement refusé à l'ingestion et dédupliqué en défense côté ML, voir [40-data.md](40-data.md). | | CI/CD | GitHub Actions | `.github/workflows` | `En cours` | Un orchestrateur `ci.yml` qui n'appelle que les composants modifiés ([ADR 0014](../adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md)) : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow, Terraform, Compose et supervision, parcours Playwright et tirs k6 contre la stack de prod ([ADR 0015](../adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md)). Déploiement vers la VM ENI par `deploy.yml`, appelé une fois « CI ok » vert, `dev` en recette et `main` en production après approbation ([ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)), mais jamais exécuté : le runner n'est pas enregistré sur la machine. Détail dans [50-cicd.md](50-cicd.md) | @@ -140,6 +141,10 @@ consolidée. Argon2id, RBAC à trois rôles. Détail dans [20-backend.md](20-backend.md), décisions dans les [ADR 0002](../adr/0002-authentification-jwt-et-refresh-opaque.md) et [0003](../adr/0003-autorisation-rbac-a-trois-roles.md). +- **Chiffrement au repos.** Sur la VM, tous les volumes Docker (base, Garage, Airflow, + supervision) vivent dans un coffre LUKS2 dont Docker exige le montage pour démarrer ; les + archives de mesures déposées sur Garage sont en plus chiffrées par clé client (SSE-C). Ce que + cela protège et ne protège pas : [ADR 0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md). - **Interdire par défaut.** Toute route exige un jeton, sauf quatre exceptions listées dans un fichier de test qui interroge réellement chaque route sans identifiant. - **Révocation immédiate.** Le compte est relu en base à chaque requête : une désactivation ou un @@ -202,3 +207,5 @@ Elles vivent dans `../adr/`, pas ici. | [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 | +| [0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md) | Stockage objet Garage par environnement ; les chunks anciens de `reading` sont exportés en CSV gzip puis supprimés | +| [0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md) | Chiffrement au repos : coffre LUKS des volumes Docker de la VM, SSE-C des archives | diff --git a/docs/architecture/60-observabilite.md b/docs/architecture/60-observabilite.md index 7d41283..4f89829 100644 --- a/docs/architecture/60-observabilite.md +++ b/docs/architecture/60-observabilite.md @@ -21,6 +21,8 @@ flowchart LR db[("db
TimescaleDB")] mail["mailpit"] + garage["garage
:3903/metrics"] + subgraph sup["Profil monitoring"] prom["prometheus
15 s, 15 jours"] am["alertmanager"] @@ -35,6 +37,7 @@ flowchart LR prom -->|"Bearer APP_METRICS_TOKEN"| api prom --> pge & node & cad + prom -->|"Bearer GARAGE_METRICS_TOKEN"| garage pge -->|"rôle supervision"| db node -.->|"/proc, /sys"| hote cad -.->|"cgroups"| hote @@ -56,6 +59,7 @@ conteneur couvre donc aussi la recette, qu'on distingue au préfixe `enervision- | node-exporter | Processeur, mémoire disponible, espace disque de `/` | Tableau « Infrastructure » | | cAdvisor | Mémoire (`working_set`) et processeur par conteneur | Tableau « Infrastructure » | | TimescaleDB, en SQL | Fraîcheur des relevés par site, relevés ingérés par heure, alertes par sévérité, `drift_report` | Tableau « Données et modèle » | +| Garage (`/metrics` du port admin, jeton `GARAGE_METRICS_TOKEN`) | `api_s3_request_counter`, `block_bytes_written`, `garage_local_disk_avail`, `cluster_healthy` | Prometheus seulement, aucun tableau dédié ; `CibleInjoignable` couvre son indisponibilité ([ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)) | Deux choix de l'instrumentation se lisent dans ces courbes : diff --git a/garage/docker-compose.yml b/garage/docker-compose.yml deleted file mode 100644 index b91b6a9..0000000 --- a/garage/docker-compose.yml +++ /dev/null @@ -1,17 +0,0 @@ -services: - garage: - image: dxflrs/garage:v1.0.1 - container_name: enervision-garage - ports: - - "127.0.0.1:3900:3900" - - "127.0.0.1:3901:3901" - - "127.0.0.1:3903:3903" - volumes: - - ./garage.toml:/etc/garage.toml:ro - - garage-meta:/var/lib/garage/meta - - garage-data:/var/lib/garage/data - restart: unless-stopped - -volumes: - garage-meta: - garage-data: diff --git a/garage/garage.toml.example b/garage/garage.toml.example deleted file mode 100644 index b90b729..0000000 --- a/garage/garage.toml.example +++ /dev/null @@ -1,20 +0,0 @@ -metadata_dir = "/var/lib/garage/meta" -data_dir = "/var/lib/garage/data" -db_engine = "sqlite" - -replication_factor = 1 - -rpc_bind_addr = "[::]:3901" -rpc_public_addr = "127.0.0.1:3901" -# Générer la vôtre : python -c "import secrets; print(secrets.token_hex(32))" -rpc_secret = "change_me" - -[s3_api] -s3_region = "garage" -api_bind_addr = "[::]:3900" -root_domain = ".s3.garage.localhost" - -[admin] -api_bind_addr = "[::]:3903" -# Générer la vôtre avec la même commande que rpc_secret -admin_token = "change_me" diff --git a/garage/tests/test_garage_smoke.py b/garage/tests/test_garage_smoke.py deleted file mode 100644 index da4b9b4..0000000 --- a/garage/tests/test_garage_smoke.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -Tests de fumée pour Garage — vérifie que le cluster local accepte -vraiment des écritures et les rend intactes, pas juste qu'il démarre. -""" -import os -import uuid - -import boto3 -import pytest - -ENDPOINT_URL = "http://127.0.0.1:3900" -ACCESS_KEY = os.environ["GARAGE_ACCESS_KEY"] -SECRET_KEY = os.environ["GARAGE_SECRET_KEY"] -BUCKET = "enervision-raw-test" - - -@pytest.fixture -def client(): - return boto3.client( - "s3", - endpoint_url=ENDPOINT_URL, - aws_access_key_id=ACCESS_KEY, - aws_secret_access_key=SECRET_KEY, - region_name="garage", - ) - - -def test_bucket_exists(client): - response = client.list_buckets() - noms = [b["Name"] for b in response["Buckets"]] - assert BUCKET in noms - - -def test_upload_and_download_roundtrip(client): - cle = f"test-{uuid.uuid4()}.txt" - contenu = b"contenu de test EnerVision" - - client.put_object(Bucket=BUCKET, Key=cle, Body=contenu) - - reponse = client.get_object(Bucket=BUCKET, Key=cle) - recupere = reponse["Body"].read() - - assert recupere == contenu - - client.delete_object(Bucket=BUCKET, Key=cle) - - -def test_deleted_object_is_really_gone(client): - cle = f"test-suppression-{uuid.uuid4()}.txt" - client.put_object(Bucket=BUCKET, Key=cle, Body=b"a supprimer") - - client.delete_object(Bucket=BUCKET, Key=cle) - - with pytest.raises(client.exceptions.NoSuchKey): - client.get_object(Bucket=BUCKET, Key=cle) diff --git a/infra/garage/README.md b/infra/garage/README.md new file mode 100644 index 0000000..cc3360f --- /dev/null +++ b/infra/garage/README.md @@ -0,0 +1,25 @@ +# Garage + +Stockage objet S3 de la stack, un conteneur par projet Compose (ADR 0019). Il ne sert qu'au DAG +`retention`, qui y archive les chunks de `reading` avant de les supprimer. + +- `garage.toml` : configuration versionnée, sans secret, montée en lecture seule. Les secrets + arrivent par l'environnement : `GARAGE_RPC_SECRET`, `GARAGE_ADMIN_TOKEN`, `GARAGE_METRICS_TOKEN`. +- Démarrage `--single-node --default-bucket` : le premier démarrage crée le layout, la clé + `GARAGE_ACCESS_KEY` et le bucket `GARAGE_BUCKET`. Rejouable tant que les volumes `garage_meta` + et `garage_data` sont conservés. Changer `GARAGE_SECRET_KEY` ensuite fait refuser le démarrage. +- Ports, sur `127.0.0.1` seulement : `GARAGE_S3_PORT` (3900) pour l'API S3, `GARAGE_ADMIN_PORT` + (3903) pour `/health` (sans jeton) et `/metrics` (jeton `GARAGE_METRICS_TOKEN`, scruté par + Prometheus). Le RPC 3901 n'est pas publié. +- Nœud unique, `replication_factor = 1`, moteur sqlite : aucune redondance, le coffre LUKS de la + VM (ADR 0020) et l'instantané des métadonnées toutes les six heures sont les seules protections. + +```bash +docker compose exec garage /garage status +docker compose exec garage /garage bucket info enervision-archives +docker compose exec garage /garage key info --show-secret "$GARAGE_ACCESS_KEY" +``` + +Tests de fumée : `tests/garage/test_smoke.py`, joués par la CI contre le vrai conteneur (job +« Validation des fichiers Compose et de la supervision »). En local, stack démarrée et `.env` chargé : +`uvx --with boto3 pytest tests/garage`. diff --git a/infra/garage/garage.toml b/infra/garage/garage.toml new file mode 100644 index 0000000..10b9b46 --- /dev/null +++ b/infra/garage/garage.toml @@ -0,0 +1,22 @@ +# Contrainte : aucun secret ici, ce fichier est versionné et monté en lecture seule. Garage lit +# GARAGE_RPC_SECRET, GARAGE_ADMIN_TOKEN et GARAGE_METRICS_TOKEN dans son environnement, posés par +# docker-compose.yml depuis le .env (ADR 0019). `--single-node` exige replication_factor = 1. + +metadata_dir = "/var/lib/garage/meta" +data_dir = "/var/lib/garage/data" +db_engine = "sqlite" +metadata_auto_snapshot_interval = "6h" + +replication_factor = 1 + +rpc_bind_addr = "[::]:3901" +rpc_public_addr = "127.0.0.1:3901" + +[s3_api] +s3_region = "garage" +api_bind_addr = "[::]:3900" +root_domain = ".s3.garage.localhost" + +[admin] +api_bind_addr = "[::]:3903" +metrics_require_token = true diff --git a/monitoring/README.md b/monitoring/README.md index 2d841e0..b29bf92 100644 --- a/monitoring/README.md +++ b/monitoring/README.md @@ -12,6 +12,7 @@ Issue #26, décisions dans l'ADR 0016, vue d'architecture dans | `postgres-exporter` | `prometheuscommunity/postgres-exporter` | Connexions, transactions, taille des bases | réseau interne | | `node-exporter` | `prom/node-exporter` | Processeur, mémoire et disque de l'hôte | réseau interne | | `cadvisor` | `gcr.io/cadvisor/cadvisor` | Mémoire et processeur par conteneur | réseau interne | +| `garage` (cible) | service de la stack | Requêtes S3, octets lus et écrits, disque local, santé du nœud, sur `garage:3903/metrics` avec `GARAGE_METRICS_TOKEN` | réseau interne | Les interfaces n'écoutent que sur `127.0.0.1`. Depuis un poste, on passe par un tunnel SSH, comme pour Airflow : @@ -27,7 +28,7 @@ ssh -L 3001:127.0.0.1:3001 -L 9090:127.0.0.1:9090 enervision@10.101.200.37 - **Recette et poste.** À la demande, sur une stack déjà démarrée : `make monitoring-up`. Les services partent en `--no-deps`, sans toucher aux autres. -Trois secrets sont requis, et `make stack-up` comme `make monitoring-up` refusent de démarrer +Quatre secrets sont requis, et `make stack-up` comme `make monitoring-up` refusent de démarrer s'il en manque un. `scripts/provision-host.sh` les génère pour un nouvel environnement. | Variable | Rôle | @@ -35,6 +36,7 @@ s'il en manque un. `scripts/provision-host.sh` les génère pour un nouvel envir | `APP_METRICS_TOKEN` | Jeton que Prometheus présente sur `/metrics`, et que l'API exige dès qu'il est posé | | `GRAFANA_ADMIN_PASSWORD` | Compte `admin` de Grafana. Sans lui, le conteneur refuse de démarrer | | `SUPERVISION_DB_PASSWORD` | Rôle PostgreSQL `supervision`, en lecture seule (`db/roles/supervision.sql`) | +| `GARAGE_METRICS_TOKEN` | Jeton que Prometheus présente sur `/metrics` de Garage (ADR 0019), passé en secret Compose | L'API doit tourner en conteneur (`make stack-up`, ou `docker compose up -d backend`) : Prometheus la joint en `backend:8000`, sur le réseau du projet. Une API lancée par `make dev` diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml index 9c1769f..7b03aea 100644 --- a/monitoring/prometheus/prometheus.yml +++ b/monitoring/prometheus/prometheus.yml @@ -41,3 +41,10 @@ scrape_configs: - job_name: cadvisor static_configs: - targets: ["cadvisor:8080"] + + - job_name: garage + authorization: + type: Bearer + credentials_file: /run/secrets/garage_metrics_token + static_configs: + - targets: ["garage:3903"] diff --git a/scripts/provision-host.sh b/scripts/provision-host.sh index dcd28c8..adaf489 100755 --- a/scripts/provision-host.sh +++ b/scripts/provision-host.sh @@ -26,12 +26,19 @@ secret() { openssl rand -base64 48 | tr -d '/+=\n' | cut -c1-48; } court() { secret | cut -c1-20; } # Clé Fernet : 32 octets en base64 urlsafe, padding compris. fernet() { openssl rand -base64 32 | tr '+/' '-_'; } +# Garage : rpc_secret et secret de clé S3 en 32 octets hexadécimaux, identifiant de clé en GK + hex, +# clé SSE-C des archives en 32 octets base64 (ADR 0019, 0020). +hex32() { openssl rand -hex 32; } +cle_acces() { echo "GK$(openssl rand -hex 12)"; } +cle_sse() { openssl rand -base64 32; } declare -A GENERATEURS=( [POSTGRES_PASSWORD]=secret [APP_SECRET_KEY]=secret [AIRFLOW_FERNET_KEY]=fernet [AIRFLOW_API_SECRET_KEY]=secret [AIRFLOW_JWT_SECRET]=secret [AIRFLOW_ADMIN_PASSWORD]=court [AIRFLOW_APP_SECRET_KEY]=secret [APP_METRICS_TOKEN]=secret [GRAFANA_ADMIN_PASSWORD]=court [SUPERVISION_DB_PASSWORD]=secret + [GARAGE_RPC_SECRET]=hex32 [GARAGE_ADMIN_TOKEN]=secret [GARAGE_METRICS_TOKEN]=secret + [GARAGE_ACCESS_KEY]=cle_acces [GARAGE_SECRET_KEY]=hex32 [GARAGE_SSE_KEY]=cle_sse ) verifier_outils() { @@ -67,7 +74,7 @@ preparer() { local env="$1" branche="$2" hote="$3" local port_https="$4" port_http="$5" port_front="$6" port_pg="$7" port_mailpit="$8" local port_airflow="$9" profils="${10}" port_grafana="${11}" port_prometheus="${12}" - local port_alertmanager="${13}" + local port_alertmanager="${13}" port_garage_s3="${14}" port_garage_admin="${15}" local dossier="$RACINE/$env" local fichier="$dossier/.env" brouillon="$dossier/.env.brouillon" cle oubliees ajoutees="" @@ -103,6 +110,7 @@ preparer() { [POSTGRES_PORT]="$port_pg" [MAILPIT_UI_PORT]="$port_mailpit" [AIRFLOW_PORT]="$port_airflow" [COMPOSE_PROFILES]="$profils" [GRAFANA_PORT]="$port_grafana" [PROMETHEUS_PORT]="$port_prometheus" [ALERTMANAGER_PORT]="$port_alertmanager" + [GARAGE_S3_PORT]="$port_garage_s3" [GARAGE_ADMIN_PORT]="$port_garage_admin" ) for cle in "${!adressage[@]}"; do poser "$brouillon" "$cle" "${adressage[$cle]}" @@ -200,10 +208,10 @@ publier_dns # Supervision active en prod seulement (ADR 0016). La prod vit sur `prod.` et non à la racine : # dynv6 ne sert pas de façon fiable un TXT `_acme-challenge` à la racine de la zone (ADR 0018). -# env branche hôte https http front pg mailpit airflow profils grafana prometheus alertmanager -preparer prod main "prod.$DOMAINE" 127.0.0.1:10443 127.0.0.1:10080 127.0.0.1:10444 5433 8025 8080 monitoring 3001 9090 9093 -preparer rec dev "rec.$DOMAINE" 127.0.0.1:8443 127.0.0.1:8081 127.0.0.1:8444 5434 8026 8082 "" 3002 9091 9094 -preparer dev dev "dev.$DOMAINE" 127.0.0.1:9443 127.0.0.1:8083 127.0.0.1:9444 5435 8027 8084 "" 3003 9092 9095 +# env branche hôte https http front pg mailpit airflow profils grafana prometheus alertmanager garage-s3 garage-admin +preparer prod main "prod.$DOMAINE" 127.0.0.1:10443 127.0.0.1:10080 127.0.0.1:10444 5433 8025 8080 monitoring 3001 9090 9093 3900 3903 +preparer rec dev "rec.$DOMAINE" 127.0.0.1:8443 127.0.0.1:8081 127.0.0.1:8444 5434 8026 8082 "" 3002 9091 9094 3910 3913 +preparer dev dev "dev.$DOMAINE" 127.0.0.1:9443 127.0.0.1:8083 127.0.0.1:9444 5435 8027 8084 "" 3003 9092 9095 3920 3923 planifier_renouvellement if [[ -n "$PROPRIETAIRE" && "$(id -u)" -eq 0 ]]; then diff --git a/tests/garage/test_smoke.py b/tests/garage/test_smoke.py new file mode 100644 index 0000000..3c244f3 --- /dev/null +++ b/tests/garage/test_smoke.py @@ -0,0 +1,70 @@ +# Pourquoi : la CI (job compose d'infra.yml) démarre le vrai conteneur Garage et joue ces tests avec +# boto3, sans venv projet. Ils prouvent que le S3 accepte des écritures, les rend intactes, supprime +# vraiment, et que SSE-C refuse une lecture sans clé (ADR 0019, 0020) - test_smoke.py + +import base64 +import os +import uuid + +import boto3 +import pytest +from botocore.exceptions import ClientError + +BUCKET = os.environ.get("GARAGE_BUCKET", "enervision-archives") +ENDPOINT_URL = os.environ.get( + "GARAGE_ENDPOINT_URL", f"http://127.0.0.1:{os.environ.get('GARAGE_S3_PORT', '3900')}" +) +SSE_KEY = base64.b64decode(os.environ["GARAGE_SSE_KEY"]) +SSE = {"SSECustomerAlgorithm": "AES256", "SSECustomerKey": SSE_KEY} + + +@pytest.fixture +def client(): + return boto3.client( + "s3", + endpoint_url=ENDPOINT_URL, + aws_access_key_id=os.environ["GARAGE_ACCESS_KEY"], + aws_secret_access_key=os.environ["GARAGE_SECRET_KEY"], + region_name="garage", + ) + + +def test_default_bucket_exists(client): + noms = [bucket["Name"] for bucket in client.list_buckets()["Buckets"]] + + assert BUCKET in noms + + +def test_upload_and_download_roundtrip(client): + cle = f"fumee/{uuid.uuid4()}.txt" + contenu = b"contenu de test EnerVision" + + client.put_object(Bucket=BUCKET, Key=cle, Body=contenu) + recupere = client.get_object(Bucket=BUCKET, Key=cle)["Body"].read() + client.delete_object(Bucket=BUCKET, Key=cle) + + assert recupere == contenu + + +def test_deleted_object_is_really_gone(client): + cle = f"fumee/suppression-{uuid.uuid4()}.txt" + client.put_object(Bucket=BUCKET, Key=cle, Body=b"a supprimer") + + client.delete_object(Bucket=BUCKET, Key=cle) + + with pytest.raises(client.exceptions.NoSuchKey): + client.get_object(Bucket=BUCKET, Key=cle) + + +def test_sse_c_object_is_unreadable_without_the_key(client): + cle = f"fumee/chiffre-{uuid.uuid4()}.txt" + contenu = b"archive chiffree" + client.put_object(Bucket=BUCKET, Key=cle, Body=contenu, **SSE) + + with pytest.raises(ClientError) as erreur: + client.get_object(Bucket=BUCKET, Key=cle) + dechiffre = client.get_object(Bucket=BUCKET, Key=cle, **SSE)["Body"].read() + client.delete_object(Bucket=BUCKET, Key=cle) + + assert erreur.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert dechiffre == contenu From 0462dd01baf7046405211393d9ce98b315151457 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 24 Sep 2026 10:35:31 +0200 Subject: [PATCH 07/10] feat(scripts): chiffre au repos les volumes Docker de la VM par un coffre LUKS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/coffre-luks.sh pose un coffre LUKS2 dans un fichier image, bind-monte /var/lib/docker/volumes depuis ce coffre et exige son montage pour que Docker démarre (drop-in RequiresMountsFor). Migration à froid rejouable, jouée par l'opérateur ; null_resource Terraform optionnel (coffre_taille). Les archives déposées sur Garage sont chiffrées par SSE-C (clé GARAGE_SSE_KEY). ADR 0020, runbook et tableaux Garage dans 10-infra.md. Closes #42 --- ...iffrement-au-repos-coffre-luks-et-sse-c.md | 98 ++++++++ docs/architecture/10-infra.md | 30 ++- infra/README.md | 5 + infra/terraform/environments/vm-eni/main.tf | 41 +++- .../vm-eni/terraform.tfvars.example | 4 + .../environments/vm-eni/variables.tf | 6 + scripts/README.md | 10 + scripts/coffre-luks.sh | 222 ++++++++++++++++++ 8 files changed, 414 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md create mode 100755 scripts/coffre-luks.sh diff --git a/docs/adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md b/docs/adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md new file mode 100644 index 0000000..2d85256 --- /dev/null +++ b/docs/adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md @@ -0,0 +1,98 @@ +# 0020 - Chiffrement au repos : coffre LUKS des volumes Docker et SSE-C des archives + +- Statut : accepté +- Date : 2026-09-24 + +## Contexte + +L'issue #42 demande que les données de la plateforme soient chiffrées au repos. Tout ce que la +plateforme persiste vit dans les volumes Docker nommés des trois projets Compose de la VM ENI +([ADR 0009](0009-deux-environnements-compose-sur-la-vm-eni.md), +[ADR 0017](0017-environnement-dev-a-la-demande.md)), sous `/var/lib/docker/volumes` : la base +TimescaleDB (`pgdata`, relevés, comptes, audit), les métadonnées et les objets de Garage +(`garage_meta`, `garage_data`, les archives des chunks de `reading` exportées par le DAG +`retention`, [ADR 0019](0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)), les journaux et l'état ML d'Airflow, les séries de Prometheus et la base +de Grafana. + +Aucun des deux dépôts de données ne chiffre lui-même : Garage n'a pas de chiffrement côté serveur +et sa documentation renvoie à un volume LUKS sous ses données ; PostgreSQL communautaire n'a pas de +chiffrement transparent des données (TDE), et l'image `timescaledb-ha` n'en ajoute pas. La VM est +unique, sur un seul disque virtuel, sans partition libre, sans TPM, et personne n'est devant sa +console au démarrage : tout redémarrage doit aboutir sans saisie. + +## Décision + +**Un coffre LUKS2 sous tous les volumes Docker, posé par `scripts/coffre-luks.sh`.** + +- Le coffre est un **fichier image creux** (`/srv/enervision/coffre.img`, 30 Go par défaut) + formaté en **LUKS2**, ouvert par une **clé de 64 octets tirée de `/dev/urandom`**, lisible par + root seulement (`/root/enervision-coffre.key`, `0400`). Un fichier plutôt qu'une partition : la + VM n'en a pas de libre, et l'image se déplace ou se sauvegarde comme un fichier. +- Le mapper `enervision-coffre` porte un ext4 monté sur `/srv/enervision/coffre`, et + `/var/lib/docker/volumes` est **bind-monté** depuis `/srv/enervision/coffre/docker-volumes`. + Docker ne voit qu'un dossier ordinaire : ni `data-root`, ni les fichiers Compose, ni les noms + de volumes ne changent, et les trois environnements sont couverts d'un coup. +- L'ouverture et les montages sont déclarés dans **`/etc/crypttab` et `/etc/fstab`, avec + `nofail`** sur les trois lignes : un coffre absent ne doit jamais envoyer la machine en mode + urgence, où SSH ne répond plus. Sur Debian 13 le générateur crypttab est dans le paquet + `systemd-cryptsetup`, installé par le script s'il existe dans apt. +- Un **drop-in `RequiresMountsFor=/var/lib/docker/volumes`** sur `docker.service` fait la + barrière : sans le bind, Docker ne démarre pas, plutôt que de recréer des volumes vides en + clair et de laisser trois stacks se lever sur des bases neuves. +- Le script est **rejouable** : clé, image, formatage, système de fichiers, crypttab, fstab et + drop-in ne sont posés que s'ils manquent, et il sort sans rien toucher si + `/var/lib/docker/volumes` est déjà servi par le coffre. La **migration à froid** des volumes + existants n'a lieu qu'avec `COFFRE_MIGRER=1` : refus si `live-restore` est actif, arrêt de + `docker.socket` et `docker.service`, `rsync -aHAX --numeric-ids`, comparaison du nombre et de la + taille des fichiers, puis bascule du dossier et redémarrage de Docker. L'ancien dossier reste en + `/var/lib/docker/volumes.avant-coffre` jusqu'à validation par un redémarrage. +- Terraform peut le jouer : `null_resource.coffre`, activé par `coffre_taille` non vide, + s'exécute après Docker et avant `provision-host.sh`. La ressource est optionnelle et absente du + plan tant que la variable est vide. + +**SSE-C sur les archives exportées vers Garage.** Le module d'export du DAG `retention` envoie +chaque archive avec une clé client (`GARAGE_SSE_KEY`, générée dans le `.env` par +`provision-host.sh`) ; Garage la chiffre en AES-256-GCM et n'en garde que l'empreinte. Les objets +sont donc chiffrés une seconde fois, avec une clé distincte de celle du coffre, dans le seul +dépôt que l'on pourrait un jour sortir de la VM. + +## Alternatives écartées + +| Écartée | Raison | +|---|---| +| `pgcrypto`, chiffrement par colonne | Ne couvre ni les index, ni les journaux WAL, ni Garage, ni Airflow ; la clé serait dans l'application, à côté des données, pour un coût de développement et de requête sur chaque lecture d'hypertable. | +| Déplacer le `data-root` de Docker dans le coffre | Chiffre aussi les images et les couches, sans valeur, et impose de recopier tout `/var/lib/docker` : plus long, plus de place, et le démon doit être reconfiguré. Seuls les volumes portent des données. | +| Chiffrer côté client dans le module d'export | Couvre les archives et rien d'autre, avec une bibliothèque cryptographique à porter dans le code métier alors que Garage offre SSE-C. Retenu seulement sous cette forme, en complément du coffre. | +| Volume Docker chiffré par un plugin | Un plugin tiers par volume nommé, à installer et suivre sur la machine, pour huit volumes par environnement ; le coffre les couvre tous d'un bind. | +| Disque ou partition dédiée | La VM n'a qu'un disque virtuel, sans partition libre, et son redimensionnement n'est pas dans les mains de l'équipe. | +| Clé saisie au démarrage | Personne devant la console ; un redémarrage de la VM par l'école laisserait la plateforme arrêtée jusqu'à intervention. | +| Clé scellée dans un TPM | La VM n'en expose pas. | + +## Conséquences + +- **Ce que le coffre protège, et ce qu'il ne protège pas.** La clé et l'image vivent sur le même + disque. Le coffre protège une copie isolée de l'image ou du disque : snapshot, sauvegarde, + décommissionnement du disque virtuel. Il ne protège ni du vol du disque entier, où la clé se + trouve aussi, ni d'un root sur l'hôte allumé, qui lit le montage en clair. La copie + `.avant-coffre`, supprimée après validation, n'est pas effaçable physiquement sur un disque + virtuel. La clé SSE-C transite en clair sur le réseau Compose interne, entre `airflow-scheduler` + et Garage, à chaque objet envoyé. +- **Perte de la clé, perte de tout.** Sans `/root/enervision-coffre.key`, l'image est illisible + et les trois bases avec elle. La clé est à sauvegarder hors de la VM tout de suite après la + pose, dans un emplacement que seuls les administrateurs lisent. +- **Coupure lors de la migration.** La copie des volumes se fait Docker arrêté : les trois + environnements sont indisponibles une à trois minutes, et le disque doit porter deux fois la + taille des volumes jusqu'à la suppression de `.avant-coffre`. +- **Redémarrage de test obligatoire.** L'ordonnancement crypttab, fstab, drop-in ne se vérifie + qu'en redémarrant : `findmnt /var/lib/docker/volumes` et `docker ps` après le reboot, avant de + supprimer la copie en clair. +- **Docker dépend du coffre.** Si l'image ou la clé disparaît, Docker refuse de démarrer + (`dependency failed`) et la machine reste joignable par SSH ; c'est voulu. Retirer la ligne + fstab du bind retire cette protection sans message. +- **Rotation.** La clé LUKS se change par `cryptsetup luksChangeKey` sans réécrire les données. + `GARAGE_SSE_KEY` ne se change pas sans réécrire chaque objet : Garage n'a pas de re-chiffrement + côté serveur, et un objet écrit avec l'ancienne clé ne se lit qu'avec elle. +- **Terraform interrompt la stack, une fois.** La première pose avec `coffre_taille` est la seule + ressource de `vm-eni` qui arrête Docker, en contradiction assumée avec + l'[ADR 0010](0010-terraform-provisionne-github-actions-deploie.md) pour cette seule occasion ; + les `apply` suivants trouvent le coffre en place et n'y touchent pas. diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md index b35e56f..528f9e5 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -37,6 +37,7 @@ flowchart TB |---|---|---| | `db` | `timescale/timescaledb-ha:pg17` | Publié sur **5433** côté hôte, 5432 souvent déjà pris. `healthcheck` `pg_isready`, 12 tentatives, `start_period` 40s | | `backend` | Construite depuis `apps/backend` | `depends_on: db, condition: service_healthy`. **N'embarque pas le source** : toute modification impose `docker compose up -d --build backend` | +| `garage` | `dxflrs/garage:v2.4.1` | S3 en `127.0.0.1:3900`, admin et `/metrics` en `127.0.0.1:3903`. `--single-node --default-bucket` : clé et bucket créés au premier démarrage, secrets par l'environnement (`GARAGE_*` du `.env`, garde dans le Makefile), `garage.toml` versionné sans secret dans `infra/garage`. Reçoit les archives du DAG `retention` ([ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)) | | `prometheus`, `alertmanager`, `grafana`, exporteurs | Images épinglées par tag | Profil `monitoring`, jamais démarrés par `make dev`. `make monitoring-up` les lance en `--no-deps`. Voir [60-observabilite.md](60-observabilite.md) | | `k6` | `grafana/k6` | Profil `load`, lancé par `make load-*` le temps d'un tir, sur le réseau du projet. Voir [`tests/load/README.md`](../../tests/load/README.md) | @@ -91,6 +92,7 @@ l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md). | `historical_import` | manuelle | `app.etl.historical_import`, dans `/opt/backend/.venv` ; les fichiers de `data/raw` sont montés en lecture seule dans `/opt/data/raw` | | `mock_api_import` | `45 * * * *` | `app.etl.mock_api_import`, dans `/opt/backend/.venv` ; importe depuis l'API Mock la mesure de l'heure pile précédant son déclenchement | | `derive` | `30 5 * * *` | `app.monitoring.drift`, dans `/opt/backend/.venv` ; quotidien parce que sa fenêtre couvre 168 h, et sans reprise parce qu'une dérive n'est pas une panne passagère | +| `retention` | `20 3 * * *` | `app.etl.reading_retention`, dans `/opt/backend/.venv` ; exporte vers Garage (CSV gzip, SSE-C) chaque chunk de `reading` plus vieux que `READING_RETENTION_DAYS` puis le supprime par `drop_chunks` ; la nuit parce que la suppression verrouille `site` et `dataset` jusqu'au COMMIT | Le DAG `historical_import` réutilise le pipeline historique existant sans dupliquer sa logique. Il reste manuel, car le dataset sert à initialiser l'environnement. Le montage @@ -179,7 +181,7 @@ du `docker-compose.yml` principal (réseau, volumes et démarrage séparés). 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 +`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 @@ -253,6 +255,7 @@ les secrets et les certificats, et ne démarre rien. | URL | `https://dev.enervision-g3.dynv6.net` | `https://rec.enervision-g3.dynv6.net` | `https://prod.enervision-g3.dynv6.net` | | Proxy HTTP, HTTPS, PROXY protocol, sur `127.0.0.1` | `8083`, `9443`, `9444` | `8081`, `8443`, `8444` | `10080`, `10443`, `10444` | | PostgreSQL, Mailpit, Airflow, sur `127.0.0.1` | `5435`, `8027`, `8084` | `5434`, `8026`, `8082` | `5433`, `8025`, `8080` | +| Garage S3, admin, sur `127.0.0.1` | `3920`, `3923` | `3910`, `3913` | `3900`, `3903` | | Supervision (profil `monitoring`) | à la demande, `make monitoring-up` | à la demande, `make monitoring-up` | active, `COMPOSE_PROFILES=monitoring` | | Grafana, Prometheus, Alertmanager, sur `127.0.0.1` | `3003`, `9092`, `9095` | `3002`, `9091`, `9094` | `3001`, `9090`, `9093` | @@ -297,6 +300,31 @@ sonde de `deploy.yml` qui le dit. Le jeton d'enregistrement du runner est valable une heure et ne vaut que pour une inscription : l'`apply` n'est pas rejouable sans qu'un administrateur du dépôt en crée un nouveau. +### Coffre LUKS des volumes Docker (issue #42) + +Statut : `En cours`, script écrit, jamais encore joué sur la VM. Décision et modèle de menace dans +l'[ADR 0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md). Un fichier image LUKS2 +(`/srv/enervision/coffre.img`, clé `/root/enervision-coffre.key`) est monté sur +`/srv/enervision/coffre`, et `/var/lib/docker/volumes` est bind-monté depuis ce coffre : les +volumes des trois environnements sont chiffrés au repos sans qu'un fichier Compose change. +`scripts/coffre-luks.sh` pose tout, rejouable ; Terraform le joue aussi quand `coffre_taille` est +renseignée. Prérequis : deux fois la taille actuelle des volumes libre sur le disque, le temps de +la migration. Runbook, joué en root sur la VM, coupure des trois environnements d'une à trois +minutes : + +```bash +scp scripts/coffre-luks.sh root@10.101.200.37:/tmp/ +ssh root@10.101.200.37 'COFFRE_TAILLE=30G COFFRE_MIGRER=1 bash /tmp/coffre-luks.sh' +ssh root@10.101.200.37 'findmnt /var/lib/docker/volumes && lsblk /dev/mapper/enervision-coffre && docker ps' +ssh root@10.101.200.37 'curl -k https://localhost:10443/api/v1/health/ready' +``` + +Ensuite, dans cet ordre : sauvegarder `/root/enervision-coffre.key` hors de la VM (sans elle, les +trois bases sont perdues) ; redémarrer la machine et rejouer les deux vérifications, ce qui valide +l'ordonnancement crypttab, fstab et drop-in Docker ; alors seulement supprimer la copie en clair, +`rm -rf /var/lib/docker/volumes.avant-coffre`. Le script affiche ces trois étapes à la fin et +n'exécute jamais la suppression. + ## Cible à terme, k3s Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster, depuis la diff --git a/infra/README.md b/infra/README.md index 6f548ce..69e3d77 100644 --- a/infra/README.md +++ b/infra/README.md @@ -44,6 +44,11 @@ zone, `prod`, `rec` et `dev` vers la machine, obtient un certificat Let's Encryp renouvellement ; sans jeton, chaque environnement garde un certificat auto-signe. Le frontal SNI (`infra/front`) se demarre une fois depuis le dossier de la prod, `make front-up`. +Chiffrement au repos ([ADR 0020](../docs/adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md)) : +`coffre_taille = "30G"` fait poser par `scripts/coffre-luks.sh` un coffre LUKS2 sous `/var/lib/docker/volumes` ; +vide par defaut, rien n'est pose. La premiere pose arrete Docker le temps de copier les volumes, et la cle +`/root/enervision-coffre.key` est a sauvegarder hors de la VM. + Retirer le runner se fait a la main, depuis les parametres du depot : `terraform destroy` ne le desinscrit pas. diff --git a/infra/terraform/environments/vm-eni/main.tf b/infra/terraform/environments/vm-eni/main.tf index 17eb3be..db1588e 100644 --- a/infra/terraform/environments/vm-eni/main.tf +++ b/infra/terraform/environments/vm-eni/main.tf @@ -6,12 +6,15 @@ # Contrainte : pas de provisioner `destroy` sur le runner. Il imposerait une connexion ne lisant # que `self`, donc le chemin de la cle SSH dans le state, et `svc.sh uninstall` ne desinscrit pas # le runner cote GitHub : le retrait reste manuel, depuis les parametres du depot. +# Piege : seule exception a « un apply n'interrompt pas la stack » : la premiere pose du coffre +# (`coffre_taille` non vide, ADR 0020) arrete Docker le temps de copier les volumes. # Ref : ADR 0009 et 0017 pour les trois environnements, `scripts/provision-host.sh` pour leur contenu. locals { sudo = var.ssh_user == "root" ? "" : "sudo " en_tant_que = "${var.ssh_user == "root" ? "" : "sudo "}runuser -u ${var.proprietaire} --" provisionneur = "${path.root}/../../../../scripts/provision-host.sh" + coffre = "${path.root}/../../../../scripts/coffre-luks.sh" runner_archive = "actions-runner-linux-x64-${var.runner_version}.tar.gz" # Substitution shell, evaluee par le sh -c distant : un nom de runner doit etre unique dans # le depot, le nom d'hote l'est deja et le reste si cette racine sert a une autre machine. @@ -50,11 +53,47 @@ resource "null_resource" "docker_engine" { } } +# Optionnel : un coffre LUKS2 dans un fichier image, bind-monte sur /var/lib/docker/volumes +# (ADR 0020). Rejouable : deja en place, le script affiche l'etat et sort sans rien toucher. +resource "null_resource" "coffre" { + count = var.coffre_taille == "" ? 0 : 1 + depends_on = [null_resource.docker_engine] + + triggers = { + script = filesha256(local.coffre) + taille = var.coffre_taille + } + + connection { + type = "ssh" + host = var.ssh_host + port = var.ssh_port + user = var.ssh_user + private_key = file(pathexpand(var.ssh_private_key_path)) + timeout = "5m" + } + + provisioner "file" { + source = local.coffre + destination = "/tmp/coffre-luks.sh" + } + + provisioner "remote-exec" { + inline = [ + <<-EOT + set -eu + ${local.sudo}env COFFRE_TAILLE='${var.coffre_taille}' COFFRE_MIGRER=1 bash /tmp/coffre-luks.sh + rm -f /tmp/coffre-luks.sh + EOT + ] + } +} + # `provision-host.sh` verifie lui-meme docker, compose et la sortie HTTPS, puis prepare un clone # par environnement, son `.env` et son certificat. Il est rejouable : un `.env` existant n'est # jamais reecrit, un certificat present jamais regenere. resource "null_resource" "environnements" { - depends_on = [null_resource.docker_engine] + depends_on = [null_resource.docker_engine, null_resource.coffre] triggers = { script = filesha256(local.provisionneur) diff --git a/infra/terraform/environments/vm-eni/terraform.tfvars.example b/infra/terraform/environments/vm-eni/terraform.tfvars.example index 9f5df93..e5e8ec4 100644 --- a/infra/terraform/environments/vm-eni/terraform.tfvars.example +++ b/infra/terraform/environments/vm-eni/terraform.tfvars.example @@ -20,3 +20,7 @@ runner_token = "A_RENSEIGNER" # Nom du runner cote GitHub. Vide par defaut : le nom d'hote de la machine. A renseigner # seulement si deux runners doivent tourner sur la meme machine, leurs noms devant differer. # runner_nom = "eni-g3-bis" + +# Coffre LUKS des volumes Docker (ADR 0020). Vide ou absent : rien n'est pose. La premiere pose +# arrete Docker le temps de copier les volumes ; la cle reste sur la machine, a sauvegarder ailleurs. +# coffre_taille = "30G" diff --git a/infra/terraform/environments/vm-eni/variables.tf b/infra/terraform/environments/vm-eni/variables.tf index 41e46a0..1e402e6 100644 --- a/infra/terraform/environments/vm-eni/variables.tf +++ b/infra/terraform/environments/vm-eni/variables.tf @@ -94,3 +94,9 @@ variable "runner_dossier" { description = "Dossier d'installation du runner sur la machine." default = "/opt/actions-runner" } + +variable "coffre_taille" { + type = string + description = "Taille du coffre LUKS qui chiffre /var/lib/docker/volumes (ADR 0020, scripts/coffre-luks.sh), ex. 30G. Vide : le coffre n'est pas pose. La premiere pose arrete Docker le temps de copier les volumes ; la cle reste sur la machine, a sauvegarder ailleurs." + default = "" +} diff --git a/scripts/README.md b/scripts/README.md index 848aeb4..8914c5d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -9,3 +9,13 @@ Prépare le scan DAST (`.github/workflows/dast.yml`) : sur une API déjà démar 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`. + +## coffre-luks.sh + +Pose un coffre LUKS2 dans un fichier image creux et bind-monte `/var/lib/docker/volumes` depuis ce +coffre : les volumes des trois environnements de la VM sont chiffrés au repos sans toucher aux +fichiers Compose (issue #42, [ADR 0020](../docs/adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md)). +Rejouable, en root sur la VM : `COFFRE_TAILLE=30G COFFRE_MIGRER=1 bash scripts/coffre-luks.sh`. Sans +`COFFRE_MIGRER=1`, le coffre est préparé mais les volumes existants ne sont pas déplacés : la +migration arrête Docker le temps de la copie. Variables : `COFFRE_IMAGE`, `COFFRE_CLE`, +`COFFRE_MONTAGE`, `COFFRE_TAILLE`. La clé est à sauvegarder hors de la VM : perdue, tout est perdu. diff --git a/scripts/coffre-luks.sh b/scripts/coffre-luks.sh new file mode 100755 index 0000000..9c5335b --- /dev/null +++ b/scripts/coffre-luks.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# Pourquoi : les volumes Docker nommés des trois environnements (pgdata TimescaleDB, Garage, +# Airflow, Prometheus, Grafana) vivent en clair sous /var/lib/docker/volumes ; Garage n'a pas de +# chiffrement côté serveur et PostgreSQL communautaire n'a pas de TDE (issue #42, ADR 0020). +# coffre-luks.sh pose un coffre LUKS2 dans un fichier image creux, le monte, puis bind-monte +# /var/lib/docker/volumes depuis ce coffre : les trois projets Compose sont chiffrés au repos +# sans qu'un fichier Compose change. La clé vit sur le même disque que l'image : le coffre +# protège une copie isolée de l'image ou du disque (snapshot, sauvegarde, décommissionnement), +# pas le vol du disque entier ni un root sur l'hôte allumé, qui lit le montage en clair. +# Piège : le drop-in RequiresMountsFor sur docker.service est la seule barrière qui empêche +# Docker de recréer des volumes en clair si le coffre manque au démarrage ; retirer la ligne +# fstab du bind la désactive sans message. `nofail` partout, sinon un coffre absent envoie la +# machine en mode urgence et coupe SSH. Bind et drop-in ne sont posés qu'avec la migration : +# posés avant, un redémarrage masquerait les volumes en clair sous un coffre vide. Rejouable. + +set -euo pipefail + +COFFRE_IMAGE="${COFFRE_IMAGE:-/srv/enervision/coffre.img}" +COFFRE_CLE="${COFFRE_CLE:-/root/enervision-coffre.key}" +COFFRE_MONTAGE="${COFFRE_MONTAGE:-/srv/enervision/coffre}" +COFFRE_TAILLE="${COFFRE_TAILLE:-30G}" +COFFRE_MIGRER="${COFFRE_MIGRER:-0}" +MAPPER="enervision-coffre" +PERIPHERIQUE="/dev/mapper/$MAPPER" +VOLUMES="/var/lib/docker/volumes" +SOURCE_BIND="$COFFRE_MONTAGE/docker-volumes" +DROPIN="/etc/systemd/system/docker.service.d/enervision-coffre.conf" +APT_A_JOUR=0 + +erreur() { echo "erreur : $*" >&2; exit 1; } + +if [[ $# -gt 0 ]]; then + echo "Usage : [COFFRE_TAILLE=30G] [COFFRE_MIGRER=1] [COFFRE_IMAGE=...] [COFFRE_CLE=...] [COFFRE_MONTAGE=...] $0" >&2 + exit 2 +fi + +installer() { + local paquet="$1" + dpkg -s "$paquet" >/dev/null 2>&1 && return 0 + if [[ $APT_A_JOUR -eq 0 ]]; then + apt-get update -qq + APT_A_JOUR=1 + fi + apt-cache show "$paquet" >/dev/null 2>&1 || return 1 + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends "$paquet" >/dev/null + echo "$paquet installé" +} + +verifier_prerequis() { + [[ "$(id -u)" -eq 0 ]] || erreur "à lancer en root" + [[ "$COFFRE_IMAGE" != /var/lib/docker/* ]] \ + || erreur "l'image $COFFRE_IMAGE ne doit pas vivre sous /var/lib/docker, que le coffre recouvre" + installer cryptsetup || erreur "cryptsetup introuvable dans apt" + # Debian 13 sépare le générateur crypttab dans systemd-cryptsetup ; sans lui, crypttab est ignoré. + installer systemd-cryptsetup || echo "systemd-cryptsetup absent d'apt : le générateur crypttab est dans systemd" + installer rsync || erreur "rsync introuvable dans apt" + for outil in truncate blkid findmnt lsblk mkfs.ext4 systemctl; do + command -v "$outil" >/dev/null || erreur "$outil absent" + done +} + +deja_sur_le_coffre() { + [[ "$(findmnt -n -o SOURCE "$VOLUMES" 2>/dev/null || true)" == *"$MAPPER"* ]] +} + +poser_cle() { + if [[ ! -f "$COFFRE_CLE" ]]; then + (umask 077 && head -c 64 /dev/urandom > "$COFFRE_CLE") + echo "clé générée : $COFFRE_CLE" + fi + chmod 400 "$COFFRE_CLE" +} + +poser_image() { + if [[ ! -f "$COFFRE_IMAGE" ]]; then + mkdir -p "$(dirname "$COFFRE_IMAGE")" + (umask 077 && truncate -s "$COFFRE_TAILLE" "$COFFRE_IMAGE") + echo "image creuse créée : $COFFRE_IMAGE ($COFFRE_TAILLE)" + fi + if ! cryptsetup isLuks "$COFFRE_IMAGE"; then + [[ -z "$(blkid -p -o value -s TYPE "$COFFRE_IMAGE" 2>/dev/null || true)" ]] \ + || erreur "$COFFRE_IMAGE porte déjà des données hors LUKS, refus de le formater" + cryptsetup luksFormat --type luks2 --batch-mode --key-file "$COFFRE_CLE" "$COFFRE_IMAGE" + echo "image formatée en LUKS2" + fi + if [[ ! -e "$PERIPHERIQUE" ]]; then + cryptsetup open --key-file "$COFFRE_CLE" "$COFFRE_IMAGE" "$MAPPER" + fi + if [[ -z "$(blkid -p -o value -s TYPE "$PERIPHERIQUE" 2>/dev/null || true)" ]]; then + mkfs.ext4 -q -L "$MAPPER" "$PERIPHERIQUE" + echo "système de fichiers ext4 créé dans le coffre" + fi + mkdir -p "$COFFRE_MONTAGE" + if ! findmnt -n -M "$COFFRE_MONTAGE" >/dev/null; then + mount "$PERIPHERIQUE" "$COFFRE_MONTAGE" + fi + mkdir -p "$SOURCE_BIND" +} + +fstab_contient() { + awk -v cible="$1" '$1 !~ /^#/ && $2 == cible { trouve = 1 } END { exit !trouve }' /etc/fstab +} + +poser_persistance() { + touch /etc/crypttab + if ! awk -v nom="$MAPPER" '$1 == nom { trouve = 1 } END { exit !trouve }' /etc/crypttab; then + echo "$MAPPER $COFFRE_IMAGE $COFFRE_CLE luks,nofail" >> /etc/crypttab + echo "crypttab : $MAPPER ajouté" + fi + if ! fstab_contient "$COFFRE_MONTAGE"; then + echo "$PERIPHERIQUE $COFFRE_MONTAGE ext4 defaults,nofail,x-systemd.device-timeout=30s 0 2" >> /etc/fstab + echo "fstab : $COFFRE_MONTAGE ajouté" + fi + systemctl daemon-reload +} + +poser_bind() { + if ! fstab_contient "$VOLUMES"; then + echo "$SOURCE_BIND $VOLUMES none bind,nofail 0 0" >> /etc/fstab + echo "fstab : bind de $VOLUMES ajouté" + fi + if [[ ! -f "$DROPIN" ]]; then + mkdir -p "$(dirname "$DROPIN")" + cat > "$DROPIN" </dev/null | grep -q "Live Restore Enabled: true"; then + erreur "live-restore actif : les conteneurs survivraient à l'arrêt du démon, volumes en clair ouverts. Le désactiver dans /etc/docker/daemon.json avant de migrer" + fi + [[ "$(du -sb "$VOLUMES" | cut -f1)" -lt "$(libre "$COFFRE_MONTAGE")" ]] \ + || erreur "le coffre est trop petit pour $VOLUMES ($(du -sh "$VOLUMES" | cut -f1)) : relancer avec une image plus grande" + + echo "arrêt de Docker : les trois environnements sont coupés le temps de la copie" + systemctl stop docker.socket docker.service + poser_bind + rsync -aHAX --numeric-ids "$VOLUMES/" "$SOURCE_BIND/" + origine="$(empreinte "$VOLUMES")" + copie="$(empreinte "$SOURCE_BIND")" + [[ "$origine" == "$copie" ]] \ + || erreur "copie incomplète : $origine dans $VOLUMES, $copie dans $SOURCE_BIND. Docker est arrêté, rien n'a été déplacé" + echo "copie vérifiée : $copie" + mv "$VOLUMES" "$VOLUMES.avant-coffre" + mkdir "$VOLUMES" + if ! mount "$VOLUMES" || ! deja_sur_le_coffre; then + umount "$VOLUMES" 2>/dev/null || true + rmdir "$VOLUMES" + mv "$VOLUMES.avant-coffre" "$VOLUMES" + erreur "bind impossible à monter depuis le coffre : $VOLUMES remis en place, Docker reste arrêté" + fi + systemctl start docker.socket docker.service + docker volume ls +} + +afficher_etat() { + echo + losetup -j "$COFFRE_IMAGE" 2>/dev/null || true + lsblk "$PERIPHERIQUE" 2>/dev/null || true + findmnt "$VOLUMES" || echo "$VOLUMES n'est pas un point de montage" + cat < + 2. Redémarrer la machine pour valider l'ordonnancement crypttab, fstab, docker, puis vérifier : + findmnt $VOLUMES && docker ps +FIN + if [[ -d "$VOLUMES.avant-coffre" ]]; then + cat < Date: Thu, 24 Sep 2026 10:36:09 +0200 Subject: [PATCH 08/10] feat(apps): archive vers Garage puis supprime les chunks anciens de reading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouveau module app.etl.reading_retention : pour chaque chunk de `reading` entièrement plus vieux que APP_READING_RETENTION_DAYS (1095 jours), export CSV gzip reproductible vers Garage (SSE-C, sha256 en métadonnées), relecture et comparaison, puis drop_chunks ciblé sur ce seul chunk dans une transaction dédiée. --dry-run. Réglages APP_S3_* optionnels, jamais exigés par l'API. DAG Airflow `retention` quotidien à 03h20. 44 tests unitaires sans réseau ni base, tests d'intégrité du DAG, vérification --help dans l'image Airflow en CI. Docs 40-data et 20-backend. Closes #36 --- .github/workflows/airflow.yml | 5 +- apps/backend/app/core/config.py | 22 +- apps/backend/app/etl/reading_retention.py | 349 +++++++++ apps/backend/pyproject.toml | 2 + .../tests/etl/test_reading_retention.py | 702 ++++++++++++++++++ apps/backend/uv.lock | 107 +++ docs/architecture/20-backend.md | 10 + docs/architecture/40-data.md | 44 +- etl/airflow/dags/retention.py | 45 ++ etl/airflow/tests/test_dags.py | 20 + 10 files changed, 1292 insertions(+), 14 deletions(-) create mode 100644 apps/backend/app/etl/reading_retention.py create mode 100644 apps/backend/tests/etl/test_reading_retention.py create mode 100644 etl/airflow/dags/retention.py diff --git a/.github/workflows/airflow.yml b/.github/workflows/airflow.yml index d6d314a..66be8b8 100644 --- a/.github/workflows/airflow.yml +++ b/.github/workflows/airflow.yml @@ -74,11 +74,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. - - name: Vérifie que les quatre commandes backend s'importent sans réseau + - name: Vérifie que les cinq 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.mock_api_import --help" + && env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.mock_api_import --help + && env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.reading_retention --help" diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index be3a63b..a0f591f 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -76,9 +76,25 @@ class Settings(BaseSettings): expose_api_docs: bool | None = None metrics_token: SecretStr | None = None - # Compose passe `APP_METRICS_TOKEN` vide quand aucun jeton n'est posé : vide vaut absent, sinon - # `/metrics` exigerait un `Bearer` sans valeur et plus rien ne pourrait le scruter. - @field_validator("metrics_token", mode="before") + s3_endpoint_url: str | None = None + s3_region: str = "garage" + s3_access_key: str | None = None + s3_secret_key: SecretStr | None = None + s3_bucket: str | None = None + s3_sse_key: SecretStr | None = None + reading_retention_days: int = Field(default=1095, ge=30) + + # Compose passe `APP_METRICS_TOKEN` et les réglages S3 vides quand rien n'est posé : vide vaut + # absent, sinon `/metrics` exigerait un `Bearer` sans valeur et l'archivage un endpoint vide. + @field_validator( + "metrics_token", + "s3_endpoint_url", + "s3_access_key", + "s3_secret_key", + "s3_bucket", + "s3_sse_key", + mode="before", + ) @classmethod def _jeton_vide_vaut_absent(cls, valeur: object) -> object: return None if valeur == "" else valeur diff --git a/apps/backend/app/etl/reading_retention.py b/apps/backend/app/etl/reading_retention.py new file mode 100644 index 0000000..f634702 --- /dev/null +++ b/apps/backend/app/etl/reading_retention.py @@ -0,0 +1,349 @@ +# Pourquoi : la suppression n'est pas confiée à add_retention_policy, qui ignorerait l'export. +# archive_reading_chunks() exporte chaque chunk vers Garage, le relit, puis le supprime seul. +# Piège : drop_chunks pose un verrou exclusif sur reading, site et dataset jusqu'au COMMIT. La +# suppression tient donc dans une transaction dédiée et courte, séparée de la lecture du chunk. + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import hashlib +import io +import json +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING, Any + +import anyio.to_thread +import boto3 +import pandas as pd +from botocore.exceptions import ClientError +from pydantic import SecretStr +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine + +from app.core.config import Settings, get_settings + +if TYPE_CHECKING: + from types_boto3_s3.client import S3Client + +SSE_KEY_LENGTH = 32 +FORMAT_BORNE = "%Y%m%dT%H%M%SZ" + +ELIGIBLE_CHUNKS = text( + "SELECT chunk_schema, chunk_name, range_start, range_end " + "FROM timescaledb_information.chunks " + "WHERE hypertable_name = 'reading' AND range_end <= :older_than " + "ORDER BY range_start" +) + +# Lecture via l'hypertable, jamais la table interne : l'exclusion de partition vise le seul chunk. +CHUNK_ROWS = text( + "SELECT * FROM reading WHERE timestamp >= :start AND timestamp < :end " + "ORDER BY timestamp, reading_id" +) + +# Les deux bornes sont inclusives pour drop_chunks : celles du chunk le désignent, et lui seul. +DROP_CHUNK = text( + "SELECT drop_chunks('reading', " + "older_than => CAST(:end AS timestamptz), newer_than => CAST(:start AS timestamptz))" +) + + +@dataclass(frozen=True) +class Chunk: + schema: str + name: str + range_start: datetime + range_end: datetime + + @property + def qualified_name(self) -> str: + return f"{self.schema}.{self.name}" + + +@dataclass +class Rapport: + chunks_vus: int = 0 + exportes: int = 0 + deja_presents: int = 0 + supprimes: int = 0 + lignes: int = 0 + + +def object_key(chunk: Chunk) -> str: + start = chunk.range_start.astimezone(UTC) + end = chunk.range_end.astimezone(UTC) + return ( + f"reading/{start.year}/reading_{start.strftime(FORMAT_BORNE)}_" + f"{end.strftime(FORMAT_BORNE)}.csv.gz" + ) + + +async def eligible_chunks(conn: AsyncConnection, older_than: datetime) -> list[Chunk]: + result = await conn.execute(ELIGIBLE_CHUNKS, {"older_than": older_than}) + return [ + Chunk( + schema=row["chunk_schema"], + name=row["chunk_name"], + range_start=row["range_start"], + range_end=row["range_end"], + ) + for row in result.mappings().all() + ] + + +async def read_chunk_rows(conn: AsyncConnection, chunk: Chunk) -> list[dict[str, Any]]: + result = await conn.execute(CHUNK_ROWS, {"start": chunk.range_start, "end": chunk.range_end}) + return [dict(row) for row in result.mappings().all()] + + +def _csv_cell(value: object) -> object: + if isinstance(value, dict | list): + return json.dumps(value, ensure_ascii=False, sort_keys=True) + return value + + +def serialize_csv_gzip(rows: list[dict[str, Any]]) -> bytes: + if not rows: + raise ValueError("Aucune ligne à sérialiser : un CSV sans colonne ne se relit pas.") + + frame = pd.DataFrame([{name: _csv_cell(value) for name, value in row.items()} for row in rows]) + buffer = io.BytesIO() + frame.to_csv(buffer, mode="wb", index=False, compression={"method": "gzip", "mtime": 0}) + return buffer.getvalue() + + +def sha256_of(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _is_missing_object(erreur: ClientError) -> bool: + error = erreur.response.get("Error") + metadata = erreur.response.get("ResponseMetadata") + code = error.get("Code") if error is not None else None + status = metadata.get("HTTPStatusCode") if metadata is not None else None + return code == "NoSuchKey" or status == 404 + + +class ArchiveStore: + def __init__(self, client: S3Client, bucket: str, sse_key: bytes | None) -> None: + self._client = client + self._bucket = bucket + self._sse_key = sse_key + + # boto3 encode lui-même la clé en base64 et calcule son MD5 : la fournir brute, sans MD5. + def _sse_headers(self) -> dict[str, Any]: + if self._sse_key is None: + return {} + return {"SSECustomerAlgorithm": "AES256", "SSECustomerKey": self._sse_key} + + def put(self, key: str, body: bytes, metadata: dict[str, str]) -> None: + self._client.put_object( + Bucket=self._bucket, + Key=key, + Body=body, + ContentType="text/csv", + ContentEncoding="gzip", + Metadata=metadata, + **self._sse_headers(), + ) + + def fetch_sha256(self, key: str) -> str | None: + try: + response = self._client.get_object(Bucket=self._bucket, Key=key, **self._sse_headers()) + except ClientError as erreur: + if _is_missing_object(erreur): + return None + raise + return sha256_of(response["Body"].read()) + + +def decode_sse_key(encoded: SecretStr | None) -> bytes | None: + if encoded is None: + return None + + key = base64.b64decode(encoded.get_secret_value(), validate=True) + if len(key) != SSE_KEY_LENGTH: + raise ValueError( + f"APP_S3_SSE_KEY doit encoder exactement {SSE_KEY_LENGTH} octets en base64, " + f"pas {len(key)}." + ) + return key + + +def build_archive_store(settings: Settings) -> ArchiveStore: + endpoint = settings.s3_endpoint_url + access_key = settings.s3_access_key + secret_key = settings.s3_secret_key + bucket = settings.s3_bucket + + if endpoint is None or access_key is None or secret_key is None or bucket is None: + raise ValueError( + "L'archivage vers Garage exige APP_S3_ENDPOINT_URL, APP_S3_ACCESS_KEY, " + "APP_S3_SECRET_KEY et APP_S3_BUCKET." + ) + + client = boto3.client( + "s3", + endpoint_url=endpoint, + aws_access_key_id=access_key, + aws_secret_access_key=secret_key.get_secret_value(), + region_name=settings.s3_region, + ) + return ArchiveStore(client, bucket=bucket, sse_key=decode_sse_key(settings.s3_sse_key)) + + +async def drop_chunk(conn: AsyncConnection, chunk: Chunk) -> None: + result = await conn.execute(DROP_CHUNK, {"start": chunk.range_start, "end": chunk.range_end}) + supprimes = list(result.scalars().all()) + + if supprimes != [chunk.qualified_name]: + raise RuntimeError( + f"drop_chunks devait supprimer exactement {chunk.qualified_name}, " + f"il a rendu {supprimes}." + ) + + +async def _export( + store: ArchiveStore, + key: str, + rows: list[dict[str, Any]], + *, + dry_run: bool, + rapport: Rapport, +) -> str: + body = serialize_csv_gzip(rows) + sha = sha256_of(body) + + if await anyio.to_thread.run_sync(store.fetch_sha256, key) == sha: + rapport.deja_presents += 1 + return f"{len(body)} octets déjà présents" + + if dry_run: + return f"{len(body)} octets à exporter" + + metadata = {"sha256": sha, "rows": str(len(rows))} + await anyio.to_thread.run_sync(store.put, key, body, metadata) + relu = await anyio.to_thread.run_sync(store.fetch_sha256, key) + + if relu != sha: + raise RuntimeError( + f"Relecture de {key} : sha256 {relu} au lieu de {sha}, le chunk est conservé." + ) + + rapport.exportes += 1 + return f"{len(body)} octets exportés et relus" + + +async def _archive_chunk( + engine: AsyncEngine, + store: ArchiveStore, + chunk: Chunk, + *, + dry_run: bool, + rapport: Rapport, +) -> None: + async with engine.connect() as conn: + rows = await read_chunk_rows(conn, chunk) + + key = object_key(chunk) + rapport.lignes += len(rows) + + if rows: + action = await _export(store, key, rows, dry_run=dry_run, rapport=rapport) + else: + action = "vide, rien à exporter" + + if dry_run: + print(f"{key} : {len(rows)} ligne(s), {action}, suppression simulée.") + return + + async with engine.begin() as conn: + await drop_chunk(conn, chunk) + + rapport.supprimes += 1 + print(f"{key} : {len(rows)} ligne(s), {action}, chunk {chunk.qualified_name} supprimé.") + + +async def archive_reading_chunks( + engine: AsyncEngine, + store: ArchiveStore, + *, + older_than: datetime, + dry_run: bool, +) -> Rapport: + rapport = Rapport() + + async with engine.connect() as conn: + chunks = await eligible_chunks(conn, older_than) + + rapport.chunks_vus = len(chunks) + print( + f"{len(chunks)} chunk(s) de reading entièrement antérieur(s) au {older_than.isoformat()}." + ) + + for chunk in chunks: + await _archive_chunk(engine, store, chunk, dry_run=dry_run, rapport=rapport) + + bilan = "Dry-run terminé : rien n'a été écrit ni supprimé." if dry_run else "Archivage terminé." + print( + f"{bilan} Chunks vus : {rapport.chunks_vus}, exportés : {rapport.exportes}, " + f"déjà présents : {rapport.deja_presents}, supprimés : {rapport.supprimes}, " + f"lignes : {rapport.lignes}." + ) + return rapport + + +async def _run( + settings: Settings, + store: ArchiveStore, + *, + older_than: datetime, + dry_run: bool, +) -> Rapport: + engine = create_async_engine(str(settings.database_url), pool_pre_ping=True) + try: + return await archive_reading_chunks(engine, store, older_than=older_than, dry_run=dry_run) + finally: + await engine.dispose() + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="python -m app.etl.reading_retention", + description=( + "Exporte vers Garage puis supprime les chunks de reading entièrement plus vieux " + "que la borne de rétention." + ), + ) + parser.add_argument( + "--older-than-days", + type=int, + default=None, + help="Borne en jours, par défaut APP_READING_RETENTION_DAYS.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Liste et mesure les chunks éligibles sans rien écrire ni supprimer.", + ) + return parser + + +def main(argv: list[str] | None = None) -> None: + args = build_parser().parse_args(argv) + settings = get_settings() + + jours = ( + settings.reading_retention_days if args.older_than_days is None else args.older_than_days + ) + older_than = datetime.now(UTC) - timedelta(days=jours) + store = build_archive_store(settings) + + asyncio.run(_run(settings, store, older_than=older_than, dry_run=args.dry_run)) + + +if __name__ == "__main__": + main() diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index e884616..73666f8 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "aiosmtplib>=5.1.3", "httpx>=0.28.1", "pandas>=3.0.5", + "boto3>=1.43.101", ] [dependency-groups] @@ -29,6 +30,7 @@ dev = [ "pytest-asyncio>=1.4.0", "pytest-cov>=7.1.0", "pandas-stubs>=3.0.5.260914", + "types-boto3[s3]>=1.43.101", ] [build-system] diff --git a/apps/backend/tests/etl/test_reading_retention.py b/apps/backend/tests/etl/test_reading_retention.py new file mode 100644 index 0000000..123c3b0 --- /dev/null +++ b/apps/backend/tests/etl/test_reading_retention.py @@ -0,0 +1,702 @@ +import base64 +import io +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from datetime import UTC, datetime, timedelta, timezone +from decimal import Decimal +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import boto3 +import pandas as pd +import pytest +from botocore.exceptions import ClientError +from botocore.response import StreamingBody +from botocore.stub import Stubber +from pydantic import SecretStr +from tests.factories import make_settings + +import app.etl.reading_retention as reading_retention +from app.core.config import Settings +from app.etl.reading_retention import ( + CHUNK_ROWS, + DROP_CHUNK, + ELIGIBLE_CHUNKS, + ArchiveStore, + Chunk, + Rapport, + archive_reading_chunks, + build_archive_store, + build_parser, + decode_sse_key, + drop_chunk, + eligible_chunks, + object_key, + read_chunk_rows, + serialize_csv_gzip, + sha256_of, +) + +CLE_SSE = b"0123456789abcdef0123456789abcdef" +CLE_SSE_BASE64 = base64.b64encode(CLE_SSE).decode("ascii") + +CHUNK = Chunk( + schema="_timescaledb_internal", + name="_hyper_1_7_chunk", + range_start=datetime(2023, 1, 5, tzinfo=UTC), + range_end=datetime(2023, 1, 12, tzinfo=UTC), +) +CLE_ATTENDUE = "reading/2023/reading_20230105T000000Z_20230112T000000Z.csv.gz" + + +def make_row(**overrides: Any) -> dict[str, Any]: + ligne: dict[str, Any] = { + "reading_id": 1, + "site_id": "SITE001", + "timestamp": datetime(2023, 1, 5, 12, tzinfo=UTC), + "source": "csv", + "dataset_id": 1, + "consumption_kw": Decimal("87.34"), + "data_quality": "good", + "null_reasons": ["sensor_offline"], + "imputed_values": None, + "raw_data": {"b": 1, "a": "é"}, + } + return {**ligne, **overrides} + + +def settings_s3(**overrides: Any) -> Settings: + reglages: dict[str, Any] = { + "_env_file": None, + "secret_key": SecretStr("secret-de-test-assez-long-pour-le-validateur"), + "database_url": "postgresql+asyncpg://retention:test@localhost:5432/enervision", + "s3_endpoint_url": "http://garage:3900", + "s3_access_key": "GK0123456789", + "s3_secret_key": SecretStr("un-secret-garage"), + "s3_bucket": "enervision-archives", + "s3_sse_key": SecretStr(CLE_SSE_BASE64), + } + return Settings(**{**reglages, **overrides}) + + +def s3_client() -> Any: + return boto3.client( + "s3", + endpoint_url="http://garage:3900", + aws_access_key_id="GK0123456789", + aws_secret_access_key="un-secret-garage", + region_name="garage", + ) + + +def streaming(data: bytes) -> StreamingBody: + return StreamingBody(io.BytesIO(data), len(data)) + + +def test_settings_treat_empty_s3_values_as_absent() -> None: + settings = make_settings( + s3_endpoint_url="", s3_access_key="", s3_secret_key="", s3_bucket="", s3_sse_key="" + ) + + assert settings.s3_endpoint_url is None + assert settings.s3_access_key is None + assert settings.s3_secret_key is None + assert settings.s3_bucket is None + assert settings.s3_sse_key is None + assert settings.reading_retention_days == 1095 + + +def test_object_key_places_the_chunk_under_the_year_of_its_start() -> None: + assert object_key(CHUNK) == CLE_ATTENDUE + + +def test_object_key_expresses_the_bounds_in_utc() -> None: + paris = timezone(timedelta(hours=1)) + chunk = Chunk( + schema=CHUNK.schema, + name=CHUNK.name, + range_start=datetime(2023, 1, 5, 1, tzinfo=paris), + range_end=datetime(2023, 1, 12, 1, tzinfo=paris), + ) + + assert object_key(chunk) == CLE_ATTENDUE + + +def test_serialize_csv_gzip_is_read_back_by_pandas() -> None: + archive = serialize_csv_gzip([make_row(), make_row(reading_id=2, null_reasons=[])]) + + relu = pd.read_csv(io.BytesIO(archive), compression="gzip") + + assert list(relu.columns) == list(make_row()) + assert relu["reading_id"].tolist() == [1, 2] + assert relu["site_id"].tolist() == ["SITE001", "SITE001"] + + +def test_serialize_csv_gzip_writes_jsonb_and_arrays_as_sorted_json() -> None: + archive = serialize_csv_gzip([make_row()]) + + relu = pd.read_csv(io.BytesIO(archive), compression="gzip") + + assert relu.loc[0, "raw_data"] == '{"a": "é", "b": 1}' + assert relu.loc[0, "null_reasons"] == '["sensor_offline"]' + + +def test_serialize_csv_gzip_is_byte_for_byte_reproducible() -> None: + lignes = [make_row(), make_row(reading_id=2)] + + assert serialize_csv_gzip(lignes) == serialize_csv_gzip(lignes) + + +def test_serialize_csv_gzip_refuses_an_empty_export() -> None: + with pytest.raises(ValueError, match="Aucune ligne"): + serialize_csv_gzip([]) + + +def test_sha256_of_hashes_the_bytes() -> None: + assert sha256_of(b"hello").startswith("2cf24dba") + + +def test_store_put_sends_the_sse_c_headers_when_a_key_is_set() -> None: + client = s3_client() + store = ArchiveStore(client, bucket="enervision-archives", sse_key=CLE_SSE) + + with Stubber(client) as stub: + stub.add_response( + "put_object", + {}, + expected_params={ + "Bucket": "enervision-archives", + "Key": CLE_ATTENDUE, + "Body": b"corps", + "ContentType": "text/csv", + "ContentEncoding": "gzip", + "Metadata": {"sha256": "abc"}, + "SSECustomerAlgorithm": "AES256", + "SSECustomerKey": CLE_SSE, + }, + ) + store.put(CLE_ATTENDUE, b"corps", {"sha256": "abc"}) + stub.assert_no_pending_responses() + + +def test_store_put_omits_the_sse_c_headers_without_a_key() -> None: + client = s3_client() + store = ArchiveStore(client, bucket="enervision-archives", sse_key=None) + + with Stubber(client) as stub: + stub.add_response( + "put_object", + {}, + expected_params={ + "Bucket": "enervision-archives", + "Key": CLE_ATTENDUE, + "Body": b"corps", + "ContentType": "text/csv", + "ContentEncoding": "gzip", + "Metadata": {}, + }, + ) + store.put(CLE_ATTENDUE, b"corps", {}) + stub.assert_no_pending_responses() + + +def test_store_fetch_sha256_hashes_the_object_read_with_the_key() -> None: + client = s3_client() + store = ArchiveStore(client, bucket="enervision-archives", sse_key=CLE_SSE) + + with Stubber(client) as stub: + stub.add_response( + "get_object", + {"Body": streaming(b"hello")}, + expected_params={ + "Bucket": "enervision-archives", + "Key": CLE_ATTENDUE, + "SSECustomerAlgorithm": "AES256", + "SSECustomerKey": CLE_SSE, + }, + ) + + assert store.fetch_sha256(CLE_ATTENDUE) == sha256_of(b"hello") + + +@pytest.mark.parametrize( + ("code", "statut"), + [("NoSuchKey", 404), ("NotFound", 404), ("NoSuchKey", 400)], + ids=["no_such_key", "404_sans_code_connu", "no_such_key_sans_404"], +) +def test_store_fetch_sha256_returns_none_for_a_missing_object(code: str, statut: int) -> None: + client = s3_client() + store = ArchiveStore(client, bucket="enervision-archives", sse_key=None) + + with Stubber(client) as stub: + stub.add_client_error("get_object", service_error_code=code, http_status_code=statut) + + assert store.fetch_sha256(CLE_ATTENDUE) is None + + +def test_store_fetch_sha256_raises_any_other_error() -> None: + client = s3_client() + store = ArchiveStore(client, bucket="enervision-archives", sse_key=None) + + with Stubber(client) as stub: + stub.add_client_error("get_object", service_error_code="AccessDenied", http_status_code=403) + + with pytest.raises(ClientError): + store.fetch_sha256(CLE_ATTENDUE) + + +def test_decode_sse_key_returns_none_without_a_key() -> None: + assert decode_sse_key(None) is None + + +def test_decode_sse_key_decodes_the_base64_key() -> None: + assert decode_sse_key(SecretStr(CLE_SSE_BASE64)) == CLE_SSE + + +def test_decode_sse_key_refuses_a_key_of_the_wrong_length() -> None: + courte = base64.b64encode(b"trop-courte").decode("ascii") + + with pytest.raises(ValueError, match="exactement 32 octets"): + decode_sse_key(SecretStr(courte)) + + +@pytest.mark.parametrize( + "manquant", + ["s3_endpoint_url", "s3_access_key", "s3_secret_key", "s3_bucket"], +) +def test_build_archive_store_refuses_a_missing_setting(manquant: str) -> None: + with pytest.raises(ValueError, match="APP_S3_ENDPOINT_URL"): + build_archive_store(settings_s3(**{manquant: None})) + + +def test_build_archive_store_refuses_a_sse_key_of_the_wrong_length() -> None: + courte = base64.b64encode(b"trop-courte").decode("ascii") + + with pytest.raises(ValueError, match="exactement 32 octets"): + build_archive_store(settings_s3(s3_sse_key=SecretStr(courte))) + + +def test_build_archive_store_configures_the_client_from_the_settings( + monkeypatch: pytest.MonkeyPatch, +) -> None: + recu: dict[str, Any] = {} + + def faux_client(service: str, **kwargs: Any) -> MagicMock: + recu["service"] = service + recu.update(kwargs) + return MagicMock() + + monkeypatch.setattr(reading_retention.boto3, "client", faux_client) + + build_archive_store(settings_s3()) + + assert recu == { + "service": "s3", + "endpoint_url": "http://garage:3900", + "aws_access_key_id": "GK0123456789", + "aws_secret_access_key": "un-secret-garage", + "region_name": "garage", + } + + +def test_build_archive_store_uses_the_bucket_and_the_decoded_key() -> None: + store = build_archive_store(settings_s3()) + + with Stubber(store._client) as stub: + stub.add_response( + "get_object", + {"Body": streaming(b"hello")}, + expected_params={ + "Bucket": "enervision-archives", + "Key": CLE_ATTENDUE, + "SSECustomerAlgorithm": "AES256", + "SSECustomerKey": CLE_SSE, + }, + ) + + assert store.fetch_sha256(CLE_ATTENDUE) == sha256_of(b"hello") + + +def test_build_archive_store_accepts_an_absent_sse_key() -> None: + store = build_archive_store(settings_s3(s3_sse_key=None)) + + with Stubber(store._client) as stub: + stub.add_response( + "get_object", + {"Body": streaming(b"hello")}, + expected_params={"Bucket": "enervision-archives", "Key": CLE_ATTENDUE}, + ) + + assert store.fetch_sha256(CLE_ATTENDUE) == sha256_of(b"hello") + + +class FakeResult: + def __init__(self, rows: list[Any]) -> None: + self._rows = rows + + def mappings(self) -> FakeResult: + return self + + def scalars(self) -> FakeResult: + return self + + def all(self) -> list[Any]: + return self._rows + + +def chunk_mapping(chunk: Chunk) -> dict[str, Any]: + return { + "chunk_schema": chunk.schema, + "chunk_name": chunk.name, + "range_start": chunk.range_start, + "range_end": chunk.range_end, + } + + +async def test_eligible_chunks_queries_the_timescaledb_catalog() -> None: + conn = AsyncMock() + conn.execute.return_value = FakeResult([chunk_mapping(CHUNK)]) + borne = datetime(2023, 10, 1, tzinfo=UTC) + + chunks = await eligible_chunks(conn, borne) + + assert chunks == [CHUNK] + statement, params = conn.execute.await_args.args + assert statement is ELIGIBLE_CHUNKS + assert params == {"older_than": borne} + + +async def test_read_chunk_rows_reads_through_the_hypertable_within_the_chunk_bounds() -> None: + conn = AsyncMock() + conn.execute.return_value = FakeResult([make_row(), make_row(reading_id=2)]) + + lignes = await read_chunk_rows(conn, CHUNK) + + assert lignes == [make_row(), make_row(reading_id=2)] + statement, params = conn.execute.await_args.args + assert statement is CHUNK_ROWS + assert params == {"start": CHUNK.range_start, "end": CHUNK.range_end} + + +async def test_drop_chunk_targets_the_chunk_by_its_own_bounds() -> None: + conn = AsyncMock() + conn.execute.return_value = FakeResult([CHUNK.qualified_name]) + + await drop_chunk(conn, CHUNK) + + statement, params = conn.execute.await_args.args + assert statement is DROP_CHUNK + assert params == {"start": CHUNK.range_start, "end": CHUNK.range_end} + + +@pytest.mark.parametrize( + "rendu", + [[], ["_timescaledb_internal._hyper_1_7_chunk", "_timescaledb_internal._hyper_1_8_chunk"]], + ids=["aucun_chunk", "deux_chunks"], +) +async def test_drop_chunk_raises_unless_exactly_the_chunk_was_dropped(rendu: list[str]) -> None: + conn = AsyncMock() + conn.execute.return_value = FakeResult(rendu) + + with pytest.raises(RuntimeError, match=r"exactement _timescaledb_internal\._hyper_1_7_chunk"): + await drop_chunk(conn, CHUNK) + + +class FakeConn: + def __init__(self, journal: list[str], chunks: list[Chunk], rows: list[dict[str, Any]]) -> None: + self._journal = journal + self._chunks = chunks + self._rows = rows + + async def execute(self, statement: Any, params: dict[str, Any]) -> FakeResult: + if statement is ELIGIBLE_CHUNKS: + self._journal.append("lister") + return FakeResult([chunk_mapping(chunk) for chunk in self._chunks]) + if statement is CHUNK_ROWS: + self._journal.append("lire") + return FakeResult(self._rows) + self._journal.append("drop") + chunk = next(c for c in self._chunks if c.range_start == params["start"]) + return FakeResult([chunk.qualified_name]) + + +class FakeEngine: + def __init__(self, conn: FakeConn, journal: list[str]) -> None: + self._conn = conn + self._journal = journal + + @asynccontextmanager + async def connect(self) -> AsyncIterator[FakeConn]: + self._journal.append("connect") + yield self._conn + + @asynccontextmanager + async def begin(self) -> AsyncIterator[FakeConn]: + self._journal.append("begin") + yield self._conn + + async def dispose(self) -> None: + self._journal.append("dispose") + + +class FakeStore(ArchiveStore): + def __init__(self, journal: list[str], *, corrompt: bool = False) -> None: + super().__init__(MagicMock(), bucket="enervision-archives", sse_key=None) + self._journal = journal + self._corrompt = corrompt + self.objets: dict[str, str] = {} + self.metadata: dict[str, dict[str, str]] = {} + + def put(self, key: str, body: bytes, metadata: dict[str, str]) -> None: + self._journal.append("put") + self.objets[key] = "sha-corrompu" if self._corrompt else sha256_of(body) + self.metadata[key] = metadata + + def fetch_sha256(self, key: str) -> str | None: + self._journal.append("relire") + return self.objets.get(key) + + +def make_archive( + chunks: list[Chunk] | None = None, + rows: list[dict[str, Any]] | None = None, + *, + corrompt: bool = False, +) -> tuple[FakeEngine, FakeStore, list[str]]: + journal: list[str] = [] + lignes = [make_row(), make_row(reading_id=2)] if rows is None else rows + eligibles = [CHUNK] if chunks is None else chunks + engine = FakeEngine(FakeConn(journal, eligibles, lignes), journal) + return engine, FakeStore(journal, corrompt=corrompt), journal + + +async def test_archive_reading_chunks_reads_exports_verifies_then_drops( + capsys: pytest.CaptureFixture[str], +) -> None: + engine, store, journal = make_archive() + borne = datetime(2023, 10, 1, tzinfo=UTC) + + rapport = await archive_reading_chunks(engine, store, older_than=borne, dry_run=False) + + assert journal == [ + "connect", + "lister", + "connect", + "lire", + "relire", + "put", + "relire", + "begin", + "drop", + ] + assert rapport == Rapport(chunks_vus=1, exportes=1, deja_presents=0, supprimes=1, lignes=2) + assert store.objets[CLE_ATTENDUE] == sha256_of( + serialize_csv_gzip([make_row(), make_row(reading_id=2)]) + ) + assert store.metadata[CLE_ATTENDUE] == {"sha256": store.objets[CLE_ATTENDUE], "rows": "2"} + + sortie = capsys.readouterr().out + assert f"{CLE_ATTENDUE} : 2 ligne(s)" in sortie + assert "exportés et relus" in sortie + assert f"chunk {CHUNK.qualified_name} supprimé" in sortie + assert "Archivage terminé." in sortie + + +async def test_archive_reading_chunks_skips_the_upload_when_the_object_already_matches() -> None: + engine, store, journal = make_archive() + store.objets[CLE_ATTENDUE] = sha256_of(serialize_csv_gzip([make_row(), make_row(reading_id=2)])) + + rapport = await archive_reading_chunks( + engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=False + ) + + assert "put" not in journal + assert journal[-2:] == ["begin", "drop"] + assert rapport == Rapport(chunks_vus=1, exportes=0, deja_presents=1, supprimes=1, lignes=2) + + +async def test_archive_reading_chunks_re_uploads_when_the_stored_object_differs() -> None: + engine, store, journal = make_archive() + store.objets[CLE_ATTENDUE] = "un-autre-sha" + + rapport = await archive_reading_chunks( + engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=False + ) + + assert journal.count("put") == 1 + assert rapport.exportes == 1 + assert rapport.deja_presents == 0 + + +async def test_archive_reading_chunks_in_dry_run_neither_writes_nor_drops( + capsys: pytest.CaptureFixture[str], +) -> None: + engine, store, journal = make_archive() + + rapport = await archive_reading_chunks( + engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=True + ) + + assert "put" not in journal + assert "begin" not in journal + assert "drop" not in journal + assert rapport == Rapport(chunks_vus=1, exportes=0, deja_presents=0, supprimes=0, lignes=2) + + sortie = capsys.readouterr().out + assert "octets à exporter, suppression simulée." in sortie + assert "Dry-run terminé : rien n'a été écrit ni supprimé." in sortie + + +async def test_archive_reading_chunks_keeps_the_chunk_when_the_read_back_differs() -> None: + engine, store, journal = make_archive(corrompt=True) + + with pytest.raises(RuntimeError, match="sha256 sha-corrompu au lieu de"): + await archive_reading_chunks( + engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=False + ) + + assert "put" in journal + assert "drop" not in journal + + +async def test_archive_reading_chunks_drops_an_empty_chunk_without_exporting( + capsys: pytest.CaptureFixture[str], +) -> None: + engine, store, journal = make_archive(rows=[]) + + rapport = await archive_reading_chunks( + engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=False + ) + + assert "put" not in journal + assert "relire" not in journal + assert journal[-2:] == ["begin", "drop"] + assert rapport == Rapport(chunks_vus=1, exportes=0, deja_presents=0, supprimes=1, lignes=0) + assert "vide, rien à exporter" in capsys.readouterr().out + + +async def test_archive_reading_chunks_handles_each_chunk_in_turn() -> None: + suivant = Chunk( + schema=CHUNK.schema, + name="_hyper_1_8_chunk", + range_start=CHUNK.range_end, + range_end=CHUNK.range_end + timedelta(days=7), + ) + engine, store, journal = make_archive(chunks=[CHUNK, suivant]) + + rapport = await archive_reading_chunks( + engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=False + ) + + assert rapport == Rapport(chunks_vus=2, exportes=2, deja_presents=0, supprimes=2, lignes=4) + assert set(store.objets) == {CLE_ATTENDUE, object_key(suivant)} + assert journal.count("drop") == 2 + + +async def test_archive_reading_chunks_reports_nothing_to_do_without_eligible_chunks( + capsys: pytest.CaptureFixture[str], +) -> None: + engine, store, journal = make_archive(chunks=[]) + + rapport = await archive_reading_chunks( + engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=False + ) + + assert rapport == Rapport() + assert journal == ["connect", "lister"] + assert "0 chunk(s) de reading" in capsys.readouterr().out + + +def test_build_parser_defaults_to_the_settings_and_a_real_run() -> None: + arguments = build_parser().parse_args([]) + + assert arguments.older_than_days is None + assert arguments.dry_run is False + + +def test_build_parser_reads_the_bound_and_the_dry_run() -> None: + arguments = build_parser().parse_args(["--older-than-days", "400", "--dry-run"]) + + assert arguments.older_than_days == 400 + assert arguments.dry_run is True + + +def test_build_parser_refuses_a_non_integer_bound() -> None: + with pytest.raises(SystemExit): + build_parser().parse_args(["--older-than-days", "un-an"]) + + +def test_build_parser_answers_help_without_settings(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("APP_SECRET_KEY", raising=False) + + with pytest.raises(SystemExit) as sortie: + build_parser().parse_args(["--help"]) + + assert sortie.value.code == 0 + + +@pytest.fixture +def main_branche(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + capture: dict[str, Any] = {} + journal: list[str] = [] + engine = FakeEngine(FakeConn(journal, [], []), journal) + store = FakeStore(journal) + + async def faux_archive( + engine_recu: Any, store_recu: Any, *, older_than: datetime, dry_run: bool + ) -> Rapport: + capture.update(engine=engine_recu, store=store_recu, older_than=older_than, dry_run=dry_run) + return Rapport() + + def faux_engine(url: str, **kwargs: Any) -> FakeEngine: + capture["url"] = url + capture["engine_kwargs"] = kwargs + return engine + + monkeypatch.setattr(reading_retention, "get_settings", settings_s3) + monkeypatch.setattr(reading_retention, "build_archive_store", lambda settings: store) + monkeypatch.setattr(reading_retention, "create_async_engine", faux_engine) + monkeypatch.setattr(reading_retention, "archive_reading_chunks", faux_archive) + capture["journal"] = journal + capture["store_attendu"] = store + capture["engine_attendu"] = engine + return capture + + +def test_main_uses_the_retention_setting_by_default(main_branche: dict[str, Any]) -> None: + avant = datetime.now(UTC) + + reading_retention.main([]) + + attendu = avant - timedelta(days=1095) + assert timedelta(0) <= main_branche["older_than"] - attendu < timedelta(seconds=5) + assert main_branche["dry_run"] is False + assert main_branche["store"] is main_branche["store_attendu"] + assert main_branche["engine"] is main_branche["engine_attendu"] + assert main_branche["url"] == "postgresql+asyncpg://retention:test@localhost:5432/enervision" + assert main_branche["engine_kwargs"] == {"pool_pre_ping": True} + assert main_branche["journal"] == ["dispose"] + + +def test_main_honours_an_explicit_bound_and_the_dry_run(main_branche: dict[str, Any]) -> None: + avant = datetime.now(UTC) + + reading_retention.main(["--older-than-days", "10", "--dry-run"]) + + attendu = avant - timedelta(days=10) + assert timedelta(0) <= main_branche["older_than"] - attendu < timedelta(seconds=5) + assert main_branche["dry_run"] is True + + +def test_main_fails_before_touching_the_database_without_s3_settings( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(reading_retention, "get_settings", lambda: settings_s3(s3_bucket=None)) + monkeypatch.setattr( + reading_retention, + "create_async_engine", + lambda *_, **__: pytest.fail("l'engine ne doit pas être créé"), + ) + + with pytest.raises(ValueError, match="APP_S3_BUCKET"): + reading_retention.main([]) diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock index 7b67e36..2ec1d36 100644 --- a/apps/backend/uv.lock +++ b/apps/backend/uv.lock @@ -192,6 +192,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, ] +[[package]] +name = "boto3" +version = "1.43.101" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/ef/096f1520a4b0cbc794348fcf77ada637e5f98145c3453f219d678c3a0798/boto3-1.43.101.tar.gz", hash = "sha256:49f3eb750f70e050df9929a7e9392e67896c97d7d0a448f13ed3354c634268bd", size = 112635, upload-time = "2026-09-23T19:23:29.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/73/8dd65374f88b1b2a33656d9c808dc36aef0cb4a22c74d618ba6fe0092cd2/boto3-1.43.101-py3-none-any.whl", hash = "sha256:8a899b0ea94df3f2fab6d0c69caf2791f2971449696834374d8e88ced01c7ef3", size = 140041, upload-time = "2026-09-23T19:23:27.61Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.101" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/12/e90cc51bd65ecdcd0eedcd522d3c9f102b1d2c601f39f1f1c256695d63a3/botocore-1.43.101.tar.gz", hash = "sha256:3bc67fb55046e1e05ce5f2bd0171f37bef1cf54161786ef04ff338614d98169e", size = 16202504, upload-time = "2026-09-23T19:23:24.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/0d/6679253333d6ba74b8ad7077560687096629255ef526e106bb3accceffcc/botocore-1.43.101-py3-none-any.whl", hash = "sha256:f380237ffecc3f887265cd09c4d7e9c8e8dd9ba6162af83b1fc9e5d24622e461", size = 15897867, upload-time = "2026-09-23T19:23:21.643Z" }, +] + +[[package]] +name = "botocore-stubs" +version = "1.43.67" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/45/53d662227dc4787b2c854445ee7eb4751cb5d74cfb5c686a6ecbe1f94c17/botocore_stubs-1.43.67.tar.gz", hash = "sha256:853e74014a1f557055c4ffae5fb38d7c65c7c0520e1aab366cac41d5428f419d", size = 42846, upload-time = "2026-08-08T14:57:53.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/5e/bdbf19967898a032292da65a47d6e25b2eee55865db4e687f861d80b5602/botocore_stubs-1.43.67-py3-none-any.whl", hash = "sha256:c51262bac3341c1cda71f05fa01141fffd3990d7a92c7960e3b755c1bc830373", size = 67244, upload-time = "2026-08-08T14:57:52.01Z" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -325,6 +362,7 @@ dependencies = [ { name = "anyio" }, { name = "argon2-cffi" }, { name = "asyncpg" }, + { name = "boto3" }, { name = "fastapi" }, { name = "httpx" }, { name = "pandas" }, @@ -345,6 +383,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "types-boto3", extra = ["s3"] }, ] [package.metadata] @@ -354,6 +393,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.0" }, { name = "argon2-cffi", specifier = ">=23.1" }, { name = "asyncpg", specifier = ">=0.31.0" }, + { name = "boto3", specifier = ">=1.43.101" }, { name = "fastapi", specifier = ">=0.141.1" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "pandas", specifier = ">=3.0.5" }, @@ -374,6 +414,7 @@ dev = [ { name = "pytest-asyncio", specifier = ">=1.4.0" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "ruff", specifier = ">=0.16.7" }, + { name = "types-boto3", extras = ["s3"], specifier = ">=1.43.101" }, ] [[package]] @@ -496,6 +537,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "librt" version = "0.15.0" @@ -959,6 +1009,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, ] +[[package]] +name = "s3transfer" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1006,6 +1068,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, ] +[[package]] +name = "types-boto3" +version = "1.43.101" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore-stubs" }, + { name = "types-s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/32/e9cfa9a44874cc603220713084bd3d347ee8d3f539748673aa7a62cc7b9c/types_boto3-1.43.101.tar.gz", hash = "sha256:a892e195f6b46e73a3278b08f45dea6470306662647ccf212e8e4366e052e863", size = 105304, upload-time = "2026-09-23T20:24:43.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/9e/57626d063d66db4329a6ee96524cf768a20b60194120a0d71ee962758d2b/types_boto3-1.43.101-py3-none-any.whl", hash = "sha256:a5303a8024fa0588dad70fb7ba5845adaa4d86c0ea395063d7ae33badbc6396f", size = 71672, upload-time = "2026-09-23T20:24:39.849Z" }, +] + +[package.optional-dependencies] +s3 = [ + { name = "types-boto3-s3" }, +] + +[[package]] +name = "types-boto3-s3" +version = "1.43.93" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/1a/285aa2a27436e437aea1c6d6f964b692df3d8c349bf6a35fd476d0b2f7bb/types_boto3_s3-1.43.93.tar.gz", hash = "sha256:6a7f979872b81f6bf22eb4dc39ea9909d635ec756275eca69e9caabdc94d5a6a", size = 79218, upload-time = "2026-09-11T19:44:38.149Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/16/db644e738b967336fb0ca335d708c7d659a965b8ae703e9c50fe209c59be/types_boto3_s3-1.43.93-py3-none-any.whl", hash = "sha256:da9249f05ea081bb3b3f3b8cc49099a988ff5c89da8a7393532397c6174e04d3", size = 86538, upload-time = "2026-09-11T19:44:36.68Z" }, +] + +[[package]] +name = "types-s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/64/42689150509eb3e6e82b33ee3d89045de1592488842ddf23c56957786d05/types_s3transfer-0.16.0.tar.gz", hash = "sha256:b4636472024c5e2b62278c5b759661efeb52a81851cde5f092f24100b1ecb443", size = 13557, upload-time = "2025-12-08T08:13:09.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/27/e88220fe6274eccd3bdf95d9382918716d312f6f6cef6a46332d1ee2feff/types_s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:1c0cd111ecf6e21437cb410f5cddb631bfb2263b77ad973e79b9c6d0cb24e0ef", size = 19247, upload-time = "2025-12-08T08:13:08.426Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -1036,6 +1134,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" }, ] +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] + [[package]] name = "uvicorn" version = "0.53.0" diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 4e4776a..fa0dd4d 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -104,6 +104,16 @@ démarre ne prouve rien sur la base, la première connexion réelle a lieu au pr | `APP_TRUST_PROXY_HEADERS` | `false` | À vrai derrière un proxy, sinon le compteur par IP devient global | | `APP_EXPOSE_API_DOCS` | déduit | Faux en `staging` et `prod` si non renseigné | | `APP_METRICS_TOKEN` | absent | Si présent et non vide, `/metrics` exige `Authorization: Bearer`. Vide vaut absent | +| `APP_S3_ENDPOINT_URL` | absent | Endpoint S3 des archives ; `http://garage:3900` posé par Compose sur `airflow-scheduler`. Vide vaut absent | +| `APP_S3_REGION` | `garage` | Région déclarée au client S3 | +| `APP_S3_ACCESS_KEY` | absent | Identifiant de la clé Garage. Vide vaut absent | +| `APP_S3_SECRET_KEY` | absent | Secret de la clé Garage, `SecretStr`. Vide vaut absent | +| `APP_S3_BUCKET` | absent | Bucket des archives, `enervision-archives` en Compose. Vide vaut absent | +| `APP_S3_SSE_KEY` | absent | Base64 de 32 octets, clé SSE-C des archives, `SecretStr`. Vide vaut absent | +| `APP_READING_RETENTION_DAYS` | `1095` | Profondeur de `reading` en base chaude, 30 jours minimum | + +L'API n'exige aucun des réglages `APP_S3_*` ni `APP_READING_RETENTION_DAYS` : seul +`app.etl.reading_retention` les réclame, et refuse de partir sans endpoint, clés et bucket. Cinq gardes refusent de démarrer plutôt que de laisser passer une erreur silencieuse : secret de moins de 32 caractères ou laissé à sa valeur d'exemple, `debug` en `staging` ou diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index 91ae347..81f5387 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -16,8 +16,9 @@ L'ingestion des **mesures** est implémentée pour les deux sources du MVP, le d l'API Mock. Celle des **alertes** de l'API Mock, `/alerts`, reste à faire : voir l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md). Les alertes `source='enervision'`, elles, sont produites par la détection interne, désormais ordonnancée par le DAG Airflow `alertes` -(issue #116). L'orchestration de l'ingestion, les agrégats continus, la compression et la -rétention restent des cibles. +(issue #116). L'orchestration de l'ingestion, les agrégats continus et la compression restent +des cibles. La rétention de `reading` est faite : chaque chunk plus vieux que la borne est exporté +vers Garage puis supprimé (issue #36, section « Rétention et archivage » ci-dessous). ## Trois emplacements, trois rôles @@ -27,7 +28,7 @@ au mauvais endroit ne s'exécute jamais, ou s'exécute deux fois. | Emplacement | Contenu | Quand ça s'exécute | |---|---|---| | `db/init/` | Extensions, bases annexes | **Une seule fois**, à la première initialisation du conteneur, quand `PGDATA` est vide. Ne rejoue jamais | -| `db/migrations/` | SQL versionné qui ne découle pas du schéma applicatif : rétention, compression | À la main, aujourd'hui vide | +| `db/migrations/` | SQL versionné qui ne découle pas du schéma applicatif : compression. La rétention de `reading` n'y est pas : une politique TimescaleDB ignorerait l'export, elle vit dans `apps/backend/app/etl/reading_retention.py`, ordonnancée par le DAG `retention` ([ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)) | À la main, aujourd'hui vide | | `apps/backend/alembic/` | Le schéma exposé par l'API, et lui seul | `alembic upgrade head`, c'est `Base.metadata` qui fait foi | Une hypertable relève des deux derniers : **Alembic crée la table, et le `create_hypertable()` @@ -75,8 +76,9 @@ Les mécanismes d'ingestion sont maintenant implémentés pour les deux sources Les traitements sont actuellement exécutables directement depuis le backend. -L'orchestration avec Apache Airflow reste une cible, tout comme les agrégats continus, -la compression et les politiques de rétention. +L'orchestration avec Apache Airflow reste une cible, tout comme les agrégats continus et la +compression. La rétention est faite : le DAG `retention` exporte chaque chunk de `reading` plus +vieux que `READING_RETENTION_DAYS` vers Garage, puis le supprime. ```mermaid flowchart LR @@ -91,7 +93,8 @@ flowchart LR hy -.-> agg[("Agrégat continu")] hy -.-> comp["Compression"] - hy -.-> ret["Rétention"] + hy --> ret["Rétention : export CSV gzip vers Garage, puis drop_chunks"] + ret --> garage[("Garage S3")] agg -.-> backend["API FastAPI"] agg -.-> graf["Grafana"] @@ -104,6 +107,26 @@ Les flèches pointillées représentent les éléments encore prévus comme cibl Les lectures de l'API et de Grafana viseront l'agrégat continu, pas la table brute : c'est tout l'intérêt de TimescaleDB, et cela doit rester vrai quand les volumes augmenteront. +### Rétention et archivage (issue #36) + +Statut : `Fait`. + +`apps/backend/app/etl/reading_retention.py`, ordonnancé chaque nuit à 03:20 UTC par le DAG +`retention`, sélectionne dans `timescaledb_information.chunks` les chunks de `reading` dont +`range_end` est antérieur ou égal à `now() - READING_RETENTION_DAYS` : seul un chunk entièrement +plus vieux que la borne est éligible. Chaque chunk est lu via l'hypertable (`WHERE timestamp >= +range_start AND timestamp < range_end`), jamais via la table interne, sérialisé en CSV gzip +reproductible (jsonb et tableaux en JSON trié), puis écrit chiffré SSE-C sous la clé +`reading//reading__.csv.gz`, bornes UTC compactes. L'objet est relu et son +sha256 comparé à celui du corps envoyé ; en cas d'écart le chunk est conservé. Seulement alors +`drop_chunks('reading', older_than => range_end, newer_than => range_start)` supprime ce chunk et +lui seul, dans une transaction dédiée et courte : `drop_chunks` pose un verrou exclusif sur +`reading`, `site` et `dataset` jusqu'au COMMIT. Un objet déjà présent avec le même sha256 n'est pas +réécrit et un chunk supprimé n'est plus éligible : rejouer le DAG est sans effet, `--dry-run` liste +et mesure sans rien écrire. Le premier passage en production archive les chunks de janvier à +septembre 2023 ; la démo, ancrée au 31/12/2024, n'est pas touchée. Restauration manuelle : +télécharger l'objet avec la clé SSE-C, `gunzip`, `COPY` dans `reading` ; aucune commande fournie. + ## Tables d'authentification Statut : `Fait`. @@ -239,8 +262,9 @@ colonne de temps : les index déclarés dans la révision le couvrent déjà. devient ininterprétable dès le premier changement d'heure. - **La colonne de partitionnement entre dans la clé primaire.** Dans `reading` elle s'appelle `timestamp` : c'est un nom de colonne, son type reste `timestamptz`. -- **Les politiques de rétention et de compression** vont dans `db/migrations/`, pas dans Alembic : - elles ne découlent pas du schéma applicatif. +- **Les politiques de compression** vont dans `db/migrations/`, pas dans Alembic : elles ne + découlent pas du schéma applicatif. La rétention de `reading` est un traitement ETL + (`reading_retention.py`), pas une politique TimescaleDB : elle doit exporter avant de supprimer. - **Tout modèle doit être importé dans `app/models/__init__.py`**, sans quoi `alembic revision --autogenerate` ne le voit pas et génère un `drop` de sa table. @@ -251,7 +275,9 @@ livrés : ce qui suit porte sur leur exploitation, plus sur leur forme. - **Quelle granularité** conserver à long terme à l'ingestion : seconde, minute ou quart d'heure. - **Quels agrégats continus** créer et sur quelles fenêtres. -- **Quelle profondeur de rétention** conserver en données brutes et à partir de quand compresser. +- **Quelle profondeur de rétention** : répondu par l'issue #36. Trois ans en base chaude par + défaut (`READING_RETENTION_DAYS`, 1095 jours) ; au-delà, les chunks sont archivés en CSV gzip + sur Garage, chiffrés SSE-C, puis supprimés. Reste ouvert : à partir de quand compresser. - **Multi-tenant ou non** : un site appartient-il à un client et faut-il cloisonner les lectures. ## Modélisation détaillée des données diff --git a/etl/airflow/dags/retention.py b/etl/airflow/dags/retention.py new file mode 100644 index 0000000..c5ded6e --- /dev/null +++ b/etl/airflow/dags/retention.py @@ -0,0 +1,45 @@ +"""DAG de rétention de l'hypertable `reading` : export vers Garage puis suppression (issue #36). + +La nuit, parce que `drop_chunks` pose un verrou exclusif sur `reading`, `site` et `dataset` +jusqu'au COMMIT : un chunk est supprimé dans une transaction courte, mais hors des heures où +l'API et les DAGs horaires écrivent. À :20 pour se glisser entre `ml_score` (à l'heure pile), +`alertes` (à :15) et `mock_api_import` (à :45), bien avant `derive` (05:30). Une reprise est sans +risque : le module est idempotent, un objet déjà exporté avec le même sha256 n'est pas réécrit et +un chunk déjà supprimé n'est plus éligible. La borne vient de `APP_READING_RETENTION_DAYS` +(1095 jours), posée par le compose sur `airflow-scheduler` avec les réglages `APP_S3_*`. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from airflow.providers.standard.operators.bash import BashOperator +from airflow.sdk import DAG + +# Le backend a son propre environnement uv dans l'image (ADR 0008). `--no-sync` et +# `env -u VIRTUAL_ENV` : cf. `ml_train.py`, même raisonnement. +COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m" + +TENTATIVES = 1 +DELAI_ENTRE_TENTATIVES = timedelta(minutes=5) +PLAFOND = timedelta(minutes=20) + +with DAG( + dag_id="retention", + description=( + "Exporte vers Garage puis supprime les chunks de reading plus vieux que la borne de " + "rétention (app.etl.reading_retention)." + ), + schedule="20 3 * * *", + start_date=datetime(2026, 1, 1), + catchup=False, + max_active_runs=1, + tags=["etl", "retention"], +) as dag: + BashOperator( + task_id="archiver", + bash_command=f"{COMMANDE_BACKEND} app.etl.reading_retention", + retries=TENTATIVES, + retry_delay=DELAI_ENTRE_TENTATIVES, + execution_timeout=PLAFOND, + ) diff --git a/etl/airflow/tests/test_dags.py b/etl/airflow/tests/test_dags.py index 9db4292..bdbbf5e 100644 --- a/etl/airflow/tests/test_dags.py +++ b/etl/airflow/tests/test_dags.py @@ -18,6 +18,7 @@ DAG_IDS = [ "historical_import", "mock_api_import", "derive", + "retention", ] TACHES = [ ("ml_train", "train"), @@ -27,6 +28,7 @@ TACHES = [ ("historical_import", "import_historical"), ("mock_api_import", "import_mock_api"), ("derive", "derive"), + ("retention", "archiver"), ] @@ -228,6 +230,24 @@ def test_derive_never_retries_a_detected_drift(dagbag: DagBag) -> None: assert dagbag.dags["derive"].get_task("derive").retries == 0 +def test_retention_runs_nightly(dagbag: DagBag) -> None: + # Entre `ml_score` (:00), `alertes` (:15) et `mock_api_import` (:45) : drop_chunks verrouille + # reading, site et dataset jusqu'au COMMIT. + assert dagbag.dags["retention"].timetable.expression == "20 3 * * *" + + +def test_retention_calls_the_backend_retention_module(dagbag: DagBag) -> None: + commande = dagbag.dags["retention"].get_task("archiver").bash_command + + assert "app.etl.reading_retention" in commande + + +def test_retention_runs_in_the_backend_environment(dagbag: DagBag) -> None: + commande = dagbag.dags["retention"].get_task("archiver").bash_command + + assert "/opt/backend" in commande + + @pytest.mark.parametrize(("dag_id", "task_id"), TACHES) def test_tasks_never_resync_the_baked_environment( dagbag: DagBag, dag_id: str, task_id: str From 7730f184e7e73ed5edaf6415a36dd6fb03c750af Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 24 Sep 2026 10:42:50 +0200 Subject: [PATCH 09/10] =?UTF-8?q?fix(apps):=20l=C3=A8ve=20les=20remarques?= =?UTF-8?q?=20SonarCloud=20sur=20les=20tests=20de=20r=C3=A9tention,=20le?= =?UTF-8?q?=20script=20du=20coffre=20et=20la=20fum=C3=A9e=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/infra.yml | 2 +- .../tests/etl/test_reading_retention.py | 28 ++++++++++++------- scripts/coffre-luks.sh | 13 +++++++-- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/.github/workflows/infra.yml b/.github/workflows/infra.yml index 30afda9..f94e1ba 100644 --- a/.github/workflows/infra.yml +++ b/.github/workflows/infra.yml @@ -114,7 +114,7 @@ jobs: - name: Fumée S3 sur Garage, SSE-C compris run: | set -a; . ./.env; set +a - uvx --with boto3==1.43.101 pytest==9.1.1 tests/garage -q + uvx --no-build --with boto3==1.43.101 pytest==9.1.1 tests/garage -q - name: Journaux de Garage en cas d'échec if: failure() diff --git a/apps/backend/tests/etl/test_reading_retention.py b/apps/backend/tests/etl/test_reading_retention.py index 123c3b0..963a918 100644 --- a/apps/backend/tests/etl/test_reading_retention.py +++ b/apps/backend/tests/etl/test_reading_retention.py @@ -144,7 +144,10 @@ def test_serialize_csv_gzip_writes_jsonb_and_arrays_as_sorted_json() -> None: def test_serialize_csv_gzip_is_byte_for_byte_reproducible() -> None: lignes = [make_row(), make_row(reading_id=2)] - assert serialize_csv_gzip(lignes) == serialize_csv_gzip(lignes) + premier = serialize_csv_gzip(lignes) + second = serialize_csv_gzip(lignes) + + assert premier == second def test_serialize_csv_gzip_refuses_an_empty_export() -> None: @@ -254,10 +257,10 @@ def test_decode_sse_key_decodes_the_base64_key() -> None: def test_decode_sse_key_refuses_a_key_of_the_wrong_length() -> None: - courte = base64.b64encode(b"trop-courte").decode("ascii") + courte = SecretStr(base64.b64encode(b"trop-courte").decode("ascii")) with pytest.raises(ValueError, match="exactement 32 octets"): - decode_sse_key(SecretStr(courte)) + decode_sse_key(courte) @pytest.mark.parametrize( @@ -265,15 +268,18 @@ def test_decode_sse_key_refuses_a_key_of_the_wrong_length() -> None: ["s3_endpoint_url", "s3_access_key", "s3_secret_key", "s3_bucket"], ) def test_build_archive_store_refuses_a_missing_setting(manquant: str) -> None: + reglages = settings_s3(**{manquant: None}) + with pytest.raises(ValueError, match="APP_S3_ENDPOINT_URL"): - build_archive_store(settings_s3(**{manquant: None})) + build_archive_store(reglages) def test_build_archive_store_refuses_a_sse_key_of_the_wrong_length() -> None: courte = base64.b64encode(b"trop-courte").decode("ascii") + reglages = settings_s3(s3_sse_key=SecretStr(courte)) with pytest.raises(ValueError, match="exactement 32 octets"): - build_archive_store(settings_s3(s3_sse_key=SecretStr(courte))) + build_archive_store(reglages) def test_build_archive_store_configures_the_client_from_the_settings( @@ -549,11 +555,10 @@ async def test_archive_reading_chunks_in_dry_run_neither_writes_nor_drops( async def test_archive_reading_chunks_keeps_the_chunk_when_the_read_back_differs() -> None: engine, store, journal = make_archive(corrompt=True) + borne = datetime(2023, 10, 1, tzinfo=UTC) with pytest.raises(RuntimeError, match="sha256 sha-corrompu au lieu de"): - await archive_reading_chunks( - engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=False - ) + await archive_reading_chunks(engine, store, older_than=borne, dry_run=False) assert "put" in journal assert "drop" not in journal @@ -622,15 +627,18 @@ def test_build_parser_reads_the_bound_and_the_dry_run() -> None: def test_build_parser_refuses_a_non_integer_bound() -> None: + parser = build_parser() + with pytest.raises(SystemExit): - build_parser().parse_args(["--older-than-days", "un-an"]) + parser.parse_args(["--older-than-days", "un-an"]) def test_build_parser_answers_help_without_settings(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("APP_SECRET_KEY", raising=False) + parser = build_parser() with pytest.raises(SystemExit) as sortie: - build_parser().parse_args(["--help"]) + parser.parse_args(["--help"]) assert sortie.value.code == 0 diff --git a/scripts/coffre-luks.sh b/scripts/coffre-luks.sh index 9c5335b..4adc1bb 100755 --- a/scripts/coffre-luks.sh +++ b/scripts/coffre-luks.sh @@ -98,7 +98,8 @@ poser_image() { } fstab_contient() { - awk -v cible="$1" '$1 !~ /^#/ && $2 == cible { trouve = 1 } END { exit !trouve }' /etc/fstab + local cible="$1" + awk -v cible="$cible" '$1 !~ /^#/ && $2 == cible { trouve = 1 } END { exit !trouve }' /etc/fstab } poser_persistance() { @@ -132,8 +133,14 @@ CONF systemctl daemon-reload } -empreinte() { find "$1" -type f -printf '%s\n' | awk '{ n++; s += $1 } END { printf "%d fichiers, %d octets", n, s }'; } -libre() { df -B1 --output=avail "$1" | tail -1 | tr -d ' '; } +empreinte() { + local dossier="$1" + find "$dossier" -type f -printf '%s\n' | awk '{ n++; s += $1 } END { printf "%d fichiers, %d octets", n, s }' +} +libre() { + local dossier="$1" + df -B1 --output=avail "$dossier" | tail -1 | tr -d ' ' +} expliquer_migration() { cat < Date: Thu, 24 Sep 2026 10:54:06 +0200 Subject: [PATCH 10/10] docs(adr): constate que la machine ENI est un conteneur LXC, LUKS y est impossible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La garde de scripts/coffre-luks.sh refuse un conteneur LXC ou l'absence de device-mapper. L'ADR 0020, 10-infra.md et la vue d'ensemble disent ce qui est en place (SSE-C des archives) et ce qui relève de l'hôte Proxmox (chiffrement du disque du conteneur). --- ...ockage-objet-garage-et-cycle-de-vie-des-mesures.md | 3 +-- .../0020-chiffrement-au-repos-coffre-luks-et-sse-c.md | 11 +++++++++++ docs/architecture/00-vue-ensemble.md | 9 +++++---- docs/architecture/10-infra.md | 6 +++++- infra/garage/README.md | 4 ++-- scripts/coffre-luks.sh | 5 +++++ 6 files changed, 29 insertions(+), 9 deletions(-) diff --git a/docs/adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md b/docs/adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md index 03e01ef..d276ca5 100644 --- a/docs/adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md +++ b/docs/adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md @@ -39,8 +39,7 @@ sans shell : la garde sur les secrets vit dans le `Makefile` (`garage-garde`, ap **Nœud unique assumé.** `replication_factor = 1` et moteur `sqlite`, avec un instantané des métadonnées toutes les six heures. La documentation de Garage réserve ce facteur aux déploiements de test : ici la machine est unique, la redondance n'existe pour aucun autre service, -et le coffre LUKS de l'[ADR 0020](0020-chiffrement-au-repos-coffre-luks-et-sse-c.md) porte les -volumes. LMDB, le moteur par défaut, se corrompt à l'arrêt brutal et rien ne le reconstruirait. +et le chiffrement au repos est traité à part ([ADR 0020](0020-chiffrement-au-repos-coffre-luks-et-sse-c.md)). LMDB, le moteur par défaut, se corrompt à l'arrêt brutal et rien ne le reconstruirait. **La rétention de `reading` est un traitement du backend, ordonnancé par Airflow.** Le DAG `retention` lance chaque nuit `app.etl.reading_retention` ([ADR 0008](0008-airflow-execute-le-code-du-backend.md)), diff --git a/docs/adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md b/docs/adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md index 2d85256..9efd9b6 100644 --- a/docs/adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md +++ b/docs/adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md @@ -22,6 +22,17 @@ console au démarrage : tout redémarrage doit aboutir sans saisie. ## Décision +**Constat du 24/09, qui borne tout ce qui suit.** La machine `eadl-2025-nantes-g3` n'est pas une +machine virtuelle mais un conteneur LXC Ubuntu 24.04 sur un hôte Proxmox (`systemd-detect-virt` +répond `lxc`, aucun `/dev/mapper/control`, aucun périphérique loop, pas de `/dev/fuse`, module +`dm_crypt` inaccessible). LUKS, comme tout chiffrement au niveau bloc ou FUSE, y est impossible. +Le chiffrement au repos du disque de ce conteneur ne peut se faire que sur l'hôte Proxmox +(volume LUKS ou ZFS chiffré sous le conteneur), par l'administrateur de l'école : la demande +lui est adressée, et jusqu'à sa réponse la base et les métadonnées Garage sont en clair sur ce +disque. `scripts/coffre-luks.sh` détecte ce cas et refuse de démarrer. Ce qui suit reste la +décision pour toute machine où le device-mapper est disponible (la cible k3s de `10-infra.md`, +ou une vraie VM), et le SSE-C des archives est en place dès aujourd'hui. + **Un coffre LUKS2 sous tous les volumes Docker, posé par `scripts/coffre-luks.sh`.** - Le coffre est un **fichier image creux** (`/srv/enervision/coffre.img`, 30 Go par défaut) diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 92ece14..056fd9a 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -141,10 +141,11 @@ consolidée. Argon2id, RBAC à trois rôles. Détail dans [20-backend.md](20-backend.md), décisions dans les [ADR 0002](../adr/0002-authentification-jwt-et-refresh-opaque.md) et [0003](../adr/0003-autorisation-rbac-a-trois-roles.md). -- **Chiffrement au repos.** Sur la VM, tous les volumes Docker (base, Garage, Airflow, - supervision) vivent dans un coffre LUKS2 dont Docker exige le montage pour démarrer ; les - archives de mesures déposées sur Garage sont en plus chiffrées par clé client (SSE-C). Ce que - cela protège et ne protège pas : [ADR 0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md). +- **Chiffrement au repos.** Les archives de mesures déposées sur Garage sont chiffrées par clé + client (SSE-C). Le coffre LUKS des volumes Docker (`scripts/coffre-luks.sh`) est prêt pour une + vraie VM, mais la machine ENI est un conteneur LXC sans device-mapper : le chiffrement de son + disque relève de l'hôte Proxmox, demandé à l'école. Ce qui est couvert et ce qui ne l'est pas : + [ADR 0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md). - **Interdire par défaut.** Toute route exige un jeton, sauf quatre exceptions listées dans un fichier de test qui interroge réellement chaque route sans identifiant. - **Révocation immédiate.** Le compte est relu en base à chaque requête : une désactivation ou un diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md index 528f9e5..2cd38f9 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -302,7 +302,11 @@ l'`apply` n'est pas rejouable sans qu'un administrateur du dépôt en crée un n ### Coffre LUKS des volumes Docker (issue #42) -Statut : `En cours`, script écrit, jamais encore joué sur la VM. Décision et modèle de menace dans +Statut : `Bloqué par la plateforme`. La machine ENI est un conteneur LXC sur Proxmox, sans +device-mapper ni loop : `scripts/coffre-luks.sh` s'y arrête sur sa garde, et le chiffrement du +disque de ce conteneur relève de l'hôte Proxmox, demandé à l'administrateur de l'école. Ce qui +est en place aujourd'hui : le SSE-C des archives déposées sur Garage. Le runbook ci-dessous vaut +pour une vraie VM (cible k3s, ou remplacement du conteneur). Décision et modèle de menace dans l'[ADR 0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md). Un fichier image LUKS2 (`/srv/enervision/coffre.img`, clé `/root/enervision-coffre.key`) est monté sur `/srv/enervision/coffre`, et `/var/lib/docker/volumes` est bind-monté depuis ce coffre : les diff --git a/infra/garage/README.md b/infra/garage/README.md index cc3360f..607c1d4 100644 --- a/infra/garage/README.md +++ b/infra/garage/README.md @@ -11,8 +11,8 @@ Stockage objet S3 de la stack, un conteneur par projet Compose (ADR 0019). Il ne - Ports, sur `127.0.0.1` seulement : `GARAGE_S3_PORT` (3900) pour l'API S3, `GARAGE_ADMIN_PORT` (3903) pour `/health` (sans jeton) et `/metrics` (jeton `GARAGE_METRICS_TOKEN`, scruté par Prometheus). Le RPC 3901 n'est pas publié. -- Nœud unique, `replication_factor = 1`, moteur sqlite : aucune redondance, le coffre LUKS de la - VM (ADR 0020) et l'instantané des métadonnées toutes les six heures sont les seules protections. +- Nœud unique, `replication_factor = 1`, moteur sqlite : aucune redondance, l'instantané des + métadonnées toutes les six heures est la seule protection ; chiffrement au repos : ADR 0020. ```bash docker compose exec garage /garage status diff --git a/scripts/coffre-luks.sh b/scripts/coffre-luks.sh index 4adc1bb..0e575be 100755 --- a/scripts/coffre-luks.sh +++ b/scripts/coffre-luks.sh @@ -48,6 +48,11 @@ installer() { verifier_prerequis() { [[ "$(id -u)" -eq 0 ]] || erreur "à lancer en root" + # Constaté le 24/09 : la machine ENI est un conteneur LXC, sans device-mapper ni loop. LUKS y + # est impossible ; le chiffrement de son disque relève de l'hôte Proxmox (ADR 0020). + [[ "$(systemd-detect-virt --container 2>/dev/null || true)" != lxc ]] \ + || erreur "conteneur LXC : pas de device-mapper ni de loop, LUKS impossible ici ; le chiffrement du disque se fait sur l'hôte (ADR 0020)" + [[ -e /dev/mapper/control ]] || erreur "/dev/mapper/control absent : device-mapper indisponible, LUKS impossible ici" [[ "$COFFRE_IMAGE" != /var/lib/docker/* ]] \ || erreur "l'image $COFFRE_IMAGE ne doit pas vivre sous /var/lib/docker, que le coffre recouvre" installer cryptsetup || erreur "cryptsetup introuvable dans apt"