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/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/.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..f94e1ba 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 --no-build --with boto3==1.43.101 pytest==9.1.1 tests/garage -q + + - name: Journaux de Garage en cas d'échec + if: failure() + run: docker compose logs --tail=100 garage + + - name: Arrête Garage + if: always() + run: docker compose down --volumes + workflows: name: Analyse des workflows if: inputs.workflows 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/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..963a918 --- /dev/null +++ b/apps/backend/tests/etl/test_reading_retention.py @@ -0,0 +1,710 @@ +import base64 +import io +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from datetime import UTC, datetime, timedelta, timezone +from decimal import Decimal +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import boto3 +import pandas as pd +import pytest +from botocore.exceptions import ClientError +from botocore.response import StreamingBody +from botocore.stub import Stubber +from pydantic import SecretStr +from tests.factories import make_settings + +import app.etl.reading_retention as reading_retention +from app.core.config import Settings +from app.etl.reading_retention import ( + CHUNK_ROWS, + DROP_CHUNK, + ELIGIBLE_CHUNKS, + ArchiveStore, + Chunk, + Rapport, + archive_reading_chunks, + build_archive_store, + build_parser, + decode_sse_key, + drop_chunk, + eligible_chunks, + object_key, + read_chunk_rows, + serialize_csv_gzip, + sha256_of, +) + +CLE_SSE = b"0123456789abcdef0123456789abcdef" +CLE_SSE_BASE64 = base64.b64encode(CLE_SSE).decode("ascii") + +CHUNK = Chunk( + schema="_timescaledb_internal", + name="_hyper_1_7_chunk", + range_start=datetime(2023, 1, 5, tzinfo=UTC), + range_end=datetime(2023, 1, 12, tzinfo=UTC), +) +CLE_ATTENDUE = "reading/2023/reading_20230105T000000Z_20230112T000000Z.csv.gz" + + +def make_row(**overrides: Any) -> dict[str, Any]: + ligne: dict[str, Any] = { + "reading_id": 1, + "site_id": "SITE001", + "timestamp": datetime(2023, 1, 5, 12, tzinfo=UTC), + "source": "csv", + "dataset_id": 1, + "consumption_kw": Decimal("87.34"), + "data_quality": "good", + "null_reasons": ["sensor_offline"], + "imputed_values": None, + "raw_data": {"b": 1, "a": "é"}, + } + return {**ligne, **overrides} + + +def settings_s3(**overrides: Any) -> Settings: + reglages: dict[str, Any] = { + "_env_file": None, + "secret_key": SecretStr("secret-de-test-assez-long-pour-le-validateur"), + "database_url": "postgresql+asyncpg://retention:test@localhost:5432/enervision", + "s3_endpoint_url": "http://garage:3900", + "s3_access_key": "GK0123456789", + "s3_secret_key": SecretStr("un-secret-garage"), + "s3_bucket": "enervision-archives", + "s3_sse_key": SecretStr(CLE_SSE_BASE64), + } + return Settings(**{**reglages, **overrides}) + + +def s3_client() -> Any: + return boto3.client( + "s3", + endpoint_url="http://garage:3900", + aws_access_key_id="GK0123456789", + aws_secret_access_key="un-secret-garage", + region_name="garage", + ) + + +def streaming(data: bytes) -> StreamingBody: + return StreamingBody(io.BytesIO(data), len(data)) + + +def test_settings_treat_empty_s3_values_as_absent() -> None: + settings = make_settings( + s3_endpoint_url="", s3_access_key="", s3_secret_key="", s3_bucket="", s3_sse_key="" + ) + + assert settings.s3_endpoint_url is None + assert settings.s3_access_key is None + assert settings.s3_secret_key is None + assert settings.s3_bucket is None + assert settings.s3_sse_key is None + assert settings.reading_retention_days == 1095 + + +def test_object_key_places_the_chunk_under_the_year_of_its_start() -> None: + assert object_key(CHUNK) == CLE_ATTENDUE + + +def test_object_key_expresses_the_bounds_in_utc() -> None: + paris = timezone(timedelta(hours=1)) + chunk = Chunk( + schema=CHUNK.schema, + name=CHUNK.name, + range_start=datetime(2023, 1, 5, 1, tzinfo=paris), + range_end=datetime(2023, 1, 12, 1, tzinfo=paris), + ) + + assert object_key(chunk) == CLE_ATTENDUE + + +def test_serialize_csv_gzip_is_read_back_by_pandas() -> None: + archive = serialize_csv_gzip([make_row(), make_row(reading_id=2, null_reasons=[])]) + + relu = pd.read_csv(io.BytesIO(archive), compression="gzip") + + assert list(relu.columns) == list(make_row()) + assert relu["reading_id"].tolist() == [1, 2] + assert relu["site_id"].tolist() == ["SITE001", "SITE001"] + + +def test_serialize_csv_gzip_writes_jsonb_and_arrays_as_sorted_json() -> None: + archive = serialize_csv_gzip([make_row()]) + + relu = pd.read_csv(io.BytesIO(archive), compression="gzip") + + assert relu.loc[0, "raw_data"] == '{"a": "é", "b": 1}' + assert relu.loc[0, "null_reasons"] == '["sensor_offline"]' + + +def test_serialize_csv_gzip_is_byte_for_byte_reproducible() -> None: + lignes = [make_row(), make_row(reading_id=2)] + + premier = serialize_csv_gzip(lignes) + second = serialize_csv_gzip(lignes) + + assert premier == second + + +def test_serialize_csv_gzip_refuses_an_empty_export() -> None: + with pytest.raises(ValueError, match="Aucune ligne"): + serialize_csv_gzip([]) + + +def test_sha256_of_hashes_the_bytes() -> None: + assert sha256_of(b"hello").startswith("2cf24dba") + + +def test_store_put_sends_the_sse_c_headers_when_a_key_is_set() -> None: + client = s3_client() + store = ArchiveStore(client, bucket="enervision-archives", sse_key=CLE_SSE) + + with Stubber(client) as stub: + stub.add_response( + "put_object", + {}, + expected_params={ + "Bucket": "enervision-archives", + "Key": CLE_ATTENDUE, + "Body": b"corps", + "ContentType": "text/csv", + "ContentEncoding": "gzip", + "Metadata": {"sha256": "abc"}, + "SSECustomerAlgorithm": "AES256", + "SSECustomerKey": CLE_SSE, + }, + ) + store.put(CLE_ATTENDUE, b"corps", {"sha256": "abc"}) + stub.assert_no_pending_responses() + + +def test_store_put_omits_the_sse_c_headers_without_a_key() -> None: + client = s3_client() + store = ArchiveStore(client, bucket="enervision-archives", sse_key=None) + + with Stubber(client) as stub: + stub.add_response( + "put_object", + {}, + expected_params={ + "Bucket": "enervision-archives", + "Key": CLE_ATTENDUE, + "Body": b"corps", + "ContentType": "text/csv", + "ContentEncoding": "gzip", + "Metadata": {}, + }, + ) + store.put(CLE_ATTENDUE, b"corps", {}) + stub.assert_no_pending_responses() + + +def test_store_fetch_sha256_hashes_the_object_read_with_the_key() -> None: + client = s3_client() + store = ArchiveStore(client, bucket="enervision-archives", sse_key=CLE_SSE) + + with Stubber(client) as stub: + stub.add_response( + "get_object", + {"Body": streaming(b"hello")}, + expected_params={ + "Bucket": "enervision-archives", + "Key": CLE_ATTENDUE, + "SSECustomerAlgorithm": "AES256", + "SSECustomerKey": CLE_SSE, + }, + ) + + assert store.fetch_sha256(CLE_ATTENDUE) == sha256_of(b"hello") + + +@pytest.mark.parametrize( + ("code", "statut"), + [("NoSuchKey", 404), ("NotFound", 404), ("NoSuchKey", 400)], + ids=["no_such_key", "404_sans_code_connu", "no_such_key_sans_404"], +) +def test_store_fetch_sha256_returns_none_for_a_missing_object(code: str, statut: int) -> None: + client = s3_client() + store = ArchiveStore(client, bucket="enervision-archives", sse_key=None) + + with Stubber(client) as stub: + stub.add_client_error("get_object", service_error_code=code, http_status_code=statut) + + assert store.fetch_sha256(CLE_ATTENDUE) is None + + +def test_store_fetch_sha256_raises_any_other_error() -> None: + client = s3_client() + store = ArchiveStore(client, bucket="enervision-archives", sse_key=None) + + with Stubber(client) as stub: + stub.add_client_error("get_object", service_error_code="AccessDenied", http_status_code=403) + + with pytest.raises(ClientError): + store.fetch_sha256(CLE_ATTENDUE) + + +def test_decode_sse_key_returns_none_without_a_key() -> None: + assert decode_sse_key(None) is None + + +def test_decode_sse_key_decodes_the_base64_key() -> None: + assert decode_sse_key(SecretStr(CLE_SSE_BASE64)) == CLE_SSE + + +def test_decode_sse_key_refuses_a_key_of_the_wrong_length() -> None: + courte = SecretStr(base64.b64encode(b"trop-courte").decode("ascii")) + + with pytest.raises(ValueError, match="exactement 32 octets"): + decode_sse_key(courte) + + +@pytest.mark.parametrize( + "manquant", + ["s3_endpoint_url", "s3_access_key", "s3_secret_key", "s3_bucket"], +) +def test_build_archive_store_refuses_a_missing_setting(manquant: str) -> None: + reglages = settings_s3(**{manquant: None}) + + with pytest.raises(ValueError, match="APP_S3_ENDPOINT_URL"): + build_archive_store(reglages) + + +def test_build_archive_store_refuses_a_sse_key_of_the_wrong_length() -> None: + courte = base64.b64encode(b"trop-courte").decode("ascii") + reglages = settings_s3(s3_sse_key=SecretStr(courte)) + + with pytest.raises(ValueError, match="exactement 32 octets"): + build_archive_store(reglages) + + +def test_build_archive_store_configures_the_client_from_the_settings( + monkeypatch: pytest.MonkeyPatch, +) -> None: + recu: dict[str, Any] = {} + + def faux_client(service: str, **kwargs: Any) -> MagicMock: + recu["service"] = service + recu.update(kwargs) + return MagicMock() + + monkeypatch.setattr(reading_retention.boto3, "client", faux_client) + + build_archive_store(settings_s3()) + + assert recu == { + "service": "s3", + "endpoint_url": "http://garage:3900", + "aws_access_key_id": "GK0123456789", + "aws_secret_access_key": "un-secret-garage", + "region_name": "garage", + } + + +def test_build_archive_store_uses_the_bucket_and_the_decoded_key() -> None: + store = build_archive_store(settings_s3()) + + with Stubber(store._client) as stub: + stub.add_response( + "get_object", + {"Body": streaming(b"hello")}, + expected_params={ + "Bucket": "enervision-archives", + "Key": CLE_ATTENDUE, + "SSECustomerAlgorithm": "AES256", + "SSECustomerKey": CLE_SSE, + }, + ) + + assert store.fetch_sha256(CLE_ATTENDUE) == sha256_of(b"hello") + + +def test_build_archive_store_accepts_an_absent_sse_key() -> None: + store = build_archive_store(settings_s3(s3_sse_key=None)) + + with Stubber(store._client) as stub: + stub.add_response( + "get_object", + {"Body": streaming(b"hello")}, + expected_params={"Bucket": "enervision-archives", "Key": CLE_ATTENDUE}, + ) + + assert store.fetch_sha256(CLE_ATTENDUE) == sha256_of(b"hello") + + +class FakeResult: + def __init__(self, rows: list[Any]) -> None: + self._rows = rows + + def mappings(self) -> FakeResult: + return self + + def scalars(self) -> FakeResult: + return self + + def all(self) -> list[Any]: + return self._rows + + +def chunk_mapping(chunk: Chunk) -> dict[str, Any]: + return { + "chunk_schema": chunk.schema, + "chunk_name": chunk.name, + "range_start": chunk.range_start, + "range_end": chunk.range_end, + } + + +async def test_eligible_chunks_queries_the_timescaledb_catalog() -> None: + conn = AsyncMock() + conn.execute.return_value = FakeResult([chunk_mapping(CHUNK)]) + borne = datetime(2023, 10, 1, tzinfo=UTC) + + chunks = await eligible_chunks(conn, borne) + + assert chunks == [CHUNK] + statement, params = conn.execute.await_args.args + assert statement is ELIGIBLE_CHUNKS + assert params == {"older_than": borne} + + +async def test_read_chunk_rows_reads_through_the_hypertable_within_the_chunk_bounds() -> None: + conn = AsyncMock() + conn.execute.return_value = FakeResult([make_row(), make_row(reading_id=2)]) + + lignes = await read_chunk_rows(conn, CHUNK) + + assert lignes == [make_row(), make_row(reading_id=2)] + statement, params = conn.execute.await_args.args + assert statement is CHUNK_ROWS + assert params == {"start": CHUNK.range_start, "end": CHUNK.range_end} + + +async def test_drop_chunk_targets_the_chunk_by_its_own_bounds() -> None: + conn = AsyncMock() + conn.execute.return_value = FakeResult([CHUNK.qualified_name]) + + await drop_chunk(conn, CHUNK) + + statement, params = conn.execute.await_args.args + assert statement is DROP_CHUNK + assert params == {"start": CHUNK.range_start, "end": CHUNK.range_end} + + +@pytest.mark.parametrize( + "rendu", + [[], ["_timescaledb_internal._hyper_1_7_chunk", "_timescaledb_internal._hyper_1_8_chunk"]], + ids=["aucun_chunk", "deux_chunks"], +) +async def test_drop_chunk_raises_unless_exactly_the_chunk_was_dropped(rendu: list[str]) -> None: + conn = AsyncMock() + conn.execute.return_value = FakeResult(rendu) + + with pytest.raises(RuntimeError, match=r"exactement _timescaledb_internal\._hyper_1_7_chunk"): + await drop_chunk(conn, CHUNK) + + +class FakeConn: + def __init__(self, journal: list[str], chunks: list[Chunk], rows: list[dict[str, Any]]) -> None: + self._journal = journal + self._chunks = chunks + self._rows = rows + + async def execute(self, statement: Any, params: dict[str, Any]) -> FakeResult: + if statement is ELIGIBLE_CHUNKS: + self._journal.append("lister") + return FakeResult([chunk_mapping(chunk) for chunk in self._chunks]) + if statement is CHUNK_ROWS: + self._journal.append("lire") + return FakeResult(self._rows) + self._journal.append("drop") + chunk = next(c for c in self._chunks if c.range_start == params["start"]) + return FakeResult([chunk.qualified_name]) + + +class FakeEngine: + def __init__(self, conn: FakeConn, journal: list[str]) -> None: + self._conn = conn + self._journal = journal + + @asynccontextmanager + async def connect(self) -> AsyncIterator[FakeConn]: + self._journal.append("connect") + yield self._conn + + @asynccontextmanager + async def begin(self) -> AsyncIterator[FakeConn]: + self._journal.append("begin") + yield self._conn + + async def dispose(self) -> None: + self._journal.append("dispose") + + +class FakeStore(ArchiveStore): + def __init__(self, journal: list[str], *, corrompt: bool = False) -> None: + super().__init__(MagicMock(), bucket="enervision-archives", sse_key=None) + self._journal = journal + self._corrompt = corrompt + self.objets: dict[str, str] = {} + self.metadata: dict[str, dict[str, str]] = {} + + def put(self, key: str, body: bytes, metadata: dict[str, str]) -> None: + self._journal.append("put") + self.objets[key] = "sha-corrompu" if self._corrompt else sha256_of(body) + self.metadata[key] = metadata + + def fetch_sha256(self, key: str) -> str | None: + self._journal.append("relire") + return self.objets.get(key) + + +def make_archive( + chunks: list[Chunk] | None = None, + rows: list[dict[str, Any]] | None = None, + *, + corrompt: bool = False, +) -> tuple[FakeEngine, FakeStore, list[str]]: + journal: list[str] = [] + lignes = [make_row(), make_row(reading_id=2)] if rows is None else rows + eligibles = [CHUNK] if chunks is None else chunks + engine = FakeEngine(FakeConn(journal, eligibles, lignes), journal) + return engine, FakeStore(journal, corrompt=corrompt), journal + + +async def test_archive_reading_chunks_reads_exports_verifies_then_drops( + capsys: pytest.CaptureFixture[str], +) -> None: + engine, store, journal = make_archive() + borne = datetime(2023, 10, 1, tzinfo=UTC) + + rapport = await archive_reading_chunks(engine, store, older_than=borne, dry_run=False) + + assert journal == [ + "connect", + "lister", + "connect", + "lire", + "relire", + "put", + "relire", + "begin", + "drop", + ] + assert rapport == Rapport(chunks_vus=1, exportes=1, deja_presents=0, supprimes=1, lignes=2) + assert store.objets[CLE_ATTENDUE] == sha256_of( + serialize_csv_gzip([make_row(), make_row(reading_id=2)]) + ) + assert store.metadata[CLE_ATTENDUE] == {"sha256": store.objets[CLE_ATTENDUE], "rows": "2"} + + sortie = capsys.readouterr().out + assert f"{CLE_ATTENDUE} : 2 ligne(s)" in sortie + assert "exportés et relus" in sortie + assert f"chunk {CHUNK.qualified_name} supprimé" in sortie + assert "Archivage terminé." in sortie + + +async def test_archive_reading_chunks_skips_the_upload_when_the_object_already_matches() -> None: + engine, store, journal = make_archive() + store.objets[CLE_ATTENDUE] = sha256_of(serialize_csv_gzip([make_row(), make_row(reading_id=2)])) + + rapport = await archive_reading_chunks( + engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=False + ) + + assert "put" not in journal + assert journal[-2:] == ["begin", "drop"] + assert rapport == Rapport(chunks_vus=1, exportes=0, deja_presents=1, supprimes=1, lignes=2) + + +async def test_archive_reading_chunks_re_uploads_when_the_stored_object_differs() -> None: + engine, store, journal = make_archive() + store.objets[CLE_ATTENDUE] = "un-autre-sha" + + rapport = await archive_reading_chunks( + engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=False + ) + + assert journal.count("put") == 1 + assert rapport.exportes == 1 + assert rapport.deja_presents == 0 + + +async def test_archive_reading_chunks_in_dry_run_neither_writes_nor_drops( + capsys: pytest.CaptureFixture[str], +) -> None: + engine, store, journal = make_archive() + + rapport = await archive_reading_chunks( + engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=True + ) + + assert "put" not in journal + assert "begin" not in journal + assert "drop" not in journal + assert rapport == Rapport(chunks_vus=1, exportes=0, deja_presents=0, supprimes=0, lignes=2) + + sortie = capsys.readouterr().out + assert "octets à exporter, suppression simulée." in sortie + assert "Dry-run terminé : rien n'a été écrit ni supprimé." in sortie + + +async def test_archive_reading_chunks_keeps_the_chunk_when_the_read_back_differs() -> None: + engine, store, journal = make_archive(corrompt=True) + borne = datetime(2023, 10, 1, tzinfo=UTC) + + with pytest.raises(RuntimeError, match="sha256 sha-corrompu au lieu de"): + await archive_reading_chunks(engine, store, older_than=borne, dry_run=False) + + assert "put" in journal + assert "drop" not in journal + + +async def test_archive_reading_chunks_drops_an_empty_chunk_without_exporting( + capsys: pytest.CaptureFixture[str], +) -> None: + engine, store, journal = make_archive(rows=[]) + + rapport = await archive_reading_chunks( + engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=False + ) + + assert "put" not in journal + assert "relire" not in journal + assert journal[-2:] == ["begin", "drop"] + assert rapport == Rapport(chunks_vus=1, exportes=0, deja_presents=0, supprimes=1, lignes=0) + assert "vide, rien à exporter" in capsys.readouterr().out + + +async def test_archive_reading_chunks_handles_each_chunk_in_turn() -> None: + suivant = Chunk( + schema=CHUNK.schema, + name="_hyper_1_8_chunk", + range_start=CHUNK.range_end, + range_end=CHUNK.range_end + timedelta(days=7), + ) + engine, store, journal = make_archive(chunks=[CHUNK, suivant]) + + rapport = await archive_reading_chunks( + engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=False + ) + + assert rapport == Rapport(chunks_vus=2, exportes=2, deja_presents=0, supprimes=2, lignes=4) + assert set(store.objets) == {CLE_ATTENDUE, object_key(suivant)} + assert journal.count("drop") == 2 + + +async def test_archive_reading_chunks_reports_nothing_to_do_without_eligible_chunks( + capsys: pytest.CaptureFixture[str], +) -> None: + engine, store, journal = make_archive(chunks=[]) + + rapport = await archive_reading_chunks( + engine, store, older_than=datetime(2023, 10, 1, tzinfo=UTC), dry_run=False + ) + + assert rapport == Rapport() + assert journal == ["connect", "lister"] + assert "0 chunk(s) de reading" in capsys.readouterr().out + + +def test_build_parser_defaults_to_the_settings_and_a_real_run() -> None: + arguments = build_parser().parse_args([]) + + assert arguments.older_than_days is None + assert arguments.dry_run is False + + +def test_build_parser_reads_the_bound_and_the_dry_run() -> None: + arguments = build_parser().parse_args(["--older-than-days", "400", "--dry-run"]) + + assert arguments.older_than_days == 400 + assert arguments.dry_run is True + + +def test_build_parser_refuses_a_non_integer_bound() -> None: + parser = build_parser() + + with pytest.raises(SystemExit): + parser.parse_args(["--older-than-days", "un-an"]) + + +def test_build_parser_answers_help_without_settings(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("APP_SECRET_KEY", raising=False) + parser = build_parser() + + with pytest.raises(SystemExit) as sortie: + parser.parse_args(["--help"]) + + assert sortie.value.code == 0 + + +@pytest.fixture +def main_branche(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + capture: dict[str, Any] = {} + journal: list[str] = [] + engine = FakeEngine(FakeConn(journal, [], []), journal) + store = FakeStore(journal) + + async def faux_archive( + engine_recu: Any, store_recu: Any, *, older_than: datetime, dry_run: bool + ) -> Rapport: + capture.update(engine=engine_recu, store=store_recu, older_than=older_than, dry_run=dry_run) + return Rapport() + + def faux_engine(url: str, **kwargs: Any) -> FakeEngine: + capture["url"] = url + capture["engine_kwargs"] = kwargs + return engine + + monkeypatch.setattr(reading_retention, "get_settings", settings_s3) + monkeypatch.setattr(reading_retention, "build_archive_store", lambda settings: store) + monkeypatch.setattr(reading_retention, "create_async_engine", faux_engine) + monkeypatch.setattr(reading_retention, "archive_reading_chunks", faux_archive) + capture["journal"] = journal + capture["store_attendu"] = store + capture["engine_attendu"] = engine + return capture + + +def test_main_uses_the_retention_setting_by_default(main_branche: dict[str, Any]) -> None: + avant = datetime.now(UTC) + + reading_retention.main([]) + + attendu = avant - timedelta(days=1095) + assert timedelta(0) <= main_branche["older_than"] - attendu < timedelta(seconds=5) + assert main_branche["dry_run"] is False + assert main_branche["store"] is main_branche["store_attendu"] + assert main_branche["engine"] is main_branche["engine_attendu"] + assert main_branche["url"] == "postgresql+asyncpg://retention:test@localhost:5432/enervision" + assert main_branche["engine_kwargs"] == {"pool_pre_ping": True} + assert main_branche["journal"] == ["dispose"] + + +def test_main_honours_an_explicit_bound_and_the_dry_run(main_branche: dict[str, Any]) -> None: + avant = datetime.now(UTC) + + reading_retention.main(["--older-than-days", "10", "--dry-run"]) + + attendu = avant - timedelta(days=10) + assert timedelta(0) <= main_branche["older_than"] - attendu < timedelta(seconds=5) + assert main_branche["dry_run"] is True + + +def test_main_fails_before_touching_the_database_without_s3_settings( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(reading_retention, "get_settings", lambda: settings_s3(s3_bucket=None)) + monkeypatch.setattr( + reading_retention, + "create_async_engine", + lambda *_, **__: pytest.fail("l'engine ne doit pas être créé"), + ) + + with pytest.raises(ValueError, match="APP_S3_BUCKET"): + reading_retention.main([]) 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/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..d276ca5 --- /dev/null +++ b/docs/adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md @@ -0,0 +1,96 @@ +# 0019 - Stockage objet Garage par environnement, et cycle de vie des mesures : export puis suppression + +- Statut : accepté +- Date : 2026-09-24 + +## Contexte + +Les issues #24 « Déployer MinIO » et #36 « Politique de rétention + export vers MinIO » datent du +cadrage du 14/09. Au 24/09, la hypertable `reading` grossit d'une lecture par site et par heure +sans qu'aucune politique ne la borne, et `docs/architecture/40-data.md` classe rétention et +compression parmi les cibles non faites. Aucun stockage objet ne tourne. + +La PR 164 a posé une amorce : un projet Compose à part dans `garage/`, l'image `dxflrs/garage:v1.0.1`, +des secrets dans un `garage.toml` gitignoré et des tests de fumée boto3 que rien ne jouait. Rien +n'était branché sur les trois environnements de la VM ([ADR 0009](0009-deux-environnements-compose-sur-la-vm-eni.md), +[ADR 0017](0017-environnement-dev-a-la-demande.md)), ni sur la CI, ni sur la supervision. + +Contrainte propre au projet : le jeu historique s'arrête au 31/12/2024 et `make demo-data` s'y +ancre. Une rétention sous vingt-et-un mois effacerait la démonstration. + +## Décision + +**Garage plutôt que MinIO**, en `v2.4.1`. Un binaire statique de quelques dizaines de Mo, une +API S3 suffisante pour boto3, des métriques Prometheus natives, et depuis la `v2.3.0` un mode +`--single-node --default-bucket` qui crée layout, clé et bucket au premier démarrage à partir de +trois variables d'environnement : aucun conteneur d'initialisation, aucune séquence CLI à rejouer. + +**Un Garage par projet Compose.** Le service `garage` vit dans `docker-compose.yml`, comme `db` +et `mailpit`. Chaque environnement a le sien, ses volumes `garage_meta` et `garage_data`, ses +secrets et ses ports sur `127.0.0.1` : S3 `3900`, `3910`, `3920` et admin `3903`, `3913`, `3923` +pour prod, recette et dev. Le RPC n'est pas publié. Rien ne passe par le proxy. + +**`infra/garage/garage.toml` est versionné sans secret.** `GARAGE_RPC_SECRET` (32 octets +hexadécimaux), `GARAGE_ADMIN_TOKEN` et `GARAGE_METRICS_TOKEN` arrivent par l'environnement, comme +les autres secrets du `.env`, générés par `scripts/provision-host.sh`. L'image est `FROM scratch`, +sans shell : la garde sur les secrets vit dans le `Makefile` (`garage-garde`, appelée par +`services-up` et `stack-up`), et le healthcheck est `garage health -q`. + +**Nœud unique assumé.** `replication_factor = 1` et moteur `sqlite`, avec un instantané des +métadonnées toutes les six heures. La documentation de Garage réserve ce facteur aux +déploiements de test : ici la machine est unique, la redondance n'existe pour aucun autre service, +et le chiffrement au repos est traité à part ([ADR 0020](0020-chiffrement-au-repos-coffre-luks-et-sse-c.md)). LMDB, le moteur par défaut, se corrompt à l'arrêt brutal et rien ne le reconstruirait. + +**La rétention de `reading` est un traitement du backend, ordonnancé par Airflow.** Le DAG +`retention` lance chaque nuit `app.etl.reading_retention` ([ADR 0008](0008-airflow-execute-le-code-du-backend.md)), +qui, pour chaque chunk entièrement plus vieux que `READING_RETENTION_DAYS` (1095 jours par défaut) : + +1. lit ses lignes par la hypertable (`WHERE timestamp >= range_start AND timestamp < range_end`) ; +2. les sérialise en CSV gzip reproductible, les colonnes `jsonb` et `text[]` en JSON ; +3. les dépose sur Garage sous `reading//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/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..9efd9b6 --- /dev/null +++ b/docs/adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md @@ -0,0 +1,109 @@ +# 0020 - Chiffrement au repos : coffre LUKS des volumes Docker et SSE-C des archives + +- Statut : accepté +- Date : 2026-09-24 + +## Contexte + +L'issue #42 demande que les données de la plateforme soient chiffrées au repos. Tout ce que la +plateforme persiste vit dans les volumes Docker nommés des trois projets Compose de la VM ENI +([ADR 0009](0009-deux-environnements-compose-sur-la-vm-eni.md), +[ADR 0017](0017-environnement-dev-a-la-demande.md)), sous `/var/lib/docker/volumes` : la base +TimescaleDB (`pgdata`, relevés, comptes, audit), les métadonnées et les objets de Garage +(`garage_meta`, `garage_data`, les archives des chunks de `reading` exportées par le DAG +`retention`, [ADR 0019](0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)), les journaux et l'état ML d'Airflow, les séries de Prometheus et la base +de Grafana. + +Aucun des deux dépôts de données ne chiffre lui-même : Garage n'a pas de chiffrement côté serveur +et sa documentation renvoie à un volume LUKS sous ses données ; PostgreSQL communautaire n'a pas de +chiffrement transparent des données (TDE), et l'image `timescaledb-ha` n'en ajoute pas. La VM est +unique, sur un seul disque virtuel, sans partition libre, sans TPM, et personne n'est devant sa +console au démarrage : tout redémarrage doit aboutir sans saisie. + +## Décision + +**Constat du 24/09, qui borne tout ce qui suit.** La machine `eadl-2025-nantes-g3` n'est pas une +machine virtuelle mais un conteneur LXC Ubuntu 24.04 sur un hôte Proxmox (`systemd-detect-virt` +répond `lxc`, aucun `/dev/mapper/control`, aucun périphérique loop, pas de `/dev/fuse`, module +`dm_crypt` inaccessible). LUKS, comme tout chiffrement au niveau bloc ou FUSE, y est impossible. +Le chiffrement au repos du disque de ce conteneur ne peut se faire que sur l'hôte Proxmox +(volume LUKS ou ZFS chiffré sous le conteneur), par l'administrateur de l'école : la demande +lui est adressée, et jusqu'à sa réponse la base et les métadonnées Garage sont en clair sur ce +disque. `scripts/coffre-luks.sh` détecte ce cas et refuse de démarrer. Ce qui suit reste la +décision pour toute machine où le device-mapper est disponible (la cible k3s de `10-infra.md`, +ou une vraie VM), et le SSE-C des archives est en place dès aujourd'hui. + +**Un coffre LUKS2 sous tous les volumes Docker, posé par `scripts/coffre-luks.sh`.** + +- Le coffre est un **fichier image creux** (`/srv/enervision/coffre.img`, 30 Go par défaut) + formaté en **LUKS2**, ouvert par une **clé de 64 octets tirée de `/dev/urandom`**, lisible par + root seulement (`/root/enervision-coffre.key`, `0400`). Un fichier plutôt qu'une partition : la + VM n'en a pas de libre, et l'image se déplace ou se sauvegarde comme un fichier. +- Le mapper `enervision-coffre` porte un ext4 monté sur `/srv/enervision/coffre`, et + `/var/lib/docker/volumes` est **bind-monté** depuis `/srv/enervision/coffre/docker-volumes`. + Docker ne voit qu'un dossier ordinaire : ni `data-root`, ni les fichiers Compose, ni les noms + de volumes ne changent, et les trois environnements sont couverts d'un coup. +- L'ouverture et les montages sont déclarés dans **`/etc/crypttab` et `/etc/fstab`, avec + `nofail`** sur les trois lignes : un coffre absent ne doit jamais envoyer la machine en mode + urgence, où SSH ne répond plus. Sur Debian 13 le générateur crypttab est dans le paquet + `systemd-cryptsetup`, installé par le script s'il existe dans apt. +- Un **drop-in `RequiresMountsFor=/var/lib/docker/volumes`** sur `docker.service` fait la + barrière : sans le bind, Docker ne démarre pas, plutôt que de recréer des volumes vides en + clair et de laisser trois stacks se lever sur des bases neuves. +- Le script est **rejouable** : clé, image, formatage, système de fichiers, crypttab, fstab et + drop-in ne sont posés que s'ils manquent, et il sort sans rien toucher si + `/var/lib/docker/volumes` est déjà servi par le coffre. La **migration à froid** des volumes + existants n'a lieu qu'avec `COFFRE_MIGRER=1` : refus si `live-restore` est actif, arrêt de + `docker.socket` et `docker.service`, `rsync -aHAX --numeric-ids`, comparaison du nombre et de la + taille des fichiers, puis bascule du dossier et redémarrage de Docker. L'ancien dossier reste en + `/var/lib/docker/volumes.avant-coffre` jusqu'à validation par un redémarrage. +- Terraform peut le jouer : `null_resource.coffre`, activé par `coffre_taille` non vide, + s'exécute après Docker et avant `provision-host.sh`. La ressource est optionnelle et absente du + plan tant que la variable est vide. + +**SSE-C sur les archives exportées vers Garage.** Le module d'export du DAG `retention` envoie +chaque archive avec une clé client (`GARAGE_SSE_KEY`, générée dans le `.env` par +`provision-host.sh`) ; Garage la chiffre en AES-256-GCM et n'en garde que l'empreinte. Les objets +sont donc chiffrés une seconde fois, avec une clé distincte de celle du coffre, dans le seul +dépôt que l'on pourrait un jour sortir de la VM. + +## Alternatives écartées + +| Écartée | Raison | +|---|---| +| `pgcrypto`, chiffrement par colonne | Ne couvre ni les index, ni les journaux WAL, ni Garage, ni Airflow ; la clé serait dans l'application, à côté des données, pour un coût de développement et de requête sur chaque lecture d'hypertable. | +| Déplacer le `data-root` de Docker dans le coffre | Chiffre aussi les images et les couches, sans valeur, et impose de recopier tout `/var/lib/docker` : plus long, plus de place, et le démon doit être reconfiguré. Seuls les volumes portent des données. | +| Chiffrer côté client dans le module d'export | Couvre les archives et rien d'autre, avec une bibliothèque cryptographique à porter dans le code métier alors que Garage offre SSE-C. Retenu seulement sous cette forme, en complément du coffre. | +| Volume Docker chiffré par un plugin | Un plugin tiers par volume nommé, à installer et suivre sur la machine, pour huit volumes par environnement ; le coffre les couvre tous d'un bind. | +| Disque ou partition dédiée | La VM n'a qu'un disque virtuel, sans partition libre, et son redimensionnement n'est pas dans les mains de l'équipe. | +| Clé saisie au démarrage | Personne devant la console ; un redémarrage de la VM par l'école laisserait la plateforme arrêtée jusqu'à intervention. | +| Clé scellée dans un TPM | La VM n'en expose pas. | + +## Conséquences + +- **Ce que le coffre protège, et ce qu'il ne protège pas.** La clé et l'image vivent sur le même + disque. Le coffre protège une copie isolée de l'image ou du disque : snapshot, sauvegarde, + décommissionnement du disque virtuel. Il ne protège ni du vol du disque entier, où la clé se + trouve aussi, ni d'un root sur l'hôte allumé, qui lit le montage en clair. La copie + `.avant-coffre`, supprimée après validation, n'est pas effaçable physiquement sur un disque + virtuel. La clé SSE-C transite en clair sur le réseau Compose interne, entre `airflow-scheduler` + et Garage, à chaque objet envoyé. +- **Perte de la clé, perte de tout.** Sans `/root/enervision-coffre.key`, l'image est illisible + et les trois bases avec elle. La clé est à sauvegarder hors de la VM tout de suite après la + pose, dans un emplacement que seuls les administrateurs lisent. +- **Coupure lors de la migration.** La copie des volumes se fait Docker arrêté : les trois + environnements sont indisponibles une à trois minutes, et le disque doit porter deux fois la + taille des volumes jusqu'à la suppression de `.avant-coffre`. +- **Redémarrage de test obligatoire.** L'ordonnancement crypttab, fstab, drop-in ne se vérifie + qu'en redémarrant : `findmnt /var/lib/docker/volumes` et `docker ps` après le reboot, avant de + supprimer la copie en clair. +- **Docker dépend du coffre.** Si l'image ou la clé disparaît, Docker refuse de démarrer + (`dependency failed`) et la machine reste joignable par SSH ; c'est voulu. Retirer la ligne + fstab du bind retire cette protection sans message. +- **Rotation.** La clé LUKS se change par `cryptsetup luksChangeKey` sans réécrire les données. + `GARAGE_SSE_KEY` ne se change pas sans réécrire chaque objet : Garage n'a pas de re-chiffrement + côté serveur, et un objet écrit avec l'ancienne clé ne se lit qu'avec elle. +- **Terraform interrompt la stack, une fois.** La première pose avec `coffre_taille` est la seule + ressource de `vm-eni` qui arrête Docker, en contradiction assumée avec + l'[ADR 0010](0010-terraform-provisionne-github-actions-deploie.md) pour cette seule occasion ; + les `apply` suivants trouvent le coffre en place et n'y touchent pas. diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 7a0666d..056fd9a 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,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.** 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 @@ -202,3 +208,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/10-infra.md b/docs/architecture/10-infra.md index b35e56f..2cd38f9 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,35 @@ sonde de `deploy.yml` qui le dit. Le jeton d'enregistrement du runner est valable une heure et ne vaut que pour une inscription : l'`apply` n'est pas rejouable sans qu'un administrateur du dépôt en crée un nouveau. +### Coffre LUKS des volumes Docker (issue #42) + +Statut : `Bloqué par la plateforme`. La machine ENI est un conteneur LXC sur Proxmox, sans +device-mapper ni loop : `scripts/coffre-luks.sh` s'y arrête sur sa garde, et le chiffrement du +disque de ce conteneur relève de l'hôte Proxmox, demandé à l'administrateur de l'école. Ce qui +est en place aujourd'hui : le SSE-C des archives déposées sur Garage. Le runbook ci-dessous vaut +pour une vraie VM (cible k3s, ou remplacement du conteneur). Décision et modèle de menace dans +l'[ADR 0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md). Un fichier image LUKS2 +(`/srv/enervision/coffre.img`, clé `/root/enervision-coffre.key`) est monté sur +`/srv/enervision/coffre`, et `/var/lib/docker/volumes` est bind-monté depuis ce coffre : les +volumes des trois environnements sont chiffrés au repos sans qu'un fichier Compose change. +`scripts/coffre-luks.sh` pose tout, rejouable ; Terraform le joue aussi quand `coffre_taille` est +renseignée. Prérequis : deux fois la taille actuelle des volumes libre sur le disque, le temps de +la migration. Runbook, joué en root sur la VM, coupure des trois environnements d'une à trois +minutes : + +```bash +scp scripts/coffre-luks.sh root@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/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/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/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 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/garage/README.md b/infra/garage/README.md new file mode 100644 index 0000000..607c1d4 --- /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, 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 +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/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/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/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..0e575be --- /dev/null +++ b/scripts/coffre-luks.sh @@ -0,0 +1,234 @@ +#!/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" + # 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" + # 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() { + local cible="$1" + awk -v cible="$cible" '$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 <