Compare commits

..
Author SHA1 Message Date
Johan LEROY f011fce84e docs(adr): constate que la machine ENI est un conteneur LXC, LUKS y est impossible
La garde de scripts/coffre-luks.sh refuse un conteneur LXC ou l'absence de device-mapper.
L'ADR 0020, 10-infra.md et la vue d'ensemble disent ce qui est en place (SSE-C des archives)
et ce qui relève de l'hôte Proxmox (chiffrement du disque du conteneur).
2026-09-24 10:54:06 +02:00
Johan LEROY 7730f184e7 fix(apps): lève les remarques SonarCloud sur les tests de rétention, le script du coffre et la fumée CI 2026-09-24 10:42:50 +02:00
Johan LEROY fa815f49b6 feat(apps): archive vers Garage puis supprime les chunks anciens de reading
Nouveau module app.etl.reading_retention : pour chaque chunk de `reading` entièrement plus
vieux que APP_READING_RETENTION_DAYS (1095 jours), export CSV gzip reproductible vers Garage
(SSE-C, sha256 en métadonnées), relecture et comparaison, puis drop_chunks ciblé sur ce seul
chunk dans une transaction dédiée. --dry-run. Réglages APP_S3_* optionnels, jamais exigés par
l'API. DAG Airflow `retention` quotidien à 03h20. 44 tests unitaires sans réseau ni base,
tests d'intégrité du DAG, vérification --help dans l'image Airflow en CI. Docs 40-data et
20-backend.

Closes #36
2026-09-24 10:36:09 +02:00
Johan LEROY 0462dd01ba feat(scripts): chiffre au repos les volumes Docker de la VM par un coffre LUKS
scripts/coffre-luks.sh pose un coffre LUKS2 dans un fichier image, bind-monte
/var/lib/docker/volumes depuis ce coffre et exige son montage pour que Docker démarre
(drop-in RequiresMountsFor). Migration à froid rejouable, jouée par l'opérateur ; null_resource
Terraform optionnel (coffre_taille). Les archives déposées sur Garage sont chiffrées par SSE-C
(clé GARAGE_SSE_KEY). ADR 0020, runbook et tableaux Garage dans 10-infra.md.

Closes #42
2026-09-24 10:35:31 +02:00
Johan LEROY e53c7e441c feat(infra): déploie Garage par environnement, secrets par le .env, fumée S3 en CI
Reprend l'amorce de la PR 164 et l'intègre à la stack : service `garage` (dxflrs/garage v2.4.1,
`--single-node --default-bucket`) dans docker-compose.yml, garage.toml versionné sans secret,
ports sur 127.0.0.1 décalés par environnement dans provision-host.sh, garde des six clés
GARAGE_* dans le Makefile, cible Prometheus avec jeton, tests de fumée déplacés dans
tests/garage et joués par le job compose d'infra.yml contre le vrai conteneur, SSE-C compris.
Variables APP_S3_* et APP_READING_RETENTION_DAYS posées sur airflow-scheduler pour le DAG
`retention`. ADR 0019.

