Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f011fce84e | ||
|
|
7730f184e7 | ||
|
|
fa815f49b6 | ||
|
|
0462dd01ba | ||
|
|
e53c7e441c | ||
|
|
0a7a802b38 | ||
|
|
e0089537d0 | ||
|
|
6b41ec900c | ||
|
|
49175d8ff7 | ||
|
|
da7fc52299 | ||
|
|
6c09beeb3c | ||
|
|
14eed08ff5 | ||
|
|
1232646f68 | ||
|
|
cbbfaf4910 | ||
|
|
b78322bd61 | ||
|
|
101ebd404f | ||
|
|
b00c39277b | ||
|
|
c2f360c591 | ||
|
|
933f0a3360 |
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
# Contrainte : la réponse de l'API Mock est une entrée hostile, pas une source de confiance.
|
# Contrainte : la réponse de l'API Mock est une entrée hostile, pas une source de confiance.
|
||||||
# Voir OWASP API10 dans docs/architecture/owasp-traceabilite.md. Rien de ce qu'elle renvoie
|
# Voir OWASP API10 dans docs/architecture/owasp-traceabilite.md. Rien de ce qu'elle renvoie
|
||||||
# n'atteint la base sans passer par build_site_row() ou build_reading_row() : seuls les champs
|
# n'atteint la base sans passer par build_site_row() ou build_reading_row() : seuls les champs
|
||||||
# attendus sont recopiés, les grandeurs physiques sont bornées par PHYSICAL_BOUNDS et la taille
|
# attendus sont recopiés, les grandeurs physiques sont bornées par PHYSICAL_BOUNDS, la taille des
|
||||||
# des tableaux est plafonnée par MAX_SITES et par --limit. Une valeur hors bornes devient NULL
|
# tableaux est plafonnée par MAX_SITES et par limit_for_window() (dérivé de la fenêtre, jamais
|
||||||
# et laisse sa trace dans null_reasons plutôt que de lever : le mock émet des anomalies par
|
# fourni par l'appelant), et les lectures dont le timestamp déborde de la fenêtre demandée sont
|
||||||
# construction, et raw_data conserve de toute façon la réponse d'origine intacte.
|
# écartées (fetch_readings). Une valeur hors bornes devient NULL et laisse sa trace dans
|
||||||
|
# null_reasons plutôt que de lever : le mock émet des anomalies par construction, et raw_data
|
||||||
|
# conserve de toute façon la réponse d'origine intacte.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -19,6 +21,7 @@ from sqlalchemy import text
|
|||||||
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
from app.etl.historical_import import SOURCE_NAME as SOURCE_CSV
|
||||||
|
|
||||||
SOURCE_HISTORY = "api_history"
|
SOURCE_HISTORY = "api_history"
|
||||||
|
|
||||||
@@ -181,6 +184,19 @@ async def upsert_sites(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamp_in_window(reading: dict[str, Any], start_time: datetime, end_time: datetime) -> bool:
|
||||||
|
valeur = reading.get("timestamp")
|
||||||
|
if not isinstance(valeur, str):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
instant = parse_datetime(valeur)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return start_time <= instant < end_time
|
||||||
|
|
||||||
|
|
||||||
async def fetch_readings(
|
async def fetch_readings(
|
||||||
client: httpx.AsyncClient,
|
client: httpx.AsyncClient,
|
||||||
site_id: str,
|
site_id: str,
|
||||||
@@ -208,7 +224,22 @@ async def fetch_readings(
|
|||||||
if len(payload) > limit:
|
if len(payload) > limit:
|
||||||
raise ValueError(f"La réponse /api/v1/readings dépasse la limite demandée de {limit}.")
|
raise ValueError(f"La réponse /api/v1/readings dépasse la limite demandée de {limit}.")
|
||||||
|
|
||||||
return payload
|
# Le garde-fou `refuse_if_overlaps_historical_dataset` ne vérifie que la fenêtre demandée :
|
||||||
|
# une réponse (bug du mock, ou hostile) dont les `timestamp` débordent de
|
||||||
|
# `[start_time, end_time)` contournerait ce contrôle et écrirait exactement le doublon
|
||||||
|
# inter-source qu'il doit empêcher. Écarter ces lectures ici rend le contrôle par fenêtre
|
||||||
|
# suffisant.
|
||||||
|
dans_la_fenetre = [
|
||||||
|
lecture
|
||||||
|
for lecture in payload
|
||||||
|
if isinstance(lecture, dict) and _timestamp_in_window(lecture, start_time, end_time)
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(dans_la_fenetre) != len(payload):
|
||||||
|
ecartees = len(payload) - len(dans_la_fenetre)
|
||||||
|
print(f"{site_id}: {ecartees} lecture(s) hors fenêtre écartée(s).")
|
||||||
|
|
||||||
|
return dans_la_fenetre
|
||||||
|
|
||||||
|
|
||||||
def build_reading_row(
|
def build_reading_row(
|
||||||
@@ -244,6 +275,36 @@ def build_reading_row(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# `uq_reading_source` autorise deux lignes au même (site_id, timestamp) dès que `source` diffère :
|
||||||
|
# sans ce garde-fou, importer une fenêtre déjà couverte par le dataset historique (source='csv')
|
||||||
|
# dupliquerait silencieusement chaque point plutôt que de lever une erreur. Ce garde-fou protège
|
||||||
|
# l'ingestion ; il ne dit rien de la lecture (`GET /readings` renvoie les deux lignes en cas de
|
||||||
|
# doublon malgré tout, cf. la section réconciliation de 40-data.md).
|
||||||
|
OVERLAP_CHECK = text(
|
||||||
|
"SELECT count(*) FROM reading WHERE source = :source_csv "
|
||||||
|
"AND timestamp >= :start_time AND timestamp < :end_time"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def refuse_if_overlaps_historical_dataset(
|
||||||
|
connection: AsyncConnection,
|
||||||
|
start_time: datetime,
|
||||||
|
end_time: datetime,
|
||||||
|
) -> None:
|
||||||
|
resultat = await connection.execute(
|
||||||
|
OVERLAP_CHECK,
|
||||||
|
{"source_csv": SOURCE_CSV, "start_time": start_time, "end_time": end_time},
|
||||||
|
)
|
||||||
|
nombre = resultat.scalar_one()
|
||||||
|
|
||||||
|
if nombre > 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"La fenêtre [{start_time.isoformat()}, {end_time.isoformat()}) recouvre "
|
||||||
|
f"{nombre} lecture(s) déjà importée(s) du dataset historique (source='{SOURCE_CSV}') : "
|
||||||
|
"import refusé pour éviter un doublon inter-source."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Le conflit vise l'index unique uq_reading_source plutôt que la table entière : sans cible
|
# Le conflit vise l'index unique uq_reading_source plutôt que la table entière : sans cible
|
||||||
# nommée, DO NOTHING avalerait aussi une violation de clé primaire.
|
# nommée, DO NOTHING avalerait aussi une violation de clé primaire.
|
||||||
READING_INSERT = text(
|
READING_INSERT = text(
|
||||||
@@ -302,41 +363,57 @@ def build_reading_batch(
|
|||||||
return [build_reading_row(reading) for reading in readings]
|
return [build_reading_row(reading) for reading in readings]
|
||||||
|
|
||||||
|
|
||||||
|
def limit_for_window(start_time: datetime, end_time: datetime) -> int:
|
||||||
|
"""Nombre de lectures à demander pour que l'API Mock en rende une par heure, alignée.
|
||||||
|
|
||||||
|
L'API ne renvoie pas un flux à un rythme naturel : elle répartit exactement `limit` lectures,
|
||||||
|
espacées uniformément, sur toute la fenêtre `[start_time, end_time)` demandée, la première
|
||||||
|
au tout début de la fenêtre (vérifié empiriquement). Deux façons d'obtenir une lecture
|
||||||
|
alignée sur l'heure :
|
||||||
|
|
||||||
|
- une fenêtre d'exactement N heures (`start_time` sur l'heure) donne, avec `limit=N`, N
|
||||||
|
lectures espacées d'1h pile, la première à `start_time` : c'est le chemin du backfill
|
||||||
|
manuel (plusieurs jours d'historique en un seul appel).
|
||||||
|
- une fenêtre plus courte qu'une heure, ou qui n'est pas un multiple entier d'heure, ne peut
|
||||||
|
espacer plusieurs lectures d'1h pile (l'espacement de l'API vaut toujours
|
||||||
|
`durée / limit`) : seule `limit=1` reste alignée, la lecture unique atterrissant à
|
||||||
|
`start_time`. C'est le chemin du DAG horaire, dont la fenêtre part de l'heure pile qui
|
||||||
|
précède son déclenchement jusqu'à l'instant du déclenchement lui-même (`:45`), donc plus
|
||||||
|
courte qu'une heure.
|
||||||
|
|
||||||
|
Dans les deux cas, `start_time` doit tomber pile sur l'heure : c'est elle qui ancre
|
||||||
|
l'alignement, jamais `end_time`. Un `limit` plus grand que celui rendu ici fabriquerait des
|
||||||
|
lectures infra-horaires, incompatibles avec les lags positionnels de `build_features`.
|
||||||
|
"""
|
||||||
|
if start_time.minute or start_time.second or start_time.microsecond:
|
||||||
|
raise ValueError(
|
||||||
|
f"La fenêtre doit démarrer pile sur l'heure : {start_time.isoformat()} ne l'est pas."
|
||||||
|
)
|
||||||
|
|
||||||
|
duree = end_time - start_time
|
||||||
|
heures, reste = divmod(duree.total_seconds(), 3600)
|
||||||
|
|
||||||
|
# Fenêtre plus courte qu'une heure, ou pas un multiple entier : aucun `limit` supérieur à 1
|
||||||
|
# n'espacerait ses lectures d'1h pile (l'espacement vaut toujours durée / limit). Seule la
|
||||||
|
# lecture unique, ancrée sur `start_time`, reste alignée.
|
||||||
|
limit = int(heures) if reste == 0 and heures >= 1 else 1
|
||||||
|
|
||||||
|
if limit > MAX_LIMIT:
|
||||||
|
raise ValueError(
|
||||||
|
f"La fenêtre demandée couvre {limit}h, au-delà du plafond de {MAX_LIMIT} "
|
||||||
|
"lectures accepté par l'API Mock."
|
||||||
|
)
|
||||||
|
|
||||||
|
return limit
|
||||||
|
|
||||||
|
|
||||||
async def import_mock_api_history(
|
async def import_mock_api_history(
|
||||||
start_time: datetime,
|
start_time: datetime,
|
||||||
end_time: datetime,
|
end_time: datetime,
|
||||||
limit: int,
|
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
limit = limit_for_window(start_time, end_time)
|
||||||
async with create_mock_api_client() as client:
|
|
||||||
sites = await fetch_sites(client)
|
|
||||||
|
|
||||||
print(f"Sites récupérés : {len(sites)}")
|
|
||||||
|
|
||||||
all_readings: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
for site in sites:
|
|
||||||
site_id = read_text(site, "site_id")
|
|
||||||
|
|
||||||
readings = await fetch_readings(
|
|
||||||
client=client,
|
|
||||||
site_id=site_id,
|
|
||||||
start_time=start_time,
|
|
||||||
end_time=end_time,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"{site_id}: {len(readings)} lectures")
|
|
||||||
|
|
||||||
all_readings.extend(readings)
|
|
||||||
|
|
||||||
print(f"Lectures récupérées : {len(all_readings)}")
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
print("Dry-run terminé : aucune donnée écrite.")
|
|
||||||
return
|
|
||||||
|
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
str(settings.database_url),
|
str(settings.database_url),
|
||||||
@@ -344,6 +421,39 @@ async def import_mock_api_history(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Garde-fou d'abord, y compris en dry-run : il est en lecture seule, et annoncer un
|
||||||
|
# succès pour une fenêtre que l'import réel refusera serait trompeur.
|
||||||
|
async with engine.connect() as connection:
|
||||||
|
await refuse_if_overlaps_historical_dataset(connection, start_time, end_time)
|
||||||
|
|
||||||
|
async with create_mock_api_client() as client:
|
||||||
|
sites = await fetch_sites(client)
|
||||||
|
|
||||||
|
print(f"Sites récupérés : {len(sites)}")
|
||||||
|
|
||||||
|
all_readings: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for site in sites:
|
||||||
|
site_id = read_text(site, "site_id")
|
||||||
|
|
||||||
|
readings = await fetch_readings(
|
||||||
|
client=client,
|
||||||
|
site_id=site_id,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"{site_id}: {len(readings)} lectures")
|
||||||
|
|
||||||
|
all_readings.extend(readings)
|
||||||
|
|
||||||
|
print(f"Lectures récupérées : {len(all_readings)}")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print("Dry-run terminé : aucune donnée écrite.")
|
||||||
|
return
|
||||||
|
|
||||||
async with engine.begin() as connection:
|
async with engine.begin() as connection:
|
||||||
await upsert_sites(
|
await upsert_sites(
|
||||||
connection,
|
connection,
|
||||||
@@ -365,7 +475,14 @@ async def import_mock_api_history(
|
|||||||
|
|
||||||
|
|
||||||
def parse_datetime(value: str) -> datetime:
|
def parse_datetime(value: str) -> datetime:
|
||||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
# Sans fuseau, l'API le traite comme reçu, telle quelle, mais l'encodeur `timestamptz`
|
||||||
|
# d'asyncpg lirait un datetime naif dans le fuseau *local du processus* (correct dans le
|
||||||
|
# conteneur Airflow en UTC, décalé de 1-2h pour un import manuel lancé depuis un poste en
|
||||||
|
# Europe/Paris). Poser `tzinfo=UTC` explicitement, même pattern que `_vers_utc()` dans
|
||||||
|
# `app/services/reading.py`, garantit que la borne envoyée à l'API et celle comparée en SQL
|
||||||
|
# (refuse_if_overlaps_historical_dataset) désignent le même instant.
|
||||||
|
instant = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
@@ -383,12 +500,6 @@ def parse_args() -> argparse.Namespace:
|
|||||||
type=parse_datetime,
|
type=parse_datetime,
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--limit",
|
|
||||||
type=int,
|
|
||||||
default=MAX_LIMIT,
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -400,9 +511,6 @@ def parse_args() -> argparse.Namespace:
|
|||||||
def main() -> None:
|
def main() -> None:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
|
||||||
if args.limit < 1 or args.limit > MAX_LIMIT:
|
|
||||||
raise ValueError(f"--limit doit être compris entre 1 et {MAX_LIMIT}.")
|
|
||||||
|
|
||||||
if args.start_time >= args.end_time:
|
if args.start_time >= args.end_time:
|
||||||
raise ValueError("--start-time doit être antérieur à --end-time.")
|
raise ValueError("--start-time doit être antérieur à --end-time.")
|
||||||
|
|
||||||
@@ -410,7 +518,6 @@ def main() -> None:
|
|||||||
import_mock_api_history(
|
import_mock_api_history(
|
||||||
start_time=args.start_time,
|
start_time=args.start_time,
|
||||||
end_time=args.end_time,
|
end_time=args.end_time,
|
||||||
limit=args.limit,
|
|
||||||
dry_run=args.dry_run,
|
dry_run=args.dry_run,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
@@ -110,8 +110,8 @@ async def test_fetch_readings_sends_expected_query_parameters() -> None:
|
|||||||
|
|
||||||
transport = MockTransport(handler)
|
transport = MockTransport(handler)
|
||||||
|
|
||||||
start_time = datetime.fromisoformat("2024-06-15T12:00:00")
|
start_time = datetime.fromisoformat("2024-06-15T12:00:00+00:00")
|
||||||
end_time = datetime.fromisoformat("2024-06-15T13:00:00")
|
end_time = datetime.fromisoformat("2024-06-15T13:00:00+00:00")
|
||||||
|
|
||||||
async with AsyncClient(
|
async with AsyncClient(
|
||||||
transport=transport,
|
transport=transport,
|
||||||
@@ -127,11 +127,61 @@ async def test_fetch_readings_sends_expected_query_parameters() -> None:
|
|||||||
|
|
||||||
assert len(readings) == 1
|
assert len(readings) == 1
|
||||||
assert captured_params["site_id"] == "SITE001"
|
assert captured_params["site_id"] == "SITE001"
|
||||||
assert captured_params["start_time"] == "2024-06-15T12:00:00"
|
assert captured_params["start_time"] == "2024-06-15T12:00:00+00:00"
|
||||||
assert captured_params["end_time"] == "2024-06-15T13:00:00"
|
assert captured_params["end_time"] == "2024-06-15T13:00:00+00:00"
|
||||||
assert captured_params["limit"] == "60"
|
assert captured_params["limit"] == "60"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetch_readings_discards_a_reading_outside_the_requested_window() -> None:
|
||||||
|
# Le garde-fou `refuse_if_overlaps_historical_dataset` ne vérifie que la fenêtre demandée :
|
||||||
|
# une réponse dont un `timestamp` déborde de `[start_time, end_time)` (bug du mock, ou
|
||||||
|
# hostile) contournerait ce contrôle si elle atteignait la base telle quelle.
|
||||||
|
dans_la_fenetre = make_reading()
|
||||||
|
dans_la_fenetre["timestamp"] = "2024-06-15T12:00:00Z"
|
||||||
|
|
||||||
|
hors_fenetre = make_reading()
|
||||||
|
hors_fenetre["timestamp"] = "2023-01-01T00:00:00Z"
|
||||||
|
|
||||||
|
def handler(request: Request) -> Response:
|
||||||
|
return Response(status_code=200, json=[dans_la_fenetre, hors_fenetre])
|
||||||
|
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=MockTransport(handler),
|
||||||
|
base_url="https://mock.test",
|
||||||
|
) as client:
|
||||||
|
readings = await fetch_readings(
|
||||||
|
client=client,
|
||||||
|
site_id="SITE001",
|
||||||
|
start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"),
|
||||||
|
end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"),
|
||||||
|
limit=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert readings == [dans_la_fenetre]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetch_readings_discards_a_reading_with_an_unparseable_timestamp() -> None:
|
||||||
|
invalide = make_reading()
|
||||||
|
invalide["timestamp"] = "pas une date"
|
||||||
|
|
||||||
|
def handler(request: Request) -> Response:
|
||||||
|
return Response(status_code=200, json=[invalide])
|
||||||
|
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=MockTransport(handler),
|
||||||
|
base_url="https://mock.test",
|
||||||
|
) as client:
|
||||||
|
readings = await fetch_readings(
|
||||||
|
client=client,
|
||||||
|
site_id="SITE001",
|
||||||
|
start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"),
|
||||||
|
end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"),
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert readings == []
|
||||||
|
|
||||||
|
|
||||||
async def test_fetch_readings_rejects_non_list_response() -> None:
|
async def test_fetch_readings_rejects_non_list_response() -> None:
|
||||||
def handler(request: Request) -> Response:
|
def handler(request: Request) -> Response:
|
||||||
return Response(
|
return Response(
|
||||||
@@ -152,8 +202,8 @@ async def test_fetch_readings_rejects_non_list_response() -> None:
|
|||||||
await fetch_readings(
|
await fetch_readings(
|
||||||
client=client,
|
client=client,
|
||||||
site_id="SITE001",
|
site_id="SITE001",
|
||||||
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
|
start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"),
|
||||||
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
|
end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"),
|
||||||
limit=60,
|
limit=60,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -175,8 +225,8 @@ async def test_fetch_readings_raises_on_http_error() -> None:
|
|||||||
await fetch_readings(
|
await fetch_readings(
|
||||||
client=client,
|
client=client,
|
||||||
site_id="SITE999",
|
site_id="SITE999",
|
||||||
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
|
start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"),
|
||||||
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
|
end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"),
|
||||||
limit=60,
|
limit=60,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -357,6 +407,100 @@ async def test_upsert_sites_with_empty_list_does_nothing() -> None:
|
|||||||
connection.execute.assert_not_awaited()
|
connection.execute.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_refuse_if_overlaps_historical_dataset_lets_a_clear_window_through() -> None:
|
||||||
|
connection = AsyncMock()
|
||||||
|
connection.execute.return_value.scalar_one = MagicMock(return_value=0)
|
||||||
|
|
||||||
|
await mock_api_import.refuse_if_overlaps_historical_dataset(
|
||||||
|
connection,
|
||||||
|
datetime.fromisoformat("2026-01-01T00:00:00+00:00"),
|
||||||
|
datetime.fromisoformat("2026-01-01T01:00:00+00:00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.execute.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_refuse_if_overlaps_historical_dataset_rejects_a_window_already_in_the_csv() -> None:
|
||||||
|
connection = AsyncMock()
|
||||||
|
connection.execute.return_value.scalar_one = MagicMock(return_value=5)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="doublon inter-source"):
|
||||||
|
await mock_api_import.refuse_if_overlaps_historical_dataset(
|
||||||
|
connection,
|
||||||
|
datetime.fromisoformat("2023-06-15T12:00:00+00:00"),
|
||||||
|
datetime.fromisoformat("2023-06-15T13:00:00+00:00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_limit_for_window_returns_one_per_hour() -> None:
|
||||||
|
limite = mock_api_import.limit_for_window(
|
||||||
|
datetime.fromisoformat("2026-09-02T12:00:00+00:00"),
|
||||||
|
datetime.fromisoformat("2026-09-23T12:00:00+00:00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert limite == 21 * 24
|
||||||
|
|
||||||
|
|
||||||
|
def test_limit_for_window_falls_back_to_one_reading_under_an_hour() -> None:
|
||||||
|
# Le DAG horaire (`:45`) demande desormais [heure pile precedente, instant du declenchement) :
|
||||||
|
# une fenetre plus courte qu'une heure, dont l'espacement `duree/limit` ne peut jamais valoir
|
||||||
|
# 1h pile pour plus d'une lecture. Seule `limit=1`, ancree sur `start_time`, reste alignee.
|
||||||
|
limite = mock_api_import.limit_for_window(
|
||||||
|
datetime.fromisoformat("2026-09-02T12:00:00+00:00"),
|
||||||
|
datetime.fromisoformat("2026-09-02T12:45:00+00:00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert limite == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_limit_for_window_falls_back_to_one_reading_for_a_non_whole_hour_span() -> None:
|
||||||
|
# Meme raisonnement pour une fenetre de plus d'une heure mais qui n'en est pas un multiple
|
||||||
|
# entier : aucun `limit > 1` ne donnerait un espacement d'1h pile.
|
||||||
|
limite = mock_api_import.limit_for_window(
|
||||||
|
datetime.fromisoformat("2026-09-02T12:00:00+00:00"),
|
||||||
|
datetime.fromisoformat("2026-09-02T13:30:00+00:00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert limite == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_limit_for_window_rejects_a_start_time_not_on_the_hour() -> None:
|
||||||
|
with pytest.raises(ValueError, match="pile sur l'heure"):
|
||||||
|
mock_api_import.limit_for_window(
|
||||||
|
datetime.fromisoformat("2026-09-02T12:05:00+00:00"),
|
||||||
|
datetime.fromisoformat("2026-09-02T13:05:00+00:00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_limit_for_window_rejects_a_window_above_the_api_cap() -> None:
|
||||||
|
with pytest.raises(ValueError, match="au-delà du plafond"):
|
||||||
|
mock_api_import.limit_for_window(
|
||||||
|
datetime.fromisoformat("2020-01-01T00:00:00+00:00"),
|
||||||
|
datetime.fromisoformat("2020-03-01T00:00:00+00:00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_engine(*, overlap_count: int = 0) -> tuple[MagicMock, AsyncMock]:
|
||||||
|
"""Engine dont `.connect()` (garde-fou) et `.begin()` (écriture) rendent tous deux la même
|
||||||
|
connexion, dont `scalar_one()` renvoie `overlap_count` : `import_mock_api_history` ouvre
|
||||||
|
désormais le garde-fou via `.connect()`, y compris en dry-run."""
|
||||||
|
connection = AsyncMock()
|
||||||
|
connection.execute.return_value.scalar_one = MagicMock(return_value=overlap_count)
|
||||||
|
|
||||||
|
def _context() -> MagicMock:
|
||||||
|
context = MagicMock()
|
||||||
|
context.__aenter__ = AsyncMock(return_value=connection)
|
||||||
|
context.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
return context
|
||||||
|
|
||||||
|
engine = MagicMock()
|
||||||
|
engine.connect.return_value = _context()
|
||||||
|
engine.begin.return_value = _context()
|
||||||
|
engine.dispose = AsyncMock()
|
||||||
|
|
||||||
|
return engine, connection
|
||||||
|
|
||||||
|
|
||||||
async def test_import_mock_api_history_dry_run_does_not_write(
|
async def test_import_mock_api_history_dry_run_does_not_write(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -396,22 +540,24 @@ async def test_import_mock_api_history_dry_run_does_not_write(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
create_engine_mock = MagicMock()
|
engine, connection = _mock_engine(overlap_count=0)
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
mock_api_import,
|
mock_api_import,
|
||||||
"create_async_engine",
|
"create_async_engine",
|
||||||
create_engine_mock,
|
MagicMock(return_value=engine),
|
||||||
)
|
)
|
||||||
|
|
||||||
await mock_api_import.import_mock_api_history(
|
await mock_api_import.import_mock_api_history(
|
||||||
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
|
start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"),
|
||||||
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
|
end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"),
|
||||||
limit=60,
|
|
||||||
dry_run=True,
|
dry_run=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
create_engine_mock.assert_not_called()
|
# Le garde-fou tourne quand même (lecture seule), mais aucune écriture n'a lieu.
|
||||||
|
connection.execute.assert_awaited_once()
|
||||||
|
engine.begin.assert_not_called()
|
||||||
|
engine.dispose.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
async def test_import_mock_api_history_loads_data(
|
async def test_import_mock_api_history_loads_data(
|
||||||
@@ -453,23 +599,8 @@ async def test_import_mock_api_history_loads_data(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
connection = AsyncMock()
|
engine, connection = _mock_engine(overlap_count=0)
|
||||||
|
create_engine_mock = MagicMock(return_value=engine)
|
||||||
transaction_context = MagicMock()
|
|
||||||
transaction_context.__aenter__ = AsyncMock(
|
|
||||||
return_value=connection,
|
|
||||||
)
|
|
||||||
transaction_context.__aexit__ = AsyncMock(
|
|
||||||
return_value=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
engine = MagicMock()
|
|
||||||
engine.begin.return_value = transaction_context
|
|
||||||
engine.dispose = AsyncMock()
|
|
||||||
|
|
||||||
create_engine_mock = MagicMock(
|
|
||||||
return_value=engine,
|
|
||||||
)
|
|
||||||
|
|
||||||
upsert_sites_mock = AsyncMock()
|
upsert_sites_mock = AsyncMock()
|
||||||
|
|
||||||
@@ -486,9 +617,8 @@ async def test_import_mock_api_history_loads_data(
|
|||||||
)
|
)
|
||||||
|
|
||||||
await mock_api_import.import_mock_api_history(
|
await mock_api_import.import_mock_api_history(
|
||||||
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
|
start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"),
|
||||||
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
|
end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"),
|
||||||
limit=60,
|
|
||||||
dry_run=False,
|
dry_run=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -502,7 +632,39 @@ async def test_import_mock_api_history_loads_data(
|
|||||||
[make_site()],
|
[make_site()],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Un appel pour le garde-fou (via .connect()), un pour READING_INSERT (via .begin()).
|
||||||
|
assert connection.execute.await_count == 2
|
||||||
|
dernier_appel = connection.execute.await_args_list[-1]
|
||||||
|
assert dernier_appel.args[0] is READING_INSERT
|
||||||
|
engine.dispose.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_mock_api_history_refuses_when_it_overlaps_the_historical_dataset(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mock_api_import,
|
||||||
|
"get_settings",
|
||||||
|
lambda: SimpleNamespace(database_url="postgresql+asyncpg://test:test@localhost/test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
engine, connection = _mock_engine(overlap_count=3)
|
||||||
|
monkeypatch.setattr(mock_api_import, "create_async_engine", MagicMock(return_value=engine))
|
||||||
|
|
||||||
|
# Le garde-fou tourne avant tout appel à l'API Mock : create_mock_api_client() ne doit
|
||||||
|
# jamais être invoqué pour une fenêtre refusée.
|
||||||
|
create_client_mock = MagicMock()
|
||||||
|
monkeypatch.setattr(mock_api_import, "create_mock_api_client", create_client_mock)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="doublon inter-source"):
|
||||||
|
await mock_api_import.import_mock_api_history(
|
||||||
|
start_time=datetime.fromisoformat("2023-06-15T12:00:00+00:00"),
|
||||||
|
end_time=datetime.fromisoformat("2023-06-15T13:00:00+00:00"),
|
||||||
|
dry_run=False,
|
||||||
|
)
|
||||||
|
|
||||||
connection.execute.assert_awaited_once()
|
connection.execute.assert_awaited_once()
|
||||||
|
create_client_mock.assert_not_called()
|
||||||
engine.dispose.assert_awaited_once()
|
engine.dispose.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
@@ -516,6 +678,23 @@ def test_parse_datetime_accepts_z_suffix() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_datetime_attaches_utc_to_a_naive_string() -> None:
|
||||||
|
# `--start-time`/`--end-time` du DAG sont formatés sans fuseau (Jinja `strftime`) : sans ce
|
||||||
|
# comportement, l'encodeur `timestamptz` d'asyncpg lirait le datetime naïf dans le fuseau
|
||||||
|
# *local du processus*, pas UTC, et le garde-fou comparerait une autre fenêtre que celle
|
||||||
|
# envoyée à l'API.
|
||||||
|
result = mock_api_import.parse_datetime("2024-06-15T12:00:00")
|
||||||
|
|
||||||
|
assert result == datetime.fromisoformat("2024-06-15T12:00:00+00:00")
|
||||||
|
assert result.tzinfo is UTC
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_datetime_keeps_a_non_utc_offset_as_is() -> None:
|
||||||
|
result = mock_api_import.parse_datetime("2024-06-15T12:00:00+02:00")
|
||||||
|
|
||||||
|
assert result == datetime.fromisoformat("2024-06-15T12:00:00+02:00")
|
||||||
|
|
||||||
|
|
||||||
def test_parse_args_reads_cli_parameters(
|
def test_parse_args_reads_cli_parameters(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -528,8 +707,6 @@ def test_parse_args_reads_cli_parameters(
|
|||||||
"2024-06-15T12:00:00Z",
|
"2024-06-15T12:00:00Z",
|
||||||
"--end-time",
|
"--end-time",
|
||||||
"2024-06-15T13:00:00Z",
|
"2024-06-15T13:00:00Z",
|
||||||
"--limit",
|
|
||||||
"60",
|
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -542,34 +719,9 @@ def test_parse_args_reads_cli_parameters(
|
|||||||
assert args.end_time == datetime.fromisoformat(
|
assert args.end_time == datetime.fromisoformat(
|
||||||
"2024-06-15T13:00:00+00:00",
|
"2024-06-15T13:00:00+00:00",
|
||||||
)
|
)
|
||||||
assert args.limit == 60
|
|
||||||
assert args.dry_run is True
|
assert args.dry_run is True
|
||||||
|
|
||||||
|
|
||||||
def test_main_rejects_limit_out_of_bounds(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.setattr(
|
|
||||||
sys,
|
|
||||||
"argv",
|
|
||||||
[
|
|
||||||
"mock_api_import",
|
|
||||||
"--start-time",
|
|
||||||
"2024-06-15T12:00:00Z",
|
|
||||||
"--end-time",
|
|
||||||
"2024-06-15T13:00:00Z",
|
|
||||||
"--limit",
|
|
||||||
"0",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(
|
|
||||||
ValueError,
|
|
||||||
match="--limit doit être compris entre 1 et 1000",
|
|
||||||
):
|
|
||||||
mock_api_import.main()
|
|
||||||
|
|
||||||
|
|
||||||
def test_main_rejects_invalid_period(
|
def test_main_rejects_invalid_period(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -582,8 +734,6 @@ def test_main_rejects_invalid_period(
|
|||||||
"2024-06-15T14:00:00Z",
|
"2024-06-15T14:00:00Z",
|
||||||
"--end-time",
|
"--end-time",
|
||||||
"2024-06-15T13:00:00Z",
|
"2024-06-15T13:00:00Z",
|
||||||
"--limit",
|
|
||||||
"60",
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -612,7 +762,6 @@ def test_main_runs_import(
|
|||||||
lambda: SimpleNamespace(
|
lambda: SimpleNamespace(
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
end_time=end_time,
|
end_time=end_time,
|
||||||
limit=60,
|
|
||||||
dry_run=True,
|
dry_run=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -628,7 +777,6 @@ def test_main_runs_import(
|
|||||||
import_mock.assert_awaited_once_with(
|
import_mock.assert_awaited_once_with(
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
end_time=end_time,
|
end_time=end_time,
|
||||||
limit=60,
|
|
||||||
dry_run=True,
|
dry_run=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -673,8 +821,8 @@ async def test_fetch_readings_rejects_a_response_above_the_requested_limit() ->
|
|||||||
await fetch_readings(
|
await fetch_readings(
|
||||||
client=client,
|
client=client,
|
||||||
site_id="SITE001",
|
site_id="SITE001",
|
||||||
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
|
start_time=datetime.fromisoformat("2024-06-15T12:00:00+00:00"),
|
||||||
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
|
end_time=datetime.fromisoformat("2024-06-15T13:00:00+00:00"),
|
||||||
limit=2,
|
limit=2,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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([])
|
||||||
Generated
+107
@@ -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"
|
||||||
|
|||||||
@@ -43,6 +43,31 @@ describe('AuthService', () => {
|
|||||||
expect(service.isAuthenticated()).toBe(true);
|
expect(service.isAuthenticated()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('garde le mot de passe provisoire pour un seul changement quand il doit être changé', () => {
|
||||||
|
service.login({ email: 'a@a.com', password: 'Provisoire' }).subscribe();
|
||||||
|
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush({
|
||||||
|
...tokenResponse,
|
||||||
|
principal: { ...tokenResponse.principal, must_change_password: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.takeProvisionalPassword()).toBe('Provisoire');
|
||||||
|
expect(service.takeProvisionalPassword()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne garde aucun mot de passe quand il est déjà définitif, ni après la fin de session', () => {
|
||||||
|
service.login({ email: 'a@a.com', password: 'Definitif' }).subscribe();
|
||||||
|
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse);
|
||||||
|
expect(service.takeProvisionalPassword()).toBeNull();
|
||||||
|
|
||||||
|
service.login({ email: 'a@a.com', password: 'Provisoire' }).subscribe();
|
||||||
|
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush({
|
||||||
|
...tokenResponse,
|
||||||
|
principal: { ...tokenResponse.principal, must_change_password: true },
|
||||||
|
});
|
||||||
|
service.clearSession();
|
||||||
|
expect(service.takeProvisionalPassword()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('efface la session au logout', () => {
|
it('efface la session au logout', () => {
|
||||||
service.login({ email: 'a@a.com', password: 'secret' }).subscribe();
|
service.login({ email: 'a@a.com', password: 'secret' }).subscribe();
|
||||||
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse);
|
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse);
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export class AuthService {
|
|||||||
// mémoire. Un rechargement de page le perd, c'est voulu par le contrat.
|
// mémoire. Un rechargement de page le perd, c'est voulu par le contrat.
|
||||||
private accessTokenSignal = signal<string | null>(null);
|
private accessTokenSignal = signal<string | null>(null);
|
||||||
private principalSignal = signal<Principal | null>(null);
|
private principalSignal = signal<Principal | null>(null);
|
||||||
|
// Pourquoi : redemander le mot de passe provisoire qu'on vient de vérifier laisse un gestionnaire
|
||||||
|
// de mots de passe y coller un ancien mot de passe du site, et `/auth/password` répond 401.
|
||||||
|
private provisionalPassword: string | null = null;
|
||||||
|
|
||||||
readonly principal = this.principalSignal.asReadonly();
|
readonly principal = this.principalSignal.asReadonly();
|
||||||
readonly isAuthenticated = computed(() => this.principalSignal() !== null);
|
readonly isAuthenticated = computed(() => this.principalSignal() !== null);
|
||||||
@@ -37,12 +40,26 @@ export class AuthService {
|
|||||||
clearSession(): void {
|
clearSession(): void {
|
||||||
this.accessTokenSignal.set(null);
|
this.accessTokenSignal.set(null);
|
||||||
this.principalSignal.set(null);
|
this.principalSignal.set(null);
|
||||||
|
this.provisionalPassword = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
login(credentials: LoginRequest): Observable<TokenResponse> {
|
login(credentials: LoginRequest): Observable<TokenResponse> {
|
||||||
return this.http
|
return this.http
|
||||||
.post<TokenResponse>(`${environment.apiUrl}/auth/login`, credentials, { withCredentials: true })
|
.post<TokenResponse>(`${environment.apiUrl}/auth/login`, credentials, { withCredentials: true })
|
||||||
.pipe(tap((response) => this.setSession(response)));
|
.pipe(
|
||||||
|
tap((response) => {
|
||||||
|
this.setSession(response);
|
||||||
|
this.provisionalPassword = response.principal.must_change_password
|
||||||
|
? credentials.password
|
||||||
|
: null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
takeProvisionalPassword(): string | null {
|
||||||
|
const password = this.provisionalPassword;
|
||||||
|
this.provisionalPassword = null;
|
||||||
|
return password;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Un seul rafraîchissement en vol à la fois, partagé entre tous les
|
// Un seul rafraîchissement en vol à la fois, partagé entre tous les
|
||||||
|
|||||||
@@ -7,14 +7,18 @@
|
|||||||
Votre mot de passe est provisoire, vous devez le modifier avant de continuer
|
Votre mot de passe est provisoire, vous devez le modifier avant de continuer
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label class="form-label" for="current_password">Mot de passe actuel</label>
|
<input hidden type="email" autocomplete="username" [value]="email" readonly />
|
||||||
<input
|
|
||||||
id="current_password"
|
@if (asksCurrentPassword()) {
|
||||||
class="form-input"
|
<label class="form-label" for="current_password">Mot de passe actuel</label>
|
||||||
type="password"
|
<input
|
||||||
formControlName="current_password"
|
id="current_password"
|
||||||
autocomplete="current-password"
|
class="form-input"
|
||||||
/>
|
type="password"
|
||||||
|
formControlName="current_password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
<label class="form-label" for="new_password">Nouveau mot de passe</label>
|
<label class="form-label" for="new_password">Nouveau mot de passe</label>
|
||||||
<input
|
<input
|
||||||
@@ -24,7 +28,7 @@
|
|||||||
formControlName="new_password"
|
formControlName="new_password"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
/>
|
/>
|
||||||
<span class="form-hint">{{ passwordHint }}</span>
|
<app-password-requirements [password]="newPassword()" />
|
||||||
|
|
||||||
@if (errorMessage()) {
|
@if (errorMessage()) {
|
||||||
<ev-alert severity="danger">{{ errorMessage() }}</ev-alert>
|
<ev-alert severity="danger">{{ errorMessage() }}</ev-alert>
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { ReactiveFormsModule } from '@angular/forms';
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
import { of, throwError } from 'rxjs';
|
import { of, throwError } from 'rxjs';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import { ChangePassword } from './change-password';
|
import { ChangePassword } from './change-password';
|
||||||
import { AuthService } from '../../../core/services/auth.service';
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
|
|
||||||
|
const NOUVEAU = 'Un-nouveau-mot-de-passe1!';
|
||||||
|
|
||||||
describe('ChangePassword', () => {
|
describe('ChangePassword', () => {
|
||||||
let authMock: { changePassword: ReturnType<typeof vi.fn> };
|
let authMock: {
|
||||||
|
changePassword: ReturnType<typeof vi.fn>;
|
||||||
|
takeProvisionalPassword: ReturnType<typeof vi.fn>;
|
||||||
|
principal: ReturnType<typeof signal>;
|
||||||
|
};
|
||||||
let routerMock: { navigate: ReturnType<typeof vi.fn> };
|
let routerMock: { navigate: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
authMock = { changePassword: vi.fn() };
|
authMock = {
|
||||||
|
changePassword: vi.fn(),
|
||||||
|
takeProvisionalPassword: vi.fn().mockReturnValue(null),
|
||||||
|
principal: signal({ email: 'johan@enervision.fr' }),
|
||||||
|
};
|
||||||
routerMock = { navigate: vi.fn() };
|
routerMock = { navigate: vi.fn() };
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
@@ -23,6 +35,10 @@ describe('ChangePassword', () => {
|
|||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function champActuel(fixture: { nativeElement: HTMLElement }): HTMLInputElement | null {
|
||||||
|
return fixture.nativeElement.querySelector('#current_password');
|
||||||
|
}
|
||||||
|
|
||||||
it('ne soumet pas si le formulaire est invalide (mot de passe trop court)', () => {
|
it('ne soumet pas si le formulaire est invalide (mot de passe trop court)', () => {
|
||||||
const fixture = TestBed.createComponent(ChangePassword);
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
const component = fixture.componentInstance;
|
const component = fixture.componentInstance;
|
||||||
@@ -44,7 +60,7 @@ describe('ChangePassword', () => {
|
|||||||
it('redirige vers /dashboard après un changement réussi', () => {
|
it('redirige vers /dashboard après un changement réussi', () => {
|
||||||
const fixture = TestBed.createComponent(ChangePassword);
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
const component = fixture.componentInstance;
|
const component = fixture.componentInstance;
|
||||||
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
|
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU });
|
||||||
|
|
||||||
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
||||||
|
|
||||||
@@ -52,46 +68,94 @@ describe('ChangePassword', () => {
|
|||||||
expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']);
|
expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("affiche un message d'erreur si le mot de passe actuel est incorrect", () => {
|
it("demande le mot de passe actuel quand la connexion ne l'a pas transmis (page rechargée)", () => {
|
||||||
const fixture = TestBed.createComponent(ChangePassword);
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
const component = fixture.componentInstance;
|
fixture.detectChanges();
|
||||||
component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
|
|
||||||
|
|
||||||
authMock.changePassword.mockReturnValue(throwError(() => new Error('401')));
|
expect(champActuel(fixture)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
component.onSubmit();
|
it('réutilise le mot de passe provisoire de la connexion sans le redemander', () => {
|
||||||
fixture.detectChanges(); // rend le bloc @if (errorMessage())
|
authMock.takeProvisionalPassword.mockReturnValue('Provisoire-24-caracteres');
|
||||||
|
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
||||||
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(component.errorMessage()).toContain('incorrect');
|
expect(champActuel(fixture)).toBeNull();
|
||||||
const errorEl = fixture.nativeElement.querySelector('.ev-alert');
|
component.form.controls.new_password.setValue(NOUVEAU);
|
||||||
expect(errorEl?.textContent).toContain('incorrect');
|
component.onSubmit();
|
||||||
|
|
||||||
|
expect(authMock.changePassword).toHaveBeenCalledWith({
|
||||||
|
current_password: 'Provisoire-24-caracteres',
|
||||||
|
new_password: NOUVEAU,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('associe le formulaire au compte connecté pour les gestionnaires de mots de passe', () => {
|
||||||
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const identifiant = fixture.nativeElement.querySelector('input[autocomplete="username"]');
|
||||||
|
expect(identifiant.value).toBe('johan@enervision.fr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sur un 401, dit que le mot de passe actuel est faux et le redemande', () => {
|
||||||
|
authMock.takeProvisionalPassword.mockReturnValue('Provisoire-perime');
|
||||||
|
authMock.changePassword.mockReturnValue(
|
||||||
|
throwError(() => new HttpErrorResponse({ status: 401 })),
|
||||||
|
);
|
||||||
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.form.controls.new_password.setValue(NOUVEAU);
|
||||||
|
|
||||||
|
component.onSubmit();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.errorMessage()).toContain('Mot de passe actuel incorrect');
|
||||||
|
expect(fixture.nativeElement.querySelector('.ev-alert')?.textContent).toContain('incorrect');
|
||||||
|
expect(champActuel(fixture)).not.toBeNull();
|
||||||
|
expect(component.form.controls.current_password.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sur un 422, dit que le nouveau mot de passe ne respecte pas la politique', () => {
|
||||||
|
authMock.changePassword.mockReturnValue(
|
||||||
|
throwError(() => new HttpErrorResponse({ status: 422 })),
|
||||||
|
);
|
||||||
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU });
|
||||||
|
|
||||||
|
component.onSubmit();
|
||||||
|
|
||||||
|
expect(component.errorMessage()).toContain('Nouveau mot de passe refusé');
|
||||||
|
expect(component.form.controls.current_password.value).toBe('ancien-mot-de-passe');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('désactive le bouton tant que le formulaire est invalide', () => {
|
it('désactive le bouton tant que le formulaire est invalide', () => {
|
||||||
const fixture = TestBed.createComponent(ChangePassword);
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const button = fixture.nativeElement.querySelector('button[type="submit"]');
|
const button = fixture.nativeElement.querySelector('button[type="submit"]');
|
||||||
expect(button.disabled).toBe(true);
|
expect(button.disabled).toBe(true);
|
||||||
expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull();
|
expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
|
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
|
||||||
const fixture = TestBed.createComponent(ChangePassword);
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
const component = fixture.componentInstance;
|
const component = fixture.componentInstance;
|
||||||
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
|
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU });
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
||||||
|
|
||||||
const form = fixture.nativeElement.querySelector('form');
|
const form = fixture.nativeElement.querySelector('form');
|
||||||
form.dispatchEvent(new Event('submit'));
|
form.dispatchEvent(new Event('submit'));
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(authMock.changePassword).toHaveBeenCalledWith({
|
expect(authMock.changePassword).toHaveBeenCalledWith({
|
||||||
current_password: 'ancien-mot-de-passe',
|
current_password: 'ancien-mot-de-passe',
|
||||||
new_password: 'Un-nouveau-mot-de-passe1!',
|
new_password: NOUVEAU,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
import { AuthService } from '../../../core/services/auth.service';
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
import { Button } from '../../../shared/components/ui/button/button';
|
import { Button } from '../../../shared/components/ui/button/button';
|
||||||
import { Card } from '../../../shared/components/ui/card/card';
|
import { Card } from '../../../shared/components/ui/card/card';
|
||||||
import { Alert } from '../../../shared/components/ui/alert/alert';
|
import { Alert } from '../../../shared/components/ui/alert/alert';
|
||||||
import { Brand } from '../../../shared/components/ui/brand/brand';
|
import { Brand } from '../../../shared/components/ui/brand/brand';
|
||||||
|
import { PasswordRequirementsChecklist } from '../../../shared/components/password-requirements/password-requirements';
|
||||||
import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator';
|
import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-change-password',
|
selector: 'app-change-password',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [ReactiveFormsModule, Button, Card, Alert, Brand],
|
imports: [ReactiveFormsModule, Button, Card, Alert, Brand, PasswordRequirementsChecklist],
|
||||||
templateUrl: './change-password.html',
|
templateUrl: './change-password.html',
|
||||||
styleUrl: './change-password.scss',
|
styleUrl: './change-password.scss',
|
||||||
})
|
})
|
||||||
@@ -20,30 +23,47 @@ export class ChangePassword {
|
|||||||
private auth = inject(AuthService);
|
private auth = inject(AuthService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
|
||||||
|
private provisionalPassword = this.auth.takeProvisionalPassword();
|
||||||
|
|
||||||
errorMessage = signal<string | null>(null);
|
errorMessage = signal<string | null>(null);
|
||||||
isLoading = signal(false);
|
isLoading = signal(false);
|
||||||
passwordHint = PASSWORD_HINT;
|
asksCurrentPassword = signal(this.provisionalPassword === null);
|
||||||
|
email = this.auth.principal()?.email ?? '';
|
||||||
|
|
||||||
form = this.fb.nonNullable.group({
|
form = this.fb.nonNullable.group({
|
||||||
current_password: ['', Validators.required],
|
current_password: [this.provisionalPassword ?? '', Validators.required],
|
||||||
new_password: ['', passwordValidators],
|
new_password: ['', passwordValidators],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
newPassword = toSignal(this.form.controls.new_password.valueChanges, { initialValue: '' });
|
||||||
|
|
||||||
onSubmit(): void {
|
onSubmit(): void {
|
||||||
if (this.form.invalid) return;
|
if (this.form.invalid) return;
|
||||||
this.isLoading.set(true);
|
this.isLoading.set(true);
|
||||||
this.errorMessage.set(null);
|
this.errorMessage.set(null);
|
||||||
|
|
||||||
this.auth.changePassword(this.form.getRawValue()).subscribe({
|
this.auth.changePassword(this.form.getRawValue()).subscribe({
|
||||||
next: (response) => {
|
next: () => {
|
||||||
this.router.navigate(['/dashboard']);
|
this.router.navigate(['/dashboard']);
|
||||||
},
|
},
|
||||||
error: () => {
|
error: (error: HttpErrorResponse) => {
|
||||||
this.isLoading.set(false);
|
this.isLoading.set(false);
|
||||||
this.errorMessage.set(
|
this.errorMessage.set(this.explique(error));
|
||||||
`Mot de passe actuel incorrect, ou nouveau mot de passe invalide (${this.passwordHint}).`,
|
if (error.status === 401) {
|
||||||
);
|
this.form.controls.current_password.reset('');
|
||||||
|
this.asksCurrentPassword.set(true);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private explique(error: HttpErrorResponse): string {
|
||||||
|
if (error.status === 401) {
|
||||||
|
return 'Mot de passe actuel incorrect : saisissez le mot de passe provisoire qui vous a été transmis.';
|
||||||
|
}
|
||||||
|
if (error.status === 422) {
|
||||||
|
return `Nouveau mot de passe refusé (${PASSWORD_HINT}).`;
|
||||||
|
}
|
||||||
|
return 'Le changement de mot de passe a échoué, réessayez dans un instant.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -70,11 +70,17 @@ Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API,
|
|||||||
intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
|
intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
|
||||||
[30-frontend.md](30-frontend.md).
|
[30-frontend.md](30-frontend.md).
|
||||||
|
|
||||||
Le lien `airflow --> db` est maintenant en trait plein : cinq DAGs tournent, deux pour
|
Le lien `airflow --> db` est maintenant en trait plein : six DAGs tournent, deux pour
|
||||||
l'entraînement et le scoring du modèle ML (issue #115), un pour la détection d'alertes et la
|
l'entraînement et le scoring du modèle ML (issue #115), un pour la détection d'alertes et la
|
||||||
génération des recommandations (issue #116), `historical_import` pour le dataset historique
|
génération des recommandations (issue #116), un pour la surveillance de dérive (issue #45),
|
||||||
(issue #119) et `mock_api_import` pour l'ingestion horaire de l'API Mock (issue #15).
|
`historical_import` pour le dataset historique (issue #119) et `mock_api_import` pour l'ingestion
|
||||||
La réconciliation globale des données provenant des deux sources reste à compléter dans l'issue #15.
|
horaire de l'API Mock (issue #15). La réconciliation entre les deux sources de lectures (issue
|
||||||
|
#15) est tranchée : le trou entre la fin de l'historique (31/12/2024) et le début de l'ingestion
|
||||||
|
API Mock est accepté comme définitivement perdu, aucune mesure réelle n'existant pour cette
|
||||||
|
période. `mock_api_import` refuse toute fenêtre qui recouvrirait des lectures déjà importées du
|
||||||
|
CSV plutôt que de laisser les deux sources dupliquer silencieusement un même instant, et le
|
||||||
|
pipeline ML déduplique par construction (`DISTINCT ON`, source `csv` préférée) au cas où un
|
||||||
|
recouvrement se produirait malgré tout, voir [40-data.md](40-data.md).
|
||||||
|
|
||||||
Les liens de la supervision sont en trait plein depuis le 23/09 (issue #26) : Prometheus scrute
|
Les liens de la supervision sont en trait plein depuis le 23/09 (issue #26) : Prometheus scrute
|
||||||
`/metrics` avec un jeton, Grafana lit Prometheus et, par un rôle en lecture seule, les tables
|
`/metrics` avec un jeton, Grafana lit Prometheus et, par un rôle en lecture seule, les tables
|
||||||
@@ -91,8 +97,9 @@ 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. La réconciliation globale des deux sources reste à compléter dans l'issue #15. |
|
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver et scheduler avec LocalExecutor via Docker Compose, sur une base PostgreSQL dédiée. Six DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes`, `historical_import`, `mock_api_import` et `derive` (quotidien, surveillance de dérive). L'import historique reste manuel et l'import API Mock s'exécute chaque heure. Réconciliation entre les deux sources (issue #15) : trou temporel accepté, recouvrement refusé à l'ingestion et dédupliqué en défense côté ML, voir [40-data.md](40-data.md). |
|
||||||
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 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) |
|
||||||
|
|
||||||
## Flux bout en bout
|
## Flux bout en bout
|
||||||
@@ -102,7 +109,8 @@ Statut : `En cours`. **Le chemin de lecture tourne** entre la base, l'API et le
|
|||||||
dataset CSV/JSON sur déclenchement manuel et `mock_api_import` collecte chaque heure les mesures
|
dataset CSV/JSON sur déclenchement manuel et `mock_api_import` collecte chaque heure les mesures
|
||||||
de l'API Mock. Les DAGs `ml_train` et `ml_score` (issue #115), `alertes` (issue #116) et `derive`
|
de l'API Mock. Les DAGs `ml_train` et `ml_score` (issue #115), `alertes` (issue #116) et `derive`
|
||||||
(issue #45) portent le pipeline ML, la détection d'alertes et la surveillance de dérive. La
|
(issue #45) portent le pipeline ML, la détection d'alertes et la surveillance de dérive. La
|
||||||
réconciliation globale des données provenant des deux sources reste à compléter dans l'issue #15.
|
réconciliation entre les deux sources de lectures (issue #15) est close : voir
|
||||||
|
[40-data.md](40-data.md) pour le détail du garde-fou d'ingestion et de la déduplication ML.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
@@ -133,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
|
||||||
@@ -195,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 |
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|
||||||
@@ -89,8 +90,9 @@ l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md).
|
|||||||
| `ml_score` | `0 * * * *` | `enervision_ml.score`, dans `/opt/ml/.venv` |
|
| `ml_score` | `0 * * * *` | `enervision_ml.score`, dans `/opt/ml/.venv` |
|
||||||
| `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations`, dans `/opt/backend/.venv` |
|
| `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations`, dans `/opt/backend/.venv` |
|
||||||
| `historical_import` | manuelle | `app.etl.historical_import`, dans `/opt/backend/.venv` ; les fichiers de `data/raw` sont montés en lecture seule dans `/opt/data/raw` |
|
| `historical_import` | manuelle | `app.etl.historical_import`, dans `/opt/backend/.venv` ; les fichiers de `data/raw` sont montés en lecture seule dans `/opt/data/raw` |
|
||||||
| `mock_api_import` | `45 * * * *` | `app.etl.mock_api_import`, dans `/opt/backend/.venv` ; importe l'heure précédant son déclenchement depuis l'API Mock |
|
| `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
|
||||||
@@ -99,8 +101,10 @@ modifier.
|
|||||||
|
|
||||||
Le DAG `mock_api_import` exécute le pipeline API Mock toutes les heures, à la minute `:45`.
|
Le DAG `mock_api_import` exécute le pipeline API Mock toutes les heures, à la minute `:45`.
|
||||||
Un `CronTriggerTimetable` explicite lui attribue un intervalle d'une heure, y compris lors d'un
|
Un `CronTriggerTimetable` explicite lui attribue un intervalle d'une heure, y compris lors d'un
|
||||||
déclenchement manuel. Il transmet cet intervalle au script backend et charge les mesures dans
|
déclenchement manuel, mais la fenêtre transmise au script backend part de l'heure pile qui
|
||||||
les tables communes `site` et `reading`. Le décalage à `:45` laisse quinze minutes avant le
|
précède le déclenchement (pas de l'intervalle Airflow tel quel), pour que la mesure importée
|
||||||
|
tombe à :00 et non à :45, voir [40-data.md](40-data.md). Le pipeline charge la mesure dans les
|
||||||
|
tables communes `site` et `reading`. Le décalage à `:45` laisse quinze minutes avant le
|
||||||
scoring exécuté à l'heure pile, puis quinze minutes supplémentaires avant les alertes à `:15`.
|
scoring exécuté à l'heure pile, puis quinze minutes supplémentaires avant les alertes à `:15`.
|
||||||
`max_active_runs=1` empêche deux exécutions du DAG de se chevaucher.
|
`max_active_runs=1` empêche deux exécutions du DAG de se chevaucher.
|
||||||
|
|
||||||
@@ -177,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
|
||||||
@@ -251,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` |
|
||||||
|
|
||||||
@@ -295,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+102
-11
@@ -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
|
||||||
@@ -437,10 +463,29 @@ Les paramètres de ligne de commande disponibles pour l'import sont :
|
|||||||
```text
|
```text
|
||||||
--start-time
|
--start-time
|
||||||
--end-time
|
--end-time
|
||||||
--limit
|
|
||||||
--dry-run
|
--dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Piège sur `limit`, corrigé dans le code plutôt que documenté** : l'API ne renvoie pas un flux à
|
||||||
|
un rythme naturel, elle répartit exactement `limit` lectures, espacées uniformément, sur toute la
|
||||||
|
fenêtre `[start_time, end_time)` demandée, la première au tout début de la fenêtre (vérifié
|
||||||
|
empiriquement en interrogeant directement l'API). Une fenêtre d'une heure avec `limit=1000`, le
|
||||||
|
réglage d'origine, renvoyait donc 1000 lectures espacées de 3,6 secondes à l'intérieur de cette
|
||||||
|
heure, pas une lecture horaire, incompatible avec les lags positionnels de `build_features`.
|
||||||
|
Plutôt que documenter la règle « `limit` = nombre d'heures de la fenêtre » et compter sur chaque
|
||||||
|
appelant pour la respecter, `limit_for_window()` la porte : `import_mock_api_history()` calcule
|
||||||
|
`limit` depuis la fenêtre reçue, refuse une fenêtre dont `start_time` ne tombe pas pile sur
|
||||||
|
l'heure (c'est elle qui ancre l'alignement), et refuse un intervalle de plus de 1000 heures (le
|
||||||
|
plafond `limit` de l'API). `--limit` n'existe donc plus côté CLI. Deux formes de fenêtre sont
|
||||||
|
gérées : un multiple entier d'heures (`limit` = ce nombre d'heures, une lecture par heure
|
||||||
|
espacée d'1h pile, chemin du backfill manuel) ou une fenêtre plus courte qu'une heure, ou qui
|
||||||
|
n'en est pas un multiple entier (`limit=1`, seule valeur qui reste alignée quand l'espacement
|
||||||
|
`durée / limit` ne peut valoir 1h pile). Le DAG `mock_api_import` est dans ce second cas : il
|
||||||
|
demande la fenêtre `[heure pile précédant le déclenchement, instant du déclenchement)`, plus
|
||||||
|
courte qu'une heure, plutôt que l'intervalle Airflow `[data_interval_start, data_interval_end)`
|
||||||
|
tel quel (`[:45, :45)`) qui aurait placé l'unique lecture à :45, hors de la grille horaire du
|
||||||
|
reste du schéma.
|
||||||
|
|
||||||
### Flux d'ingestion API Mock
|
### Flux d'ingestion API Mock
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -492,7 +537,7 @@ réponse est donc traitée comme une entrée hostile, conformément à API10 dan
|
|||||||
[la traçabilité OWASP](owasp-traceabilite.md). Le risque premier n'est pas la fausse alerte,
|
[la traçabilité OWASP](owasp-traceabilite.md). Le risque premier n'est pas la fausse alerte,
|
||||||
c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction.
|
c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction.
|
||||||
|
|
||||||
Quatre garde-fous, tous dans `mock_api_import.py` :
|
Cinq garde-fous, tous dans `mock_api_import.py` :
|
||||||
|
|
||||||
| Garde-fou | Mise en œuvre |
|
| Garde-fou | Mise en œuvre |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -500,6 +545,7 @@ Quatre garde-fous, tous dans `mock_api_import.py` :
|
|||||||
| Taille de tableau plafonnée | `MAX_SITES` sites, et au plus `--limit` mesures par site |
|
| Taille de tableau plafonnée | `MAX_SITES` sites, et au plus `--limit` mesures par site |
|
||||||
| Bornes physiques | `PHYSICAL_BOUNDS`, une plage par grandeur |
|
| Bornes physiques | `PHYSICAL_BOUNDS`, une plage par grandeur |
|
||||||
| Frontière d'anti-corruption | `build_site_row()` et `build_reading_row()`, qui ne recopient que les champs attendus |
|
| Frontière d'anti-corruption | `build_site_row()` et `build_reading_row()`, qui ne recopient que les champs attendus |
|
||||||
|
| Refus de recouvrir l'historique | `refuse_if_overlaps_historical_dataset()`, voir ci-dessous |
|
||||||
|
|
||||||
Une valeur hors bornes, d'un type inattendu, `NaN` ou infinie devient `NULL`. Elle laisse sa
|
Une valeur hors bornes, d'un type inattendu, `NaN` ou infinie devient `NULL`. Elle laisse sa
|
||||||
trace dans `null_reasons` sous la forme `out_of_physical_bounds:<colonne>`, et `data_quality`
|
trace dans `null_reasons` sous la forme `out_of_physical_bounds:<colonne>`, et `data_quality`
|
||||||
@@ -510,6 +556,51 @@ d'origine intacte : rien n'est perdu, seule son exploitation est bornée.
|
|||||||
Le plafond de taille s'applique après désérialisation de la réponse. Borner le corps HTTP
|
Le plafond de taille s'applique après désérialisation de la réponse. Borner le corps HTTP
|
||||||
lui-même demanderait une lecture en flux, et reste à faire.
|
lui-même demanderait une lecture en flux, et reste à faire.
|
||||||
|
|
||||||
|
### Réconciliation entre les deux sources (issue #15)
|
||||||
|
|
||||||
|
`historical_import` (source `csv`) et `mock_api_import` (source `api_history`) écrivent toutes
|
||||||
|
deux dans `reading`. Trois décisions ferment cette réconciliation :
|
||||||
|
|
||||||
|
- **Le trou temporel est accepté.** Le dataset historique s'arrête au 31/12/2024, et
|
||||||
|
`mock_api_import` n'importe que l'heure précédant chaque déclenchement : rien ne comble
|
||||||
|
automatiquement la période intermédiaire, et rien ne le pourra jamais, aucune mesure réelle
|
||||||
|
n'existe pour ces instants. Conséquence pour le ML, pas nouvelle mais que ce trou rend
|
||||||
|
définitive : `build_features()` calcule ses lags par `shift(n)` positionnel, et `train.py`
|
||||||
|
n'écarte que les lignes où `lag_168h` est `NaN`. Pour un site présent dans les deux sources, les
|
||||||
|
168 premières lectures `api_history` qui suivent le trou héritent donc de lags et de moyennes
|
||||||
|
glissantes calculés sur décembre 2024 (et tant que l'ingestion a moins de 7 jours, c'est le cas
|
||||||
|
de toutes les lectures). Même effet, plus ponctuel, pour chaque heure que le DAG manque
|
||||||
|
(`mock_api_import` en échec, Airflow arrêté). Aucun garde-fou ne détecte aujourd'hui un lag
|
||||||
|
calculé sur un écart réel différent de celui attendu ; issue de suivi à ouvrir.
|
||||||
|
- **Le recouvrement est refusé à l'ingestion.** `uq_reading_source` autorise deux lignes au même
|
||||||
|
`(site_id, timestamp)` dès que `source` diffère : rien dans le schéma n'empêche donc un import
|
||||||
|
Mock API manuel avec une fenêtre passée (le script accepte `--start-time`/`--end-time`
|
||||||
|
arbitraires) de dupliquer un point déjà couvert par le CSV. `import_mock_api_history()` appelle
|
||||||
|
`refuse_if_overlaps_historical_dataset()` avant toute écriture, y compris en `--dry-run` (le
|
||||||
|
contrôle est en lecture seule) et avant le moindre appel à l'API Mock : si la fenêtre demandée
|
||||||
|
recouvre au moins une lecture `source='csv'`, l'import est refusé (`ValueError`) plutôt que
|
||||||
|
d'écrire un doublon inter-source silencieux. Le contrôle ne porte que sur la fenêtre demandée,
|
||||||
|
pas sur les lectures reçues : `fetch_readings()` écarte donc toute lecture dont le `timestamp`
|
||||||
|
déborde de `[start_time, end_time)`, pour qu'une réponse hors fenêtre (bug du mock, ou hostile)
|
||||||
|
ne puisse pas le contourner. Ce contrôle compare des instants, pas des chaînes : `parse_datetime()`
|
||||||
|
pose `tzinfo=UTC` sur une entrée sans fuseau (même pattern que `_vers_utc()` dans
|
||||||
|
`app/services/reading.py`), sans quoi l'encodeur `timestamptz` d'asyncpg lirait un datetime naïf
|
||||||
|
dans le fuseau local du **processus**, correct dans le conteneur Airflow (UTC) mais décalé pour
|
||||||
|
un import manuel lancé depuis un poste en Europe/Paris.
|
||||||
|
- **Le pipeline ML déduplique en défense.** Le garde-fou ci-dessus protège l'ingestion, pas
|
||||||
|
la lecture : si un recouvrement se produisait malgré tout (import direct en base, contournement
|
||||||
|
du script), `ml/enervision_ml/data.py` ne doit pas casser silencieusement l'hypothèse de
|
||||||
|
`build_features` (« une ligne par `(site_id, timestamp)` »). `load_from_database()` et
|
||||||
|
`load_recent_from_database()` utilisent donc `SELECT DISTINCT ON (site_id, timestamp)`, `source
|
||||||
|
= 'csv'` gagnant sur `'api_history'` en cas d'égalité, l'historique étant une source vérifiée,
|
||||||
|
l'API Mock une entrée hostile (cf. ci-dessus). **Cette préférence est spécifique au chargeur
|
||||||
|
ML.** `GET /readings` renvoie les deux lignes sans les fusionner, et `DriftRepository` /
|
||||||
|
`ReadingRepository.latest_by_site()` / `.latest_for_site()` départagent par `reading_id` le plus
|
||||||
|
grand (en pratique la ligne insérée en dernier, pas forcément `csv`) : en cas de recouvrement, la
|
||||||
|
dérive comparerait alors une prévision à une valeur différente de celle sur laquelle le modèle a
|
||||||
|
appris. Pas d'incohérence aujourd'hui tant que le recouvrement reste refusé à l'ingestion ; à
|
||||||
|
aligner si ce garde-fou devait un jour être contourné.
|
||||||
|
|
||||||
### Qualité des données de l'API Mock
|
### Qualité des données de l'API Mock
|
||||||
|
|
||||||
Les valeurs `NULL` ne sont pas remplacées pendant l'ingestion.
|
Les valeurs `NULL` ne sont pas remplacées pendant l'ingestion.
|
||||||
|
|||||||
@@ -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 :
|
||||||
|
|
||||||
|
|||||||
+12
-9
@@ -668,15 +668,18 @@ le pipeline ML (`ml_train` et `ml_score`, issue #115), la détection d'alertes e
|
|||||||
des recommandations (`alertes`, issue #116), l'import historique (`historical_import`,
|
des recommandations (`alertes`, issue #116), l'import historique (`historical_import`,
|
||||||
issue #119) et l'import périodique de l'API Mock (`mock_api_import`, issue #15).
|
issue #119) et l'import périodique de l'API Mock (`mock_api_import`, issue #15).
|
||||||
|
|
||||||
Le DAG `mock_api_import` s'exécute chaque heure, à la minute `:45`. Il appelle
|
Le DAG `mock_api_import` s'exécute chaque heure, à la minute `:45`, sur une fenêtre qui part de
|
||||||
`app.etl.mock_api_import` avec un intervalle explicite d'une heure et une limite de 1 000 lectures
|
l'heure pile précédant son déclenchement jusqu'à l'instant du déclenchement lui-même (pas
|
||||||
par site. Les deux pipelines normalisent leurs données vers les tables communes `site` et
|
l'intervalle Airflow `data_interval_start`/`end` tel quel). L'API Mock génère autant de points que
|
||||||
`reading`, tout en conservant leur source (`csv` ou `api_history`). La réconciliation globale
|
la limite demandée, répartis sur la fenêtre et le premier à son début :
|
||||||
des deux sources reste à compléter dans l'issue #15.
|
`app.etl.mock_api_import.limit_for_window()` dérive donc `limit` de la fenêtre reçue (une seule
|
||||||
|
lecture ici, ancrée sur l'heure pile) plutôt que de dépendre d'une valeur fixée à la main côté
|
||||||
Le DAG `mock_api_import` exécute `app.etl.mock_api_import` toutes les heures. Chaque exécution
|
DAG, et refuse une fenêtre qui ne démarre pas pile sur l'heure. Une fenêtre calée sur l'intervalle
|
||||||
traite l'intervalle Airflow précédent. Les deux pipelines normalisent leurs données vers les
|
Airflow tel quel (`[:45, :45)`) placerait cette lecture à :45, hors de la grille horaire du reste
|
||||||
tables communes `site` et `reading`, tout en conservant leur source (`csv` ou `api_history`).
|
du schéma (vérifié empiriquement contre l'API Mock) ; partir de l'heure pile évite ce décalage.
|
||||||
|
Les deux pipelines normalisent leurs données vers les tables communes `site` et `reading`, tout en
|
||||||
|
conservant leur source (`csv` ou `api_history`). La réconciliation entre les deux sources
|
||||||
|
(issue #15) est close : voir `docs/architecture/40-data.md`.
|
||||||
|
|
||||||
Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py`, `alertes.py`, `historical_import.py` et
|
Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py`, `alertes.py`, `historical_import.py` et
|
||||||
`mock_api_import.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui).
|
`mock_api_import.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""DAG d'import périodique des données de l'API Mock EnerVision (issue #15).
|
"""DAG d'import périodique des données de l'API Mock EnerVision (issue #15).
|
||||||
|
|
||||||
Orchestre le pipeline existant `app.etl.mock_api_import` sans dupliquer sa logique ETL.
|
Orchestre le pipeline existant `app.etl.mock_api_import` sans dupliquer sa logique ETL.
|
||||||
Chaque exécution traite l'heure précédant son déclenchement.
|
Chaque exécution importe la mesure de l'heure pile qui précède son déclenchement.
|
||||||
|
|
||||||
Le pipeline backend reste responsable de la validation, de la normalisation, du suivi de la
|
Le pipeline backend reste responsable de la validation, de la normalisation, du suivi de la
|
||||||
qualité, de l'idempotence et du chargement dans PostgreSQL/TimescaleDB.
|
qualité, de l'idempotence et du chargement dans PostgreSQL/TimescaleDB.
|
||||||
@@ -18,10 +18,6 @@ from airflow.timetables.trigger import CronTriggerTimetable
|
|||||||
# Le backend possède son propre environnement uv dans l'image Airflow (ADR 0008).
|
# Le backend possède son propre environnement uv dans l'image Airflow (ADR 0008).
|
||||||
COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m"
|
COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m"
|
||||||
|
|
||||||
# Le pipeline backend et l'API acceptent au maximum 1 000 lectures par site.
|
|
||||||
# Cette marge évite de perdre silencieusement une lecture si une heure en contient plus de 60.
|
|
||||||
LIMITE_LECTURES = 1000
|
|
||||||
|
|
||||||
# Deux reprises donnent trois tentatives au total. Même dans le pire cas, l'exécution reste
|
# Deux reprises donnent trois tentatives au total. Même dans le pire cas, l'exécution reste
|
||||||
# inférieure au pas horaire du DAG.
|
# inférieure au pas horaire du DAG.
|
||||||
NOMBRE_REPRISES = 2
|
NOMBRE_REPRISES = 2
|
||||||
@@ -30,7 +26,11 @@ PLAFOND_PAR_TENTATIVE = timedelta(minutes=10)
|
|||||||
|
|
||||||
# L'intervalle est déclaré explicitement pour ne pas dépendre de la valeur du paramètre Airflow
|
# L'intervalle est déclaré explicitement pour ne pas dépendre de la valeur du paramètre Airflow
|
||||||
# `create_cron_data_intervals`. Le déclenchement à :45 laisse quinze minutes avant `ml_score`,
|
# `create_cron_data_intervals`. Le déclenchement à :45 laisse quinze minutes avant `ml_score`,
|
||||||
# exécuté à l'heure pile, puis avant `alertes`, exécuté à :15.
|
# exécuté à l'heure pile, puis avant `alertes`, exécuté à :15. La fenêtre demandée à l'API Mock
|
||||||
|
# (voir `bash_command` ci-dessous) ne suit pas cet intervalle Airflow tel quel : elle part de
|
||||||
|
# l'heure pile qui précède le déclenchement, pas de `data_interval_start`, pour que l'unique
|
||||||
|
# lecture demandée (`app.etl.mock_api_import.limit_for_window()`) atterrisse à :00 et non à :45
|
||||||
|
# (vérifié empiriquement sur l'API Mock), au pas horaire du reste du schéma, cf. 40-data.md.
|
||||||
PLANIFICATION = CronTriggerTimetable(
|
PLANIFICATION = CronTriggerTimetable(
|
||||||
"45 * * * *",
|
"45 * * * *",
|
||||||
timezone="UTC",
|
timezone="UTC",
|
||||||
@@ -51,9 +51,16 @@ with DAG(
|
|||||||
task_id="import_mock_api",
|
task_id="import_mock_api",
|
||||||
bash_command=(
|
bash_command=(
|
||||||
f"{COMMANDE_BACKEND} app.etl.mock_api_import "
|
f"{COMMANDE_BACKEND} app.etl.mock_api_import "
|
||||||
"--start-time \"{{ data_interval_start.strftime('%Y-%m-%dT%H:%M:%S') }}\" "
|
# `--start-time` part de l'heure pile qui précède le déclenchement, pas de
|
||||||
"--end-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:%M:%S') }}\" "
|
# `data_interval_start` : sur `[:45, :45)`, l'API aurait placé son unique lecture
|
||||||
f"--limit {LIMITE_LECTURES}"
|
# à :45, hors de la grille horaire du reste du schéma (vérifié empiriquement).
|
||||||
|
"--start-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:00:00') }}\" "
|
||||||
|
"--end-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:%M:%S') }}\""
|
||||||
|
# Pas de --limit : app.etl.mock_api_import.limit_for_window() le dérive de la
|
||||||
|
# fenêtre (ici plus courte qu'une heure, donc une seule lecture, ancrée sur
|
||||||
|
# --start-time) et refuse une fenêtre qui ne démarre pas pile sur l'heure. Porter
|
||||||
|
# la règle dans le code, pas dans ce DAG, évite qu'un appel manuel oublie de la
|
||||||
|
# respecter.
|
||||||
),
|
),
|
||||||
retries=NOMBRE_REPRISES,
|
retries=NOMBRE_REPRISES,
|
||||||
retry_delay=DELAI_ENTRE_REPRISES,
|
retry_delay=DELAI_ENTRE_REPRISES,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -113,12 +115,13 @@ def test_mock_api_import_calls_the_existing_backend_module(dagbag: DagBag) -> No
|
|||||||
assert "app.etl.mock_api_import" in commande
|
assert "app.etl.mock_api_import" in commande
|
||||||
|
|
||||||
|
|
||||||
def test_mock_api_import_uses_the_airflow_data_interval(dagbag: DagBag) -> None:
|
def test_mock_api_import_asks_for_the_on_the_hour_reading(dagbag: DagBag) -> None:
|
||||||
commande = dagbag.dags["mock_api_import"].get_task("import_mock_api").bash_command
|
commande = dagbag.dags["mock_api_import"].get_task("import_mock_api").bash_command
|
||||||
|
|
||||||
assert "--start-time \"{{ data_interval_start.strftime('%Y-%m-%dT%H:%M:%S') }}\"" in commande
|
assert "--start-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:00:00') }}\"" in commande
|
||||||
assert "--end-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:%M:%S') }}\"" in commande
|
assert "--end-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:%M:%S') }}\"" in commande
|
||||||
assert "--limit 1000" in commande
|
# Pas de --limit : app.etl.mock_api_import.limit_for_window() le dérive de la fenêtre.
|
||||||
|
assert "--limit" not in commande
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("task_id", ["detection", "recommandations"])
|
@pytest.mark.parametrize("task_id", ["detection", "recommandations"])
|
||||||
@@ -227,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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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
|
||||||
@@ -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 = ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,9 +47,15 @@ NUMERIC_COLUMNS = [
|
|||||||
# `bool` : `astype(bool)` ferait un `True` d'une absence, et les deux chargeurs divergeraient.
|
# `bool` : `astype(bool)` ferait un `True` d'une absence, et les deux chargeurs divergeraient.
|
||||||
FLAG_COLUMNS = ["is_working_hours"]
|
FLAG_COLUMNS = ["is_working_hours"]
|
||||||
|
|
||||||
|
# `uq_reading_source` autorise deux lignes au meme (site_id, timestamp) des que `source` differe
|
||||||
|
# (cf. `app/etl/mock_api_import.py`, qui refuse desormais d'importer une fenetre deja couverte par
|
||||||
|
# le CSV, mais ne protege pas le sens inverse). `build_features` suppose une ligne par
|
||||||
|
# (site_id, timestamp) sans doublon : le `DISTINCT ON` l'impose plutot que de la supposer.
|
||||||
|
# 'csv' gagne sur 'api_history' en cas de recouvrement, l'historique etant une source verifiee
|
||||||
|
# alors que l'API Mock est traitee comme une entree hostile (cf. OWASP API10).
|
||||||
_READING_QUERY = text(
|
_READING_QUERY = text(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT DISTINCT ON (r.site_id, r.timestamp)
|
||||||
r.site_id,
|
r.site_id,
|
||||||
r.timestamp,
|
r.timestamp,
|
||||||
r.consumption_kwh,
|
r.consumption_kwh,
|
||||||
@@ -61,14 +67,14 @@ _READING_QUERY = text(
|
|||||||
s.capacity_kw
|
s.capacity_kw
|
||||||
FROM reading r
|
FROM reading r
|
||||||
JOIN site s ON s.site_id = r.site_id
|
JOIN site s ON s.site_id = r.site_id
|
||||||
ORDER BY r.site_id, r.timestamp
|
ORDER BY r.site_id, r.timestamp, (r.source = 'csv') DESC, r.reading_id DESC
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_RECENT_READING_QUERY = text(
|
_RECENT_READING_QUERY = text(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT DISTINCT ON (r.site_id, r.timestamp)
|
||||||
r.site_id,
|
r.site_id,
|
||||||
r.timestamp,
|
r.timestamp,
|
||||||
r.consumption_kwh,
|
r.consumption_kwh,
|
||||||
@@ -81,7 +87,7 @@ _RECENT_READING_QUERY = text(
|
|||||||
FROM reading r
|
FROM reading r
|
||||||
JOIN site s ON s.site_id = r.site_id
|
JOIN site s ON s.site_id = r.site_id
|
||||||
WHERE r.timestamp >= :since AND r.timestamp <= :until
|
WHERE r.timestamp >= :since AND r.timestamp <= :until
|
||||||
ORDER BY r.site_id, r.timestamp
|
ORDER BY r.site_id, r.timestamp, (r.source = 'csv') DESC, r.reading_id DESC
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+38
-5
@@ -16,7 +16,7 @@ from collections.abc import Iterator
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import lightgbm as lgb
|
import lightgbm as lgb
|
||||||
@@ -44,20 +44,30 @@ _INSERT_SITE = text(
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
# `source = 'api_history'` impose `dataset_id IS NULL` (ck_reading_dataset_source), ce qui evite
|
# `source = 'api_history'` impose `dataset_id IS NULL` (ck_reading_dataset_source) : le defaut
|
||||||
# de creer une ligne `dataset`. `raw_data` est NOT NULL, d'ou le litteral jsonb.
|
# `dataset_id=None` evite de creer une ligne `dataset` pour la plupart des tests. `source='csv'`
|
||||||
|
# impose l'inverse, d'ou `insere_dataset()` quand un test a besoin de cette source precise.
|
||||||
|
# `raw_data` est NOT NULL, d'ou le litteral jsonb.
|
||||||
_INSERT_READING = text(
|
_INSERT_READING = text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO reading (
|
INSERT INTO reading (
|
||||||
site_id, timestamp, source, consumption_kwh, temperature_celsius,
|
site_id, timestamp, source, dataset_id, consumption_kwh, temperature_celsius,
|
||||||
humidity_percent, solar_irradiance_wm2, is_working_hours, raw_data
|
humidity_percent, solar_irradiance_wm2, is_working_hours, raw_data
|
||||||
) VALUES (
|
) VALUES (
|
||||||
:site_id, :timestamp, :source, :consumption_kwh, :temperature_celsius,
|
:site_id, :timestamp, :source, :dataset_id, :consumption_kwh, :temperature_celsius,
|
||||||
:humidity_percent, :solar_irradiance_wm2, :is_working_hours, '{}'::jsonb
|
:humidity_percent, :solar_irradiance_wm2, :is_working_hours, '{}'::jsonb
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_INSERT_DATASET = text(
|
||||||
|
"""
|
||||||
|
INSERT INTO dataset (dataset_name, archive_sha256, storage_uri, source_timezone, metadata)
|
||||||
|
VALUES (:dataset_name, :archive_sha256, :storage_uri, 'UTC', '{}'::jsonb)
|
||||||
|
RETURNING dataset_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
_SELECT_PREDICTIONS = text(
|
_SELECT_PREDICTIONS = text(
|
||||||
"""
|
"""
|
||||||
SELECT target_at, predicted_value, status, failure_reason, model_reference
|
SELECT target_at, predicted_value, status, failure_reason, model_reference
|
||||||
@@ -111,6 +121,25 @@ def insere_site(
|
|||||||
return site_id
|
return site_id
|
||||||
|
|
||||||
|
|
||||||
|
def insere_dataset(connexion: Connection) -> int:
|
||||||
|
"""Ligne `dataset` minimale, requise pour inserer une lecture `source='csv'`
|
||||||
|
|
||||||
|
(`ck_reading_dataset_source` impose `dataset_id IS NOT NULL` pour cette seule source).
|
||||||
|
"""
|
||||||
|
marque = uuid4().hex
|
||||||
|
return cast(
|
||||||
|
int,
|
||||||
|
connexion.execute(
|
||||||
|
_INSERT_DATASET,
|
||||||
|
{
|
||||||
|
"dataset_name": f"jeu de test {marque}",
|
||||||
|
"archive_sha256": marque.rjust(64, "0"),
|
||||||
|
"storage_uri": f"file:///test/{marque}.csv",
|
||||||
|
},
|
||||||
|
).scalar_one(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def insere_lectures(
|
def insere_lectures(
|
||||||
connexion: Connection,
|
connexion: Connection,
|
||||||
site_id: str,
|
site_id: str,
|
||||||
@@ -119,6 +148,7 @@ def insere_lectures(
|
|||||||
fin: datetime,
|
fin: datetime,
|
||||||
valeur: float = 50.0,
|
valeur: float = 50.0,
|
||||||
source: str = "api_history",
|
source: str = "api_history",
|
||||||
|
dataset_id: int | None = None,
|
||||||
is_working_hours: bool | None = True,
|
is_working_hours: bool | None = True,
|
||||||
) -> list[datetime]:
|
) -> list[datetime]:
|
||||||
"""Grille horaire contigue finissant a `fin`, incluse.
|
"""Grille horaire contigue finissant a `fin`, incluse.
|
||||||
@@ -134,6 +164,7 @@ def insere_lectures(
|
|||||||
"site_id": site_id,
|
"site_id": site_id,
|
||||||
"timestamp": instant,
|
"timestamp": instant,
|
||||||
"source": source,
|
"source": source,
|
||||||
|
"dataset_id": dataset_id,
|
||||||
"consumption_kwh": valeur + math.sin(rang / 12.0) * 10.0,
|
"consumption_kwh": valeur + math.sin(rang / 12.0) * 10.0,
|
||||||
"temperature_celsius": 15.0,
|
"temperature_celsius": 15.0,
|
||||||
"humidity_percent": 50.0,
|
"humidity_percent": 50.0,
|
||||||
@@ -153,6 +184,7 @@ def insere_lecture(
|
|||||||
instant: datetime,
|
instant: datetime,
|
||||||
consumption_kwh: float | None = 50.0,
|
consumption_kwh: float | None = 50.0,
|
||||||
source: str = "api_history",
|
source: str = "api_history",
|
||||||
|
dataset_id: int | None = None,
|
||||||
is_working_hours: bool | None = True,
|
is_working_hours: bool | None = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Une lecture isolee, quand le test pilote sa valeur plutot que sa forme."""
|
"""Une lecture isolee, quand le test pilote sa valeur plutot que sa forme."""
|
||||||
@@ -162,6 +194,7 @@ def insere_lecture(
|
|||||||
"site_id": site_id,
|
"site_id": site_id,
|
||||||
"timestamp": instant,
|
"timestamp": instant,
|
||||||
"source": source,
|
"source": source,
|
||||||
|
"dataset_id": dataset_id,
|
||||||
"consumption_kwh": consumption_kwh,
|
"consumption_kwh": consumption_kwh,
|
||||||
"temperature_celsius": 15.0,
|
"temperature_celsius": 15.0,
|
||||||
"humidity_percent": 50.0,
|
"humidity_percent": 50.0,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from enervision_ml.data import (
|
|||||||
load_from_database,
|
load_from_database,
|
||||||
load_recent_from_database,
|
load_recent_from_database,
|
||||||
)
|
)
|
||||||
from tests.conftest import ANCRAGE, insere_lecture, insere_lectures, insere_site
|
from tests.conftest import ANCRAGE, insere_dataset, insere_lecture, insere_lectures, insere_site
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
@@ -39,6 +39,65 @@ def test_load_from_database_joins_the_site_attributes_to_every_reading(
|
|||||||
assert set(mien["capacity_kw"]) == {250.0}
|
assert set(mien["capacity_kw"]) == {250.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_from_database_deduplicates_two_sources_at_the_same_instant(
|
||||||
|
connexion_ml: Connection,
|
||||||
|
) -> None:
|
||||||
|
# `uq_reading_source` autorise deux lignes au meme (site_id, timestamp) des que `source`
|
||||||
|
# differe : le garde-fou vit dans `mock_api_import.py`, pas dans le schema. Le chargeur ML
|
||||||
|
# doit donc imposer lui-meme "une ligne par (site_id, timestamp)", pas la supposer.
|
||||||
|
#
|
||||||
|
# `csv` est inseree en premier (reading_id le plus bas) et `api_history` en second (le plus
|
||||||
|
# haut) : un depart par `reading_id DESC` seul choisirait `api_history` a tort. Seule la
|
||||||
|
# preference explicite pour `source='csv'` fait gagner le bon reading_id ici, et le test
|
||||||
|
# cesserait de proteger cette regle si l'ordre d'insertion etait inverse.
|
||||||
|
site_id = insere_site(connexion_ml)
|
||||||
|
dataset_id = insere_dataset(connexion_ml)
|
||||||
|
insere_lecture(
|
||||||
|
connexion_ml,
|
||||||
|
site_id,
|
||||||
|
instant=ANCRAGE,
|
||||||
|
consumption_kwh=99.0,
|
||||||
|
source="csv",
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
)
|
||||||
|
insere_lecture(
|
||||||
|
connexion_ml, site_id, instant=ANCRAGE, consumption_kwh=10.0, source="api_history"
|
||||||
|
)
|
||||||
|
|
||||||
|
frame = load_from_database(connexion_ml)
|
||||||
|
|
||||||
|
mien = frame[frame["site_id"] == site_id]
|
||||||
|
assert len(mien) == 1
|
||||||
|
assert mien["consumption_kwh"].iloc[0] == 99.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_recent_from_database_prefers_csv_when_two_sources_share_an_instant(
|
||||||
|
connexion_ml: Connection,
|
||||||
|
) -> None:
|
||||||
|
# Meme ordre d'insertion que ci-dessus, et pour la meme raison : `csv` doit gagner malgre un
|
||||||
|
# `reading_id` plus bas que celui d'`api_history`.
|
||||||
|
site_id = insere_site(connexion_ml)
|
||||||
|
dataset_id = insere_dataset(connexion_ml)
|
||||||
|
insere_lecture(
|
||||||
|
connexion_ml,
|
||||||
|
site_id,
|
||||||
|
instant=ANCRAGE,
|
||||||
|
consumption_kwh=99.0,
|
||||||
|
source="csv",
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
)
|
||||||
|
insere_lecture(
|
||||||
|
connexion_ml, site_id, instant=ANCRAGE, consumption_kwh=10.0, source="api_history"
|
||||||
|
)
|
||||||
|
|
||||||
|
frame = load_recent_from_database(
|
||||||
|
connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(frame) == 1
|
||||||
|
assert frame["consumption_kwh"].iloc[0] == 99.0
|
||||||
|
|
||||||
|
|
||||||
def test_load_recent_from_database_excludes_readings_before_the_since_bound(
|
def test_load_recent_from_database_excludes_readings_before_the_since_bound(
|
||||||
connexion_ml: Connection,
|
connexion_ml: Connection,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Executable
+234
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ test('impose le changement du mot de passe temporaire avant le tableau de bord',
|
|||||||
await expect(page).toHaveURL(/\/change-password$/);
|
await expect(page).toHaveURL(/\/change-password$/);
|
||||||
await expect(page.getByRole('heading', { name: 'Nouveau mot de passe' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Nouveau mot de passe' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByLabel('Mot de passe actuel').fill(compte.password);
|
await expect(page.getByLabel('Mot de passe actuel')).toHaveCount(0);
|
||||||
await page.getByLabel('Nouveau mot de passe').fill(nouveauMotDePasse());
|
await page.getByLabel('Nouveau mot de passe').fill(nouveauMotDePasse());
|
||||||
await page.getByRole('button', { name: 'Valider' }).click();
|
await page.getByRole('button', { name: 'Valider' }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user