Closes #24
2026-09-24 10:30:04 +02:00
Johan LEROY 0a7a802b38 Merge remote-tracking branch 'origin/dev' into feat/deployer-docker-garage 2026-09-24 10:22:25 +02:00
Valentin e0089537d0 feat(garage): configuration Docker Compose et tests de fumée S3 2026-09-23 16:27:03 +02:00
PhyriosandGitHub 6b41ec900c Merge pull request #162 from ineszang/feat/reconciliation-dag
fix(backend,airflow,ml): cloture la reconciliation entre les deux sou…
2026-09-23 15:59:44 +02:00
33 changed files with 2113 additions and 26 deletions
+25
View File
@@ -52,6 +52,31 @@ AIRFLOW_ADMIN_EMAIL=admin@enervision.fr
# python -c "import secrets; print(secrets.token_urlsafe(48))" # python -c "import secrets; print(secrets.token_urlsafe(48))"
AIRFLOW_APP_SECRET_KEY=change_me 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). # 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 alimente l'origine CORS, le lien de réinitialisation et le certificat.
PUBLIC_HOST=enervision.local PUBLIC_HOST=enervision.local
+3 -2
View File
@@ -74,11 +74,12 @@ jobs:
# `--help` sort par argparse avant `get_settings()` : ni base ni secret requis, et # `--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. # 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: > run: >
docker run --rm --network none enervision-airflow:ci docker run --rm --network none enervision-airflow:ci
bash -c "cd /opt/backend 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.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.cli generate-recommendations --help
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.historical_import --help && env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.historical_import --help
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.mock_api_import --help" && 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"
+2
View File
@@ -82,6 +82,8 @@ jobs:
- "docker-compose*.yml" - "docker-compose*.yml"
- ".env.example" - ".env.example"
- "infra/front/**" - "infra/front/**"
- "infra/garage/**"
- "tests/garage/**"
- "monitoring/**" - "monitoring/**"
- ".github/workflows/infra.yml" - ".github/workflows/infra.yml"
workflows: workflows:
+33
View File
@@ -67,6 +67,16 @@ jobs:
- name: Prépare un .env d'exemple - name: Prépare un .env d'exemple
run: cp .env.example .env 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 - name: Valide la stack de développement
run: docker compose config --quiet run: docker compose config --quiet
@@ -91,6 +101,29 @@ jobs:
- name: Valide les tableaux de bord Grafana - name: Valide les tableaux de bord Grafana
run: for tableau in monitoring/grafana/dashboards/*.json; do jq empty "$tableau"; done 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: workflows:
name: Analyse des workflows name: Analyse des workflows
if: inputs.workflows if: inputs.workflows
+13 -3
View File
@@ -41,11 +41,19 @@ SUPERVISION := $(findstring monitoring,$(COMPOSE_PROFILES) $(call env-val,COMPOS
SERVICES_SUPERVISION := prometheus alertmanager grafana postgres-exporter node-exporter cadvisor SERVICES_SUPERVISION := prometheus alertmanager grafana postgres-exporter node-exporter cadvisor
GRAFANA_PORT := $(or $(strip $(call env-val,GRAFANA_PORT)),3001) GRAFANA_PORT := $(or $(strip $(call env-val,GRAFANA_PORT)),3001)
PROMETHEUS_PORT := $(or $(strip $(call env-val,PROMETHEUS_PORT)),9090) 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 . \ 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; }; \ || { echo "$$cle manquant dans .env, requis par la supervision (cf. .env.example)"; exit 1; }; \
done done
MONITORING := docker compose --profile monitoring 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 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 # 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 & \ $(MAKE) --no-print-directory dev-frontend & \
wait wait
services-up: ## Démarre les services conteneurisés dont `make dev` dépend (base, Mailpit, Airflow) services-up: ## Démarre les services conteneurisés dont `make dev` dépend (base, Mailpit, Garage, Airflow)
docker compose up -d db mailpit @$(garage-garde)
docker compose up -d db mailpit garage
@$(MAKE) --no-print-directory db-wait @$(MAKE) --no-print-directory db-wait
@$(MAKE) --no-print-directory db-ensure-airflow @$(MAKE) --no-print-directory db-ensure-airflow
docker compose up -d airflow-init airflow-apiserver airflow-scheduler airflow-dag-processor 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 \ @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; } || { 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) @$(if $(SUPERVISION),$(supervision-garde),true)
@$(garage-garde)
$(COMPOSE_PROD) up -d --build $(COMPOSE_PROD) up -d --build
$(COMPOSE_PROD) exec -T backend alembic upgrade head $(COMPOSE_PROD) exec -T backend alembic upgrade head
@$(if $(SUPERVISION),$(MAKE) --no-print-directory db-ensure-supervision,true) @$(if $(SUPERVISION),$(MAKE) --no-print-directory db-ensure-supervision,true)
+6 -1
View File
@@ -28,6 +28,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place | | Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
| CI/CD | GitHub Actions | `.github/workflows` | En place | | CI/CD | GitHub Actions | `.github/workflows` | En place |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | En place, profil Compose | | 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 | | Tests e2e et de charge | Playwright, k6 | `tests` | En place |
| ML | LightGBM, MLflow | `ml` | 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 │ ├── include/ Requetes SQL et ressources des DAGs
│ └── tests/ Tests d'integrite des DAGs │ └── tests/ Tests d'integrite des DAGs
├── infra/ ├── infra/
│ ├── garage/ Stockage objet S3 : configuration sans secret
│ ├── proxy/ Reverse proxy Nginx : terminaison TLS et routage │ ├── proxy/ Reverse proxy Nginx : terminaison TLS et routage
│ └── terraform/ │ └── terraform/
│ ├── modules/ Modules reutilisables │ ├── modules/ Modules reutilisables
@@ -68,6 +70,7 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
│ └── alertmanager/ Routage des alertes │ └── alertmanager/ Routage des alertes
├── tests/ ├── tests/
│ ├── e2e/ Parcours Playwright contre la stack │ ├── e2e/ Parcours Playwright contre la stack
│ ├── garage/ Tests de fumée S3 joués par la CI contre Garage
│ └── load/ Scenarios de charge k6 │ └── load/ Scenarios de charge k6
├── docs/ ADR et vues d'architecture ├── docs/ ADR et vues d'architecture
└── scripts/ Outillage local └── 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`, 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_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_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`, Les cibles d'origine restent disponibles pour ne demarrer qu'une partie : `make db-up`,
`make airflow-up`, `make dev-backend`, `make dev-frontend`. `make airflow-up`, `make dev-backend`, `make dev-frontend`.
+19 -3
View File
@@ -76,9 +76,25 @@ class Settings(BaseSettings):
expose_api_docs: bool | None = None expose_api_docs: bool | None = None
metrics_token: SecretStr | None = None metrics_token: SecretStr | None = None
# Compose passe `APP_METRICS_TOKEN` vide quand aucun jeton n'est posé : vide vaut absent, sinon s3_endpoint_url: str | None = None
# `/metrics` exigerait un `Bearer` sans valeur et plus rien ne pourrait le scruter. s3_region: str = "garage"
@field_validator("metrics_token", mode="before") 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 @classmethod
def _jeton_vide_vaut_absent(cls, valeur: object) -> object: def _jeton_vide_vaut_absent(cls, valeur: object) -> object:
return None if valeur == "" else valeur return None if valeur == "" else valeur
+349
View File
@@ -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()
+2
View File
@@ -19,6 +19,7 @@ dependencies = [
"aiosmtplib>=5.1.3", "aiosmtplib>=5.1.3",
"httpx>=0.28.1", "httpx>=0.28.1",
"pandas>=3.0.5", "pandas>=3.0.5",
"boto3>=1.43.101",
] ]
[dependency-groups] [dependency-groups]
@@ -29,6 +30,7 @@ dev = [
"pytest-asyncio>=1.4.0", "pytest-asyncio>=1.4.0",
"pytest-cov>=7.1.0", "pytest-cov>=7.1.0",
"pandas-stubs>=3.0.5.260914", "pandas-stubs>=3.0.5.260914",
"types-boto3[s3]>=1.43.101",
] ]
[build-system] [build-system]
@@ -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([])
+107
View File
@@ -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" }, { 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]] [[package]]
name = "certifi" name = "certifi"
version = "2026.7.22" version = "2026.7.22"
@@ -325,6 +362,7 @@ dependencies = [
{ name = "anyio" }, { name = "anyio" },
{ name = "argon2-cffi" }, { name = "argon2-cffi" },
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "boto3" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "pandas" }, { name = "pandas" },
@@ -345,6 +383,7 @@ dev = [
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "pytest-cov" }, { name = "pytest-cov" },
{ name = "ruff" }, { name = "ruff" },
{ name = "types-boto3", extra = ["s3"] },
] ]
[package.metadata] [package.metadata]
@@ -354,6 +393,7 @@ requires-dist = [
{ name = "anyio", specifier = ">=4.0" }, { name = "anyio", specifier = ">=4.0" },
{ name = "argon2-cffi", specifier = ">=23.1" }, { name = "argon2-cffi", specifier = ">=23.1" },
{ name = "asyncpg", specifier = ">=0.31.0" }, { name = "asyncpg", specifier = ">=0.31.0" },
{ name = "boto3", specifier = ">=1.43.101" },
{ name = "fastapi", specifier = ">=0.141.1" }, { name = "fastapi", specifier = ">=0.141.1" },
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "pandas", specifier = ">=3.0.5" }, { name = "pandas", specifier = ">=3.0.5" },
@@ -374,6 +414,7 @@ dev = [
{ name = "pytest-asyncio", specifier = ">=1.4.0" }, { name = "pytest-asyncio", specifier = ">=1.4.0" },
{ name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-cov", specifier = ">=7.1.0" },
{ name = "ruff", specifier = ">=0.16.7" }, { name = "ruff", specifier = ">=0.16.7" },
{ name = "types-boto3", extras = ["s3"], specifier = ">=1.43.101" },
] ]
[[package]] [[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" }, { 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]] [[package]]
name = "librt" name = "librt"
version = "0.15.0" 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" }, { 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]] [[package]]
name = "six" name = "six"
version = "1.17.0" 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" }, { 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]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.16.0" 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" }, { 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]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.53.0" version = "0.53.0"
+45
View File
@@ -82,6 +82,37 @@ services:
- "${MAILPIT_UI_PORT:-8025}:8025" - "${MAILPIT_UI_PORT:-8025}:8025"
restart: unless-stopped 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: backend:
build: ./apps/backend build: ./apps/backend
depends_on: depends_on:
@@ -178,6 +209,15 @@ services:
APP_MOCK_API_USERNAME: ${APP_MOCK_API_USERNAME:-} APP_MOCK_API_USERNAME: ${APP_MOCK_API_USERNAME:-}
APP_MOCK_API_PASSWORD: ${APP_MOCK_API_PASSWORD:-} APP_MOCK_API_PASSWORD: ${APP_MOCK_API_PASSWORD:-}
APP_MOCK_API_TIMEOUT_SECONDS: ${APP_MOCK_API_TIMEOUT_SECONDS:-10} 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: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -209,6 +249,7 @@ services:
- prometheus_data:/prometheus - prometheus_data:/prometheus
secrets: secrets:
- metrics_token - metrics_token
- garage_metrics_token
ports: ports:
- "127.0.0.1:${PROMETHEUS_PORT:-9090}:9090" - "127.0.0.1:${PROMETHEUS_PORT:-9090}:9090"
mem_limit: 512m mem_limit: 512m
@@ -318,6 +359,8 @@ services:
volumes: volumes:
pgdata: pgdata:
garage_meta:
garage_data:
airflow_logs: airflow_logs:
airflow_ml_state: airflow_ml_state:
prometheus_data: prometheus_data:
@@ -328,3 +371,5 @@ volumes:
secrets: secrets:
metrics_token: metrics_token:
environment: APP_METRICS_TOKEN environment: APP_METRICS_TOKEN
garage_metrics_token:
environment: GARAGE_METRICS_TOKEN
@@ -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/<annee>/reading_<debut>_<fin>.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.
@@ -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.
+8
View File
@@ -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`) | | 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) | | 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 | | 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) | | 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). | | 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) | | 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 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 [ADR 0002](../adr/0002-authentification-jwt-et-refresh-opaque.md) et
[0003](../adr/0003-autorisation-rbac-a-trois-roles.md). [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 - **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. 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 - **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 | | [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é | | [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 | | [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 |
+33 -1
View File
@@ -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 | | `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` | | `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) | | `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) | | `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` | | `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 | | `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 | | `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. 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 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 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 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 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. 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 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` | | 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` | | 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` | | 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` | | 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` | | 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 : 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. 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 ## Cible à terme, k3s
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster, depuis la Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster, depuis la
+10
View File
@@ -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_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_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_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 : 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 secret de moins de 32 caractères ou laissé à sa valeur d'exemple, `debug` en `staging` ou
+35 -9
View File
@@ -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'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'`, 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` 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 (issue #116). L'orchestration de l'ingestion, les agrégats continus et la compression restent
rétention restent des cibles. 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 ## 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 | | 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/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 | | `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()` 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. Les traitements sont actuellement exécutables directement depuis le backend.
L'orchestration avec Apache Airflow reste une cible, tout comme les agrégats continus, L'orchestration avec Apache Airflow reste une cible, tout comme les agrégats continus et la
la compression et les politiques de rétention. 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 ```mermaid
flowchart LR flowchart LR
@@ -91,7 +93,8 @@ flowchart LR
hy -.-> agg[("Agrégat continu")] hy -.-> agg[("Agrégat continu")]
hy -.-> comp["Compression"] 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 -.-> backend["API FastAPI"]
agg -.-> graf["Grafana"] 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 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. 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/<année>/reading_<début>_<fin>.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 ## Tables d'authentification
Statut : `Fait`. 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. devient ininterprétable dès le premier changement d'heure.
- **La colonne de partitionnement entre dans la clé primaire.** Dans `reading` elle s'appelle - **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`. `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 : - **Les politiques de compression** vont dans `db/migrations/`, pas dans Alembic : elles ne
elles ne découlent pas du schéma applicatif. 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 - **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. `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. - **Quelle granularité** conserver à long terme à l'ingestion : seconde, minute ou quart d'heure.
- **Quels agrégats continus** créer et sur quelles fenêtres. - **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. - **Multi-tenant ou non** : un site appartient-il à un client et faut-il cloisonner les lectures.
## Modélisation détaillée des données ## Modélisation détaillée des données
+4
View File
@@ -21,6 +21,8 @@ flowchart LR
db[("db<br/>TimescaleDB")] db[("db<br/>TimescaleDB")]
mail["mailpit"] mail["mailpit"]
garage["garage<br/>:3903/metrics"]
subgraph sup["Profil monitoring"] subgraph sup["Profil monitoring"]
prom["prometheus<br/>15 s, 15 jours"] prom["prometheus<br/>15 s, 15 jours"]
am["alertmanager"] am["alertmanager"]
@@ -35,6 +37,7 @@ flowchart LR
prom -->|"Bearer APP_METRICS_TOKEN"| api prom -->|"Bearer APP_METRICS_TOKEN"| api
prom --> pge & node & cad prom --> pge & node & cad
prom -->|"Bearer GARAGE_METRICS_TOKEN"| garage
pge -->|"rôle supervision"| db pge -->|"rôle supervision"| db
node -.->|"/proc, /sys"| hote node -.->|"/proc, /sys"| hote
cad -.->|"cgroups"| 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 » | | node-exporter | Processeur, mémoire disponible, espace disque de `/` | Tableau « Infrastructure » |
| cAdvisor | Mémoire (`working_set`) et processeur par conteneur | 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 » | | 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 : Deux choix de l'instrumentation se lisent dans ces courbes :
+45
View File
@@ -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,
)
+20
View File
@@ -18,6 +18,7 @@ DAG_IDS = [
"historical_import", "historical_import",
"mock_api_import", "mock_api_import",
"derive", "derive",
"retention",
] ]
TACHES = [ TACHES = [
("ml_train", "train"), ("ml_train", "train"),
@@ -27,6 +28,7 @@ TACHES = [
("historical_import", "import_historical"), ("historical_import", "import_historical"),
("mock_api_import", "import_mock_api"), ("mock_api_import", "import_mock_api"),
("derive", "derive"), ("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 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) @pytest.mark.parametrize(("dag_id", "task_id"), TACHES)
def test_tasks_never_resync_the_baked_environment( def test_tasks_never_resync_the_baked_environment(
dagbag: DagBag, dag_id: str, task_id: str dagbag: DagBag, dag_id: str, task_id: str
+5
View File
@@ -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`) 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`. 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 Retirer le runner se fait a la main, depuis les parametres du depot : `terraform destroy` ne le
desinscrit pas. desinscrit pas.
+25
View File
@@ -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`.
+22
View File
@@ -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
+40 -1
View File
@@ -6,12 +6,15 @@
# Contrainte : pas de provisioner `destroy` sur le runner. Il imposerait une connexion ne lisant # 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 # 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. # 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. # Ref : ADR 0009 et 0017 pour les trois environnements, `scripts/provision-host.sh` pour leur contenu.
locals { locals {
sudo = var.ssh_user == "root" ? "" : "sudo " sudo = var.ssh_user == "root" ? "" : "sudo "
en_tant_que = "${var.ssh_user == "root" ? "" : "sudo "}runuser -u ${var.proprietaire} --" en_tant_que = "${var.ssh_user == "root" ? "" : "sudo "}runuser -u ${var.proprietaire} --"
provisionneur = "${path.root}/../../../../scripts/provision-host.sh" 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" 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 # 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. # 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 # `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 # par environnement, son `.env` et son certificat. Il est rejouable : un `.env` existant n'est
# jamais reecrit, un certificat present jamais regenere. # jamais reecrit, un certificat present jamais regenere.
resource "null_resource" "environnements" { resource "null_resource" "environnements" {
depends_on = [null_resource.docker_engine] depends_on = [null_resource.docker_engine, null_resource.coffre]
triggers = { triggers = {
script = filesha256(local.provisionneur) script = filesha256(local.provisionneur)
@@ -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 # 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. # seulement si deux runners doivent tourner sur la meme machine, leurs noms devant differer.
# runner_nom = "eni-g3-bis" # 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"
@@ -94,3 +94,9 @@ variable "runner_dossier" {
description = "Dossier d'installation du runner sur la machine." description = "Dossier d'installation du runner sur la machine."
default = "/opt/actions-runner" 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 = ""
}
+3 -1
View File
@@ -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 | | `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 | | `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 | | `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, Les interfaces n'écoutent que sur `127.0.0.1`. Depuis un poste, on passe par un tunnel SSH,
comme pour Airflow : 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 - **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. 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. s'il en manque un. `scripts/provision-host.sh` les génère pour un nouvel environnement.
| Variable | Rôle | | 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é | | `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 | | `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`) | | `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`) : 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` Prometheus la joint en `backend:8000`, sur le réseau du projet. Une API lancée par `make dev`
+7
View File
@@ -41,3 +41,10 @@ scrape_configs:
- job_name: cadvisor - job_name: cadvisor
static_configs: static_configs:
- targets: ["cadvisor:8080"] - targets: ["cadvisor:8080"]
- job_name: garage
authorization:
type: Bearer
credentials_file: /run/secrets/garage_metrics_token
static_configs:
- targets: ["garage:3903"]
+10
View File
@@ -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 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`, crée deux comptes) : `BASE_URL=http://localhost:8000 ../../scripts/dast-token.sh`. Nécessite `curl`,
`jq` et `openssl`. `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.
+234
View File
@@ -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" <<CONF
# Écrit par scripts/coffre-luks.sh (ADR 0020) : coffre absent au démarrage, Docker ne démarre
# pas, plutôt que de recréer des volumes en clair sous $VOLUMES.
[Unit]
RequiresMountsFor=$VOLUMES
CONF
echo "drop-in : $DROPIN écrit"
fi
systemctl daemon-reload
}
empreinte() {
local dossier="$1"
find "$dossier" -type f -printf '%s\n' | awk '{ n++; s += $1 } END { printf "%d fichiers, %d octets", n, s }'
}
libre() {
local dossier="$1"
df -B1 --output=avail "$dossier" | tail -1 | tr -d ' '
}
expliquer_migration() {
cat <<FIN
Le coffre est prêt, mais $VOLUMES n'y est pas encore : rien n'a changé pour Docker, un redémarrage
est sans risque. La migration arrête Docker, donc les trois environnements, le temps de copier les
volumes (une à trois minutes), puis le redémarre.
Volumes à copier : $(du -sh "$VOLUMES" | cut -f1), libre sur le coffre : $(df -h --output=avail "$COFFRE_MONTAGE" | tail -1 | tr -d ' ')
Libre sur le disque qui porte l'image, la copie occupant deux fois la place jusqu'à la
suppression de $VOLUMES.avant-coffre : $(df -h --output=avail "$(dirname "$COFFRE_IMAGE")" | tail -1 | tr -d ' ')
Pour la jouer : COFFRE_MIGRER=1 bash $0
FIN
exit 1
}
migrer() {
local origine copie
mkdir -p "$VOLUMES"
if [[ -z "$(ls -A "$VOLUMES")" ]]; then
systemctl stop docker.socket docker.service
poser_bind
mount "$VOLUMES"
systemctl start docker.socket docker.service
echo "aucun volume à migrer : bind monté, Docker redémarré"
return 0
fi
[[ "$COFFRE_MIGRER" == 1 ]] || expliquer_migration
if docker info 2>/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 <<FIN
À faire par l'opérateur :
1. Sauvegarder la clé hors de la VM, sans elle le coffre est perdu :
scp root@$(hostname -I | awk '{print $1}'):$COFFRE_CLE <emplacement sûr, hors de la machine>
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 <<FIN
3. Seulement après ce redémarrage validé, supprimer la copie en clair (non effaçable physiquement) :
rm -rf $VOLUMES.avant-coffre
FIN
fi
}
verifier_prerequis
if deja_sur_le_coffre; then
echo "$VOLUMES est déjà servi par le coffre $MAPPER, rien à faire"
afficher_etat
exit 0
fi
poser_cle
poser_image
poser_persistance
migrer
afficher_etat
+13 -5
View File
@@ -26,12 +26,19 @@ secret() { openssl rand -base64 48 | tr -d '/+=\n' | cut -c1-48; }
court() { secret | cut -c1-20; } court() { secret | cut -c1-20; }
# Clé Fernet : 32 octets en base64 urlsafe, padding compris. # Clé Fernet : 32 octets en base64 urlsafe, padding compris.
fernet() { openssl rand -base64 32 | tr '+/' '-_'; } fernet() { openssl rand -base64 32 | tr '+/' '-_'; }
# Garage : rpc_secret et secret de clé S3 en 32 octets hexadécimaux, identifiant de clé en GK + hex,
# clé SSE-C des archives en 32 octets base64 (ADR 0019, 0020).
hex32() { openssl rand -hex 32; }
cle_acces() { echo "GK$(openssl rand -hex 12)"; }
cle_sse() { openssl rand -base64 32; }
declare -A GENERATEURS=( declare -A GENERATEURS=(
[POSTGRES_PASSWORD]=secret [APP_SECRET_KEY]=secret [AIRFLOW_FERNET_KEY]=fernet [POSTGRES_PASSWORD]=secret [APP_SECRET_KEY]=secret [AIRFLOW_FERNET_KEY]=fernet
[AIRFLOW_API_SECRET_KEY]=secret [AIRFLOW_JWT_SECRET]=secret [AIRFLOW_ADMIN_PASSWORD]=court [AIRFLOW_API_SECRET_KEY]=secret [AIRFLOW_JWT_SECRET]=secret [AIRFLOW_ADMIN_PASSWORD]=court
[AIRFLOW_APP_SECRET_KEY]=secret [APP_METRICS_TOKEN]=secret [GRAFANA_ADMIN_PASSWORD]=court [AIRFLOW_APP_SECRET_KEY]=secret [APP_METRICS_TOKEN]=secret [GRAFANA_ADMIN_PASSWORD]=court
[SUPERVISION_DB_PASSWORD]=secret [SUPERVISION_DB_PASSWORD]=secret
[GARAGE_RPC_SECRET]=hex32 [GARAGE_ADMIN_TOKEN]=secret [GARAGE_METRICS_TOKEN]=secret
[GARAGE_ACCESS_KEY]=cle_acces [GARAGE_SECRET_KEY]=hex32 [GARAGE_SSE_KEY]=cle_sse
) )
verifier_outils() { verifier_outils() {
@@ -67,7 +74,7 @@ preparer() {
local env="$1" branche="$2" hote="$3" local env="$1" branche="$2" hote="$3"
local port_https="$4" port_http="$5" port_front="$6" port_pg="$7" port_mailpit="$8" local port_https="$4" port_http="$5" port_front="$6" port_pg="$7" port_mailpit="$8"
local port_airflow="$9" profils="${10}" port_grafana="${11}" port_prometheus="${12}" local port_airflow="$9" profils="${10}" port_grafana="${11}" port_prometheus="${12}"
local port_alertmanager="${13}" local port_alertmanager="${13}" port_garage_s3="${14}" port_garage_admin="${15}"
local dossier="$RACINE/$env" local dossier="$RACINE/$env"
local fichier="$dossier/.env" brouillon="$dossier/.env.brouillon" cle oubliees ajoutees="" local fichier="$dossier/.env" brouillon="$dossier/.env.brouillon" cle oubliees ajoutees=""
@@ -103,6 +110,7 @@ preparer() {
[POSTGRES_PORT]="$port_pg" [MAILPIT_UI_PORT]="$port_mailpit" [AIRFLOW_PORT]="$port_airflow" [POSTGRES_PORT]="$port_pg" [MAILPIT_UI_PORT]="$port_mailpit" [AIRFLOW_PORT]="$port_airflow"
[COMPOSE_PROFILES]="$profils" [GRAFANA_PORT]="$port_grafana" [COMPOSE_PROFILES]="$profils" [GRAFANA_PORT]="$port_grafana"
[PROMETHEUS_PORT]="$port_prometheus" [ALERTMANAGER_PORT]="$port_alertmanager" [PROMETHEUS_PORT]="$port_prometheus" [ALERTMANAGER_PORT]="$port_alertmanager"
[GARAGE_S3_PORT]="$port_garage_s3" [GARAGE_ADMIN_PORT]="$port_garage_admin"
) )
for cle in "${!adressage[@]}"; do for cle in "${!adressage[@]}"; do
poser "$brouillon" "$cle" "${adressage[$cle]}" poser "$brouillon" "$cle" "${adressage[$cle]}"
@@ -200,10 +208,10 @@ publier_dns
# Supervision active en prod seulement (ADR 0016). La prod vit sur `prod.` et non à la racine : # Supervision active en prod seulement (ADR 0016). La prod vit sur `prod.` et non à la racine :
# dynv6 ne sert pas de façon fiable un TXT `_acme-challenge` à la racine de la zone (ADR 0018). # dynv6 ne sert pas de façon fiable un TXT `_acme-challenge` à la racine de la zone (ADR 0018).
# env branche hôte https http front pg mailpit airflow profils grafana prometheus alertmanager # env branche hôte https http front pg mailpit airflow profils grafana prometheus alertmanager garage-s3 garage-admin
preparer prod main "prod.$DOMAINE" 127.0.0.1:10443 127.0.0.1:10080 127.0.0.1:10444 5433 8025 8080 monitoring 3001 9090 9093 preparer prod main "prod.$DOMAINE" 127.0.0.1:10443 127.0.0.1:10080 127.0.0.1:10444 5433 8025 8080 monitoring 3001 9090 9093 3900 3903
preparer rec dev "rec.$DOMAINE" 127.0.0.1:8443 127.0.0.1:8081 127.0.0.1:8444 5434 8026 8082 "" 3002 9091 9094 preparer rec dev "rec.$DOMAINE" 127.0.0.1:8443 127.0.0.1:8081 127.0.0.1:8444 5434 8026 8082 "" 3002 9091 9094 3910 3913
preparer dev dev "dev.$DOMAINE" 127.0.0.1:9443 127.0.0.1:8083 127.0.0.1:9444 5435 8027 8084 "" 3003 9092 9095 preparer dev dev "dev.$DOMAINE" 127.0.0.1:9443 127.0.0.1:8083 127.0.0.1:9444 5435 8027 8084 "" 3003 9092 9095 3920 3923
planifier_renouvellement planifier_renouvellement
if [[ -n "$PROPRIETAIRE" && "$(id -u)" -eq 0 ]]; then if [[ -n "$PROPRIETAIRE" && "$(id -u)" -eq 0 ]]; then
+70
View File
@@ -0,0 +1,70 @@
# Pourquoi : la CI (job compose d'infra.yml) démarre le vrai conteneur Garage et joue ces tests avec
# boto3, sans venv projet. Ils prouvent que le S3 accepte des écritures, les rend intactes, supprime
# vraiment, et que SSE-C refuse une lecture sans clé (ADR 0019, 0020) - test_smoke.py
import base64
import os
import uuid
import boto3
import pytest
from botocore.exceptions import ClientError
BUCKET = os.environ.get("GARAGE_BUCKET", "enervision-archives")
ENDPOINT_URL = os.environ.get(
"GARAGE_ENDPOINT_URL", f"http://127.0.0.1:{os.environ.get('GARAGE_S3_PORT', '3900')}"
)
SSE_KEY = base64.b64decode(os.environ["GARAGE_SSE_KEY"])
SSE = {"SSECustomerAlgorithm": "AES256", "SSECustomerKey": SSE_KEY}
@pytest.fixture
def client():
return boto3.client(
"s3",
endpoint_url=ENDPOINT_URL,
aws_access_key_id=os.environ["GARAGE_ACCESS_KEY"],
aws_secret_access_key=os.environ["GARAGE_SECRET_KEY"],
region_name="garage",
)
def test_default_bucket_exists(client):
noms = [bucket["Name"] for bucket in client.list_buckets()["Buckets"]]
assert BUCKET in noms
def test_upload_and_download_roundtrip(client):
cle = f"fumee/{uuid.uuid4()}.txt"
contenu = b"contenu de test EnerVision"
client.put_object(Bucket=BUCKET, Key=cle, Body=contenu)
recupere = client.get_object(Bucket=BUCKET, Key=cle)["Body"].read()
client.delete_object(Bucket=BUCKET, Key=cle)
assert recupere == contenu
def test_deleted_object_is_really_gone(client):
cle = f"fumee/suppression-{uuid.uuid4()}.txt"
client.put_object(Bucket=BUCKET, Key=cle, Body=b"a supprimer")
client.delete_object(Bucket=BUCKET, Key=cle)
with pytest.raises(client.exceptions.NoSuchKey):
client.get_object(Bucket=BUCKET, Key=cle)
def test_sse_c_object_is_unreadable_without_the_key(client):
cle = f"fumee/chiffre-{uuid.uuid4()}.txt"
contenu = b"archive chiffree"
client.put_object(Bucket=BUCKET, Key=cle, Body=contenu, **SSE)
with pytest.raises(ClientError) as erreur:
client.get_object(Bucket=BUCKET, Key=cle)
dechiffre = client.get_object(Bucket=BUCKET, Key=cle, **SSE)["Body"].read()
client.delete_object(Bucket=BUCKET, Key=cle)
assert erreur.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
assert dechiffre == contenu