Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dc59df1de | ||
|
|
79cafb8e1a | ||
|
|
5e06b08546 | ||
|
|
931ec9e527 | ||
|
|
c8eddbc02b | ||
|
|
68fc1052fb | ||
|
|
0a7dd2e692 | ||
|
|
45cc67266d | ||
|
|
182a2f4a6c | ||
|
|
7ecc0b2e64 | ||
|
|
9ee0de9d55 | ||
|
|
9f343e9f42 | ||
|
|
5a7e2a94a7 | ||
|
|
f011fce84e | ||
|
|
7730f184e7 | ||
|
|
fa815f49b6 | ||
|
|
0462dd01ba | ||
|
|
e53c7e441c | ||
|
|
0a7a802b38 | ||
|
|
e0089537d0 | ||
|
|
6b41ec900c | ||
|
|
49175d8ff7 | ||
|
|
da7fc52299 | ||
|
|
5afe2fc88e | ||
|
|
6c09beeb3c | ||
|
|
14eed08ff5 | ||
|
|
1232646f68 | ||
|
|
cbbfaf4910 | ||
|
|
b78322bd61 | ||
|
|
101ebd404f | ||
|
|
32f1bef643 | ||
|
|
b00c39277b | ||
|
|
c2f360c591 | ||
|
|
7f4364df77 | ||
|
|
933f0a3360 |
+30
-6
@@ -52,21 +52,45 @@ 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
|
||||||
ACME_EMAIL=
|
ACME_EMAIL=
|
||||||
|
|
||||||
# Deux environnements sur la même machine (ADR 0009) : un dossier, un `.env` et un projet Compose
|
# Trois environnements sur la même machine (ADR 0009, 0017) : un dossier, un `.env` et un projet
|
||||||
# chacun. Le nom de projet préfixe volumes, réseau et conteneurs et l'emporte sur `name:`.
|
# Compose chacun. Le nom de projet préfixe volumes, réseau et conteneurs et l'emporte sur `name:`.
|
||||||
# Vide sur un poste de développement : le projet reste `enervision`.
|
# Vide sur un poste de développement : le projet reste `enervision`.
|
||||||
COMPOSE_PROJECT_NAME=
|
COMPOSE_PROJECT_NAME=
|
||||||
# Origine publique, avec le port si le proxy HTTPS n'écoute pas 443. Vide : https://PUBLIC_HOST.
|
# Origine publique, avec le port si le proxy HTTPS n'écoute pas 443. Vide : https://PUBLIC_HOST.
|
||||||
# Recette : PUBLIC_HOST=rec.enervision.local et PUBLIC_ORIGIN=https://rec.enervision.local:8443.
|
# Sur la VM, provision-host.sh pose https://<nom de l'environnement>, sans port (frontal SNI).
|
||||||
PUBLIC_ORIGIN=
|
PUBLIC_ORIGIN=
|
||||||
# Ports publiés par le proxy. Vides : 80 et 443. Recette : PROXY_HTTPS_PORT=8443 et
|
# Ports publiés par le proxy. Vides : 80 et 443. Sur la VM, provision-host.sh les pose sur 127.0.0.1,
|
||||||
# PROXY_HTTP_PORT=127.0.0.1:8081, la redirection vers 443 n'ayant pas à être joignable de
|
# derrière le frontal SNI, et décale aussi base, Mailpit et Airflow par environnement.
|
||||||
# l'extérieur. Décaler aussi POSTGRES_PORT, MAILPIT_UI_PORT et AIRFLOW_PORT (5434, 8026, 8082).
|
|
||||||
PROXY_HTTP_PORT=
|
PROXY_HTTP_PORT=
|
||||||
PROXY_HTTPS_PORT=
|
PROXY_HTTPS_PORT=
|
||||||
# Écouteur PROXY protocol du proxy, que seul le frontal de la VM joint (infra/front, ADR 0018).
|
# Écouteur PROXY protocol du proxy, que seul le frontal de la VM joint (infra/front, ADR 0018).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -16,25 +16,27 @@ series temporelles energetiques, deployee sur une machine on-premise.
|
|||||||
|
|
||||||
Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.md](docs/architecture/00-vue-ensemble.md).
|
Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.md](docs/architecture/00-vue-ensemble.md).
|
||||||
|
|
||||||
## Stack cible
|
## Stack
|
||||||
|
|
||||||
| Domaine | Technologie | Emplacement | Etat |
|
| Domaine | Technologie | Emplacement | Etat |
|
||||||
|------------|-------------------------------------|---------------------|---------------|
|
|------------|-------------------------------------|---------------------|---------------|
|
||||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | En place |
|
| Backend | FastAPI, Python 3.14 | `apps/backend` | En place |
|
||||||
| Frontend | Angular 22, Node 26 | `apps/frontend` | En place |
|
| Frontend | Angular 22, Node 26 | `apps/frontend` | En place |
|
||||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | En place |
|
| Base | PostgreSQL 17 + TimescaleDB | `db` | En place |
|
||||||
| ETL | Apache Airflow | `etl/airflow` | Cinq DAGs |
|
| ETL | Apache Airflow | `etl/airflow` | Sept DAGs |
|
||||||
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
|
| Infra | Terraform (VM ENI ; module k3s) | `infra/terraform` | VM appliquée, k3s écrit non appliqué |
|
||||||
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
|
| Reverse proxy | Nginx, TLS, frontal SNI | `infra/proxy`, `infra/front` | En place, certificats Let's Encrypt |
|
||||||
| 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 |
|
||||||
|
|
||||||
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend
|
Toutes ces briques tournent sur la machine du groupe, en trois environnements (production,
|
||||||
sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints
|
recette, dev). Le frontend sert le tableau de bord, les vues sites, recommandations et
|
||||||
correspondants restent à écrire côté API. Les autres dossiers portent l'arborescence et un README
|
supervision des capteurs, toutes branchées sur l'API réelle : les fixtures sont coupées
|
||||||
de cadrage, leur contenu fait l'objet d'un ticket dedie.
|
(`useMockFixtures: false`). Le module Terraform k3s reste une cible, écrite et validée, jamais
|
||||||
|
appliquée.
|
||||||
|
|
||||||
L'etat detaille de chaque brique et les vues d'architecture sont dans
|
L'etat detaille de chaque brique et les vues d'architecture sont dans
|
||||||
[docs/architecture](docs/architecture/README.md).
|
[docs/architecture](docs/architecture/README.md).
|
||||||
@@ -52,11 +54,13 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
|
|||||||
│ ├── roles/ Roles PostgreSQL hors schema (supervision)
|
│ ├── roles/ Roles PostgreSQL hors schema (supervision)
|
||||||
│ └── seeds/ Jeu de demonstration des tests
|
│ └── seeds/ Jeu de demonstration des tests
|
||||||
├── etl/airflow/
|
├── etl/airflow/
|
||||||
│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, imports, dérive)
|
│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, imports, dérive, rétention)
|
||||||
│ ├── plugins/ Operateurs et hooks maison
|
│ ├── plugins/ Operateurs et hooks maison
|
||||||
│ ├── 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/
|
||||||
|
│ ├── front/ Frontal SNI de la machine : ports 80 et 443, aiguillage par nom
|
||||||
|
│ ├── 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,14 +72,15 @@ 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, vues d'architecture, runbook de pilotage, livrables de rendu
|
||||||
└── scripts/ Outillage local
|
└── scripts/ Outillage local
|
||||||
```
|
```
|
||||||
|
|
||||||
## Demarrage
|
## Demarrage
|
||||||
|
|
||||||
Prerequis : uv, Docker, Node 24 LTS (npm fourni). Le poste doit disposer de Python 3.14, que
|
Prerequis : uv, Docker, Node 26 (version de la CI et de l'image frontend, npm fourni). Le poste doit disposer de Python 3.14, que
|
||||||
`uv` installe seul.
|
`uv` installe seul.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -101,7 +106,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`.
|
||||||
@@ -143,16 +150,19 @@ L'overlay emploie `!override` et `!reset`, donc **Docker Compose 2.24.4 ou plus
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
make tls-selfsigned PUBLIC_HOST=enervision.local # certificat de démonstration
|
make tls-selfsigned PUBLIC_HOST=enervision.local # certificat de démonstration
|
||||||
make stack-up PUBLIC_HOST=enervision.local # nginx en 80/443, rien d'autre n'est publié
|
make stack-up PUBLIC_HOST=enervision.local # nginx en 80/443, le reste sur 127.0.0.1
|
||||||
```
|
```
|
||||||
|
|
||||||
Le navigateur avertit d'un émetteur inconnu : Let's Encrypt reste hors d'atteinte tant qu'aucun
|
Le navigateur avertit d'un émetteur inconnu : sur le poste, le certificat est auto-signé. Sur la
|
||||||
nom de domaine public ne résout vers la machine. Routage, mode ACME et renouvellement dans
|
machine, les certificats viennent de Let's Encrypt par défi DNS-01
|
||||||
|
([ADR 0018](docs/adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md)). Routage, mode ACME et renouvellement dans
|
||||||
[`infra/proxy/README.md`](infra/proxy/README.md) ; la décision et ses motifs dans
|
[`infra/proxy/README.md`](infra/proxy/README.md) ; la décision et ses motifs dans
|
||||||
[l'ADR 0007](docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
|
[l'ADR 0007](docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
|
||||||
|
|
||||||
Sur la VM ENI, deux environnements cohabitent, recette sur `dev` et production sur `main`,
|
Sur la VM ENI, trois environnements cohabitent, production sur `main`, recette sur `dev`, et
|
||||||
chacun dans son dossier et son projet Compose : `scripts/provision-host.sh` les prépare, le
|
`dev` pour toute autre branche lancée à la main
|
||||||
|
([ADR 0017](docs/adr/0017-environnement-dev-a-la-demande.md)), chacun dans son dossier et son
|
||||||
|
projet Compose, derrière un frontal SNI commun : `scripts/provision-host.sh` les prépare, le
|
||||||
workflow `deploy.yml` les redéploie par un runner auto-hébergé, une fois la CI du commit poussé
|
workflow `deploy.yml` les redéploie par un runner auto-hébergé, une fois la CI du commit poussé
|
||||||
verte ([ADR 0014](docs/adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md)). Ports, noms
|
verte ([ADR 0014](docs/adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md)). Ports, noms
|
||||||
d'hôte et garde-fous dans [`docs/architecture/10-infra.md`](docs/architecture/10-infra.md) et
|
d'hôte et garde-fous dans [`docs/architecture/10-infra.md`](docs/architecture/10-infra.md) et
|
||||||
|
|||||||
@@ -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,13 +363,68 @@ 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)
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
str(settings.database_url),
|
||||||
|
pool_pre_ping=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
async with create_mock_api_client() as client:
|
||||||
sites = await fetch_sites(client)
|
sites = await fetch_sites(client)
|
||||||
@@ -338,12 +454,6 @@ async def import_mock_api_history(
|
|||||||
print("Dry-run terminé : aucune donnée écrite.")
|
print("Dry-run terminé : aucune donnée écrite.")
|
||||||
return
|
return
|
||||||
|
|
||||||
engine = create_async_engine(
|
|
||||||
str(settings.database_url),
|
|
||||||
pool_pre_ping=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
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"
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ it('devrait faire X quand Y', () => {
|
|||||||
## Ce qui doit être testé en priorité
|
## Ce qui doit être testé en priorité
|
||||||
- Services (`core/services/`) : logique métier, gestion des erreurs
|
- Services (`core/services/`) : logique métier, gestion des erreurs
|
||||||
- Guards et interceptors (`core/guards/`, `core/interceptors/`) : chaque branche de décision
|
- Guards et interceptors (`core/guards/`, `core/interceptors/`) : chaque branche de décision
|
||||||
- Composants avec logique (formulaires, conditions d'affichage) — pas nécessaire pour
|
- Composants avec logique (formulaires, conditions d'affichage), mais pas nécessaire pour
|
||||||
un composant 100% template, sans logique
|
un composant 100% template, sans logique
|
||||||
|
|
||||||
`core/services/`, `core/guards/` et `core/interceptors/` n'existent pas encore : c'est
|
`core/services/`, `core/guards/` et `core/interceptors/` n'existent pas encore : c'est
|
||||||
l'arborescence cible, décrite dans
|
l'arborescence cible, décrite dans
|
||||||
[docs/architecture/30-frontend.md](../../docs/architecture/30-frontend.md).
|
[docs/architecture/30-frontend.md](../../docs/architecture/30-frontend.md).
|
||||||
|
|
||||||
## Gabarit — tester un service avec appel HTTP
|
## Gabarit · tester un service avec appel HTTP
|
||||||
```typescript
|
```typescript
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
@@ -61,7 +61,7 @@ describe('MonService', () => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Gabarit — tester un composant standalone
|
## Gabarit · tester un composant standalone
|
||||||
```typescript
|
```typescript
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { MonComposant } from './mon-composant';
|
import { MonComposant } from './mon-composant';
|
||||||
|
|||||||
@@ -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,6 +7,9 @@
|
|||||||
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>
|
||||||
|
|
||||||
|
<input hidden type="email" autocomplete="username" [value]="email" readonly />
|
||||||
|
|
||||||
|
@if (asksCurrentPassword()) {
|
||||||
<label class="form-label" for="current_password">Mot de passe actuel</label>
|
<label class="form-label" for="current_password">Mot de passe actuel</label>
|
||||||
<input
|
<input
|
||||||
id="current_password"
|
id="current_password"
|
||||||
@@ -15,6 +18,7 @@
|
|||||||
formControlName="current_password"
|
formControlName="current_password"
|
||||||
autocomplete="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,19 +68,68 @@ 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);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(champActuel(fixture)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('réutilise le mot de passe provisoire de la connexion sans le redemander', () => {
|
||||||
|
authMock.takeProvisionalPassword.mockReturnValue('Provisoire-24-caracteres');
|
||||||
|
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
||||||
const fixture = TestBed.createComponent(ChangePassword);
|
const fixture = TestBed.createComponent(ChangePassword);
|
||||||
const component = fixture.componentInstance;
|
const component = fixture.componentInstance;
|
||||||
component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
|
fixture.detectChanges();
|
||||||
|
|
||||||
authMock.changePassword.mockReturnValue(throwError(() => new Error('401')));
|
expect(champActuel(fixture)).toBeNull();
|
||||||
|
component.form.controls.new_password.setValue(NOUVEAU);
|
||||||
|
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();
|
component.onSubmit();
|
||||||
fixture.detectChanges(); // rend le bloc @if (errorMessage())
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(component.errorMessage()).toContain('incorrect');
|
expect(component.errorMessage()).toContain('Mot de passe actuel incorrect');
|
||||||
const errorEl = fixture.nativeElement.querySelector('.ev-alert');
|
expect(fixture.nativeElement.querySelector('.ev-alert')?.textContent).toContain('incorrect');
|
||||||
expect(errorEl?.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', () => {
|
||||||
@@ -79,7 +144,7 @@ describe('ChangePassword', () => {
|
|||||||
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' } }));
|
||||||
@@ -90,8 +155,7 @@ describe('ChangePassword', () => {
|
|||||||
|
|
||||||
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.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
# moyen de dépublier 8000 et 3000 : sans lui, l'API resterait joignable en clair à côté du proxy.
|
# moyen de dépublier 8000 et 3000 : sans lui, l'API resterait joignable en clair à côté du proxy.
|
||||||
# Piège : pas de `:?` sur `PUBLIC_HOST`. Compose interpole tout le fichier, y compris pour
|
# Piège : pas de `:?` sur `PUBLIC_HOST`. Compose interpole tout le fichier, y compris pour
|
||||||
# `stop` et `logs` : la garde vit dans `make stack-up`, qui la compare au certificat servi.
|
# `stop` et `logs` : la garde vit dans `make stack-up`, qui la compare au certificat servi.
|
||||||
# Pourquoi : ports du proxy et origine publique en variables, pour que deux environnements
|
# Pourquoi : ports du proxy et origine publique en variables, pour que trois environnements
|
||||||
# cohabitent sur la même machine, chacun dans son projet Compose (ADR 0009).
|
# cohabitent sur la même machine, chacun dans son projet Compose (ADR 0009, 0017).
|
||||||
|
|
||||||
name: enervision
|
name: enervision
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+10
-1
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
- `adr` : décisions d'architecture, une par fichier, numérotées et immuables.
|
- `adr` : décisions d'architecture, une par fichier, numérotées et immuables.
|
||||||
- `architecture` : les vues du système. Point d'entrée : [architecture/README.md](architecture/README.md).
|
- `architecture` : les vues du système. Point d'entrée : [architecture/README.md](architecture/README.md).
|
||||||
|
Le pilotage des traitements automatisés a son runbook :
|
||||||
|
[architecture/70-pilotage.md](architecture/70-pilotage.md).
|
||||||
|
- `livrables` : rapports de rendu, le rapport collectif EC02 et le rapport de sécurisation EC04
|
||||||
|
avec ses preuves.
|
||||||
|
- `dailies` : points d'avancement versionnés.
|
||||||
|
|
||||||
## Décisions en vigueur
|
## Décisions en vigueur
|
||||||
|
|
||||||
@@ -15,7 +20,7 @@
|
|||||||
| [0006](adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
|
| [0006](adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
|
||||||
| [0007](adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
|
| [0007](adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
|
||||||
| [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) | Un projet Compose par environnement sur la VM ENI, déployé par un runner auto-hébergé (deux environnements à l'origine, trois depuis l'ADR 0017) |
|
||||||
| [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 |
|
||||||
| [0011](adr/0011-enervision-procedure-deploiement.md) | Procédure de déploiement, telle qu'exécutée le 22/09/2026 |
|
| [0011](adr/0011-enervision-procedure-deploiement.md) | Procédure de déploiement, telle qu'exécutée le 22/09/2026 |
|
||||||
| [0012](adr/0012-enervision-deploiement-rec-prod-vm-eni.md) | État de la recette et de la production sur la VM ENI |
|
| [0012](adr/0012-enervision-deploiement-rec-prod-vm-eni.md) | État de la recette et de la production sur la VM ENI |
|
||||||
@@ -23,3 +28,7 @@
|
|||||||
| [0014](adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md) | Un pipeline CI unique appelle les workflows de composant et conditionne le déploiement |
|
| [0014](adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md) | Un pipeline CI unique appelle les workflows de composant et conditionne le déploiement |
|
||||||
| [0015](adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md) | Les tests de bout en bout et de charge visent la stack Compose déployée |
|
| [0015](adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md) | Les tests de bout en bout et de charge visent la stack Compose déployée |
|
||||||
| [0016](adr/0016-supervision-en-profil-compose.md) | La supervision vit dans un profil Compose, active en prod |
|
| [0016](adr/0016-supervision-en-profil-compose.md) | La supervision vit dans un profil Compose, active en prod |
|
||||||
|
| [0017](adr/0017-environnement-dev-a-la-demande.md) | Un troisième environnement, `dev`, déployé à la demande depuis n'importe quelle branche |
|
||||||
|
| [0018](adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md) | Noms publics, certificats Let's Encrypt par DNS-01 et frontal SNI sans port |
|
||||||
|
| [0019](adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md) | Stockage objet Garage par environnement, et cycle de vie des mesures : export puis suppression |
|
||||||
|
| [0020](adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md) | Chiffrement au repos : coffre LUKS des volumes Docker et SSE-C des archives |
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ contraintes non négociables cadrent le choix, discutées dans l'issue #89 :
|
|||||||
1. **EC06** (grille de notation individuelle) exige un modèle **entraîné, versionné avec
|
1. **EC06** (grille de notation individuelle) exige un modèle **entraîné, versionné avec
|
||||||
MLflow**, exposé via un endpoint fonctionnel, avec **surveillance du drift** en production.
|
MLflow**, exposé via un endpoint fonctionnel, avec **surveillance du drift** en production.
|
||||||
2. **Aucun GPU dédié** : l'infra tourne on-premise sur une VM à 4 CPU / 8 Gio RAM (ou
|
2. **Aucun GPU dédié** : l'infra tourne on-premise sur une VM à 4 CPU / 8 Gio RAM (ou
|
||||||
`Standard_B2s`/`B2ms` côté Azure, 2 vCPU max) — Azure Machine Learning est de toute façon
|
`Standard_B2s`/`B2ms` côté Azure, 2 vCPU max) ; Azure Machine Learning est de toute façon
|
||||||
bloqué par la politique Azure du projet.
|
bloqué par la politique Azure du projet.
|
||||||
3. **Délai serré** : le jalon J3 arrive à échéance le lendemain de la décision, J4 concentre déjà
|
3. **Délai serré** : le jalon J3 arrive à échéance le lendemain de la décision, J4 concentre déjà
|
||||||
26 issues sur 4 jours. Un modèle long à mettre en œuvre retarde la chaîne complète (service de
|
26 issues sur 4 jours. Un modèle long à mettre en œuvre retarde la chaîne complète (service de
|
||||||
@@ -32,7 +32,7 @@ déjà dérivées.
|
|||||||
| Régresseurs exogènes | Oui, mais doivent être connus dans le futur au moment de la prédiction | Oui, via lags/moyennes glissantes sur le passé | Oui, natif | Difficile en multivarié | Aucun support | Contexte de prompt seulement, non appris |
|
| Régresseurs exogènes | Oui, mais doivent être connus dans le futur au moment de la prédiction | Oui, via lags/moyennes glissantes sur le passé | Oui, natif | Difficile en multivarié | Aucun support | Contexte de prompt seulement, non appris |
|
||||||
| Coût de calcul (VM sans GPU) | Faible | Faible | Élevé (deep learning) | Faible | Faible | Élevé à prohibitif |
|
| Coût de calcul (VM sans GPU) | Faible | Faible | Élevé (deep learning) | Faible | Faible | Élevé à prohibitif |
|
||||||
| Versionnable MLflow | Oui, nativement | Oui, nativement | Pas de support direct | Oui, générique | Pas de support direct | Rien à versionner (pas un modèle entraîné) |
|
| Versionnable MLflow | Oui, nativement | Oui, nativement | Pas de support direct | Oui, générique | Pas de support direct | Rien à versionner (pas un modèle entraîné) |
|
||||||
| Granularité | Un modèle par site (ou par site × métrique) | Un seul modèle global sur tous les sites | Un par site | Un par site | Un par site | — |
|
| Granularité | Un modèle par site (ou par site × métrique) | Un seul modèle global sur tous les sites | Un par site | Un par site | Un par site | - |
|
||||||
| Effort avant l'échéance | Faible | Moyen (feature engineering) | Élevé | Moyen à élevé | Faible en soi | Élevé, ou factice |
|
| Effort avant l'échéance | Faible | Moyen (feature engineering) | Élevé | Moyen à élevé | Faible en soi | Élevé, ou factice |
|
||||||
|
|
||||||
## Décision
|
## Décision
|
||||||
@@ -40,7 +40,7 @@ déjà dérivées.
|
|||||||
**LightGBM, un seul modèle global** couvrant tous les sites, plutôt qu'un modèle par site
|
**LightGBM, un seul modèle global** couvrant tous les sites, plutôt qu'un modèle par site
|
||||||
(Prophet) ou par famille de site. Cible : `consumption_kwh`, avec `period_minutes` comme feature
|
(Prophet) ou par famille de site. Cible : `consumption_kwh`, avec `period_minutes` comme feature
|
||||||
d'entrée plutôt que comme étape d'agrégation post-prédiction. Suivi et versioning via **MLflow**
|
d'entrée plutôt que comme étape d'agrégation post-prédiction. Suivi et versioning via **MLflow**
|
||||||
(tracking + registre de modèles), sur le magasin local par défaut dans un premier temps —
|
(tracking + registre de modèles), sur le magasin local par défaut dans un premier temps ;
|
||||||
l'hébergement sur l'infra k3s reste une question ouverte, non bloquante pour démarrer.
|
l'hébergement sur l'infra k3s reste une question ouverte, non bloquante pour démarrer.
|
||||||
|
|
||||||
Raisons retenues, au-delà du tableau ci-dessus :
|
Raisons retenues, au-delà du tableau ci-dessus :
|
||||||
@@ -53,7 +53,7 @@ Raisons retenues, au-delà du tableau ci-dessus :
|
|||||||
`humidity_percent` et `solar_irradiance_wm2` sont des mesures passées, pas des prévisions, et
|
`humidity_percent` et `solar_irradiance_wm2` sont des mesures passées, pas des prévisions, et
|
||||||
aucune source de prévision météo n'existe dans le projet. LightGBM s'en sort avec des features
|
aucune source de prévision météo n'existe dans le projet. LightGBM s'en sort avec des features
|
||||||
de lag/moyenne glissante calculées sur l'historique déjà présent dans `reading`, cf.
|
de lag/moyenne glissante calculées sur l'historique déjà présent dans `reading`, cf.
|
||||||
`ml/enervision_ml/features.py` — un choix qui vaut aussi bien à l'entraînement qu'au futur
|
`ml/enervision_ml/features.py`, un choix qui vaut aussi bien à l'entraînement qu'au futur
|
||||||
scoring.
|
scoring.
|
||||||
- **Apprentissage direct sur `consumption_kwh`** avec `period_minutes` en feature, sans étape
|
- **Apprentissage direct sur `consumption_kwh`** avec `period_minutes` en feature, sans étape
|
||||||
d'agrégation intermédiaire que la sortie continue de Prophet aurait demandée.
|
d'agrégation intermédiaire que la sortie continue de Prophet aurait demandée.
|
||||||
@@ -92,9 +92,9 @@ ValentinDeFaria), actée en réunion d'équipe du 2026-09-17 et validée par l'e
|
|||||||
- **SARIMA** : ne gère pas nativement plusieurs régresseurs exogènes ; réglage (p,d,q,P,D,Q) plus
|
- **SARIMA** : ne gère pas nativement plusieurs régresseurs exogènes ; réglage (p,d,q,P,D,Q) plus
|
||||||
long que le délai disponible.
|
long que le délai disponible.
|
||||||
- **NeuralProphet** : fait tout ce que fait Prophet et apprend en plus des motifs autorégressifs,
|
- **NeuralProphet** : fait tout ce que fait Prophet et apprend en plus des motifs autorégressifs,
|
||||||
mais coûte plus cher en calcul (pas de GPU disponible) et n'a pas d'outil MLflow direct — piste
|
mais coûte plus cher en calcul (pas de GPU disponible) et n'a pas d'outil MLflow direct : piste
|
||||||
d'évolution possible, non engageante à ce stade.
|
d'évolution possible, non engageante à ce stade.
|
||||||
- **Holt-Winters** : écarté d'entrée, pas seulement différé — aucun support de régresseurs
|
- **Holt-Winters** : écarté d'entrée, pas seulement différé : aucun support de régresseurs
|
||||||
exogènes, alors que la météo et l'irradiance sont nécessaires ici.
|
exogènes, alors que la météo et l'irradiance sont nécessaires ici.
|
||||||
- **CatBoost** : même famille que LightGBM, gère nativement les colonnes catégorielles (comme
|
- **CatBoost** : même famille que LightGBM, gère nativement les colonnes catégorielles (comme
|
||||||
`site_type`) sans encodage manuel. Non rejeté, différé : candidat à comparer si LightGBM
|
`site_type`) sans encodage manuel. Non rejeté, différé : candidat à comparer si LightGBM
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
- Statut : accepté
|
- Statut : accepté
|
||||||
- Date : 2026-09-21
|
- Date : 2026-09-21
|
||||||
|
- Complété par : [ADR 0017](0017-environnement-dev-a-la-demande.md), troisième environnement `dev`
|
||||||
|
- Note du 24/09 : l'approbation annoncée avant la production n'a jamais été activée. L'environnement GitHub `prod` n'accepte que `main`, sans relecteur requis.
|
||||||
|
|
||||||
## Contexte
|
## Contexte
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# EnerVision · procédure de déploiement (22/09/2026)
|
# EnerVision · procédure de déploiement (22/09/2026)
|
||||||
|
|
||||||
Terraform provisionne la machine, GitHub Actions déploie (ADR 0010). Deux environnements Compose
|
Terraform provisionne la machine, GitHub Actions déploie (ADR 0010). Deux environnements Compose
|
||||||
sur la VM ENI `10.101.200.37` : `rec` sur la branche `dev`, `prod` sur `main` (ADR 0009).
|
sur la VM ENI `<IP-VM-G3>` : `rec` sur la branche `dev`, `prod` sur `main` (ADR 0009).
|
||||||
|
|
||||||
| | recette | production |
|
| | recette | production |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -13,7 +13,7 @@ sur la VM ENI `10.101.200.37` : `rec` sur la branche `dev`, `prod` sur `main` (A
|
|||||||
|
|
||||||
## 0. Avant toute commande
|
## 0. Avant toute commande
|
||||||
|
|
||||||
1. **Clé SSH déposée** sur la VM : `ssh-copy-id -i ~/.ssh/id_ed25519.pub root@10.101.200.37`.
|
1. **Clé SSH déposée** sur la VM : `ssh-copy-id -i ~/.ssh/id_ed25519.pub root@<IP-VM-G3>`.
|
||||||
Terraform ne gère **pas** l'authentification par mot de passe (elle finirait dans le state).
|
Terraform ne gère **pas** l'authentification par mot de passe (elle finirait dans le state).
|
||||||
2. **L'utilisateur propriétaire existe déjà** sur la VM (ex. `enervision`) : il possède
|
2. **L'utilisateur propriétaire existe déjà** sur la VM (ex. `enervision`) : il possède
|
||||||
`/srv/enervision` et fait tourner le runner. Terraform échoue tôt s'il manque, il ne le crée pas.
|
`/srv/enervision` et fait tourner le runner. Terraform échoue tôt s'il manque, il ne le crée pas.
|
||||||
@@ -42,7 +42,7 @@ runner_version = "2.330.0" # épingler depuis github.com/actions/runner/rel
|
|||||||
runner_token = "..." # jeton d'1 h, à retirer du fichier après l'apply
|
runner_token = "..." # jeton d'1 h, à retirer du fichier après l'apply
|
||||||
```
|
```
|
||||||
|
|
||||||
Défauts utiles : `ssh_host = "10.101.200.37"`, `ssh_user = "root"`,
|
Défauts utiles : `ssh_host = "<IP-VM-G3>"`, `ssh_user = "root"`,
|
||||||
`ssh_private_key_path = "~/.ssh/id_ed25519"`, `racine = "/srv/enervision"`,
|
`ssh_private_key_path = "~/.ssh/id_ed25519"`, `racine = "/srv/enervision"`,
|
||||||
`runner_labels = "eni-g3"` (ciblé par `deploy.yml`), `runner_dossier = "/opt/actions-runner"`.
|
`runner_labels = "eni-g3"` (ciblé par `deploy.yml`), `runner_dossier = "/opt/actions-runner"`.
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ curl -k https://localhost/api/v1/health/ready # production, sur la VM
|
|||||||
Depuis un poste, ajouter à `/etc/hosts` :
|
Depuis un poste, ajouter à `/etc/hosts` :
|
||||||
|
|
||||||
```
|
```
|
||||||
10.101.200.37 enervision.local rec.enervision.local
|
<IP-VM-G3> enervision.local rec.enervision.local
|
||||||
```
|
```
|
||||||
|
|
||||||
Les deux noms sont obligatoires : le cookie `__Secure-ev_refresh` est posé par hôte et non par
|
Les deux noms sont obligatoires : le cookie `__Secure-ev_refresh` est posé par hôte et non par
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# EnerVision · Recette et production sur la VM ENI, aujourd'hui
|
# EnerVision · Recette et production sur la VM ENI, aujourd'hui
|
||||||
|
|
||||||
État au lundi 21 septembre 2026, 15h. Cible : deux environnements qui tournent sur la VM
|
État au lundi 21 septembre 2026, 15h. Cible : deux environnements qui tournent sur la VM
|
||||||
`eadl-2025-nantes-g3` (`10.101.200.37`) avant vendredi 25/09 9h, déployés automatiquement depuis
|
`eadl-2025-nantes-g3` (`<IP-VM-G3>`) avant vendredi 25/09 9h, déployés automatiquement depuis
|
||||||
GitHub. Ce document donne la solution retenue, ce qu'elle change dans le dépôt, et le déroulé de
|
GitHub. Ce document donne la solution retenue, ce qu'elle change dans le dépôt, et le déroulé de
|
||||||
l'après-midi avec qui fait quoi.
|
l'après-midi avec qui fait quoi.
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ prod à chaque connexion sur la recette.
|
|||||||
- **Un projet Compose isole tout.** Volumes, réseau, noms de conteneurs sont préfixés par le nom
|
- **Un projet Compose isole tout.** Volumes, réseau, noms de conteneurs sont préfixés par le nom
|
||||||
du projet. Casser la recette ne touche pas la prod, ce qui est la raison d'être d'une recette.
|
du projet. Casser la recette ne touche pas la prod, ce qui est la raison d'être d'une recette.
|
||||||
- **Le runner sur la VM est la seule façon d'atteindre une IP privée d'école depuis GitHub.** Les
|
- **Le runner sur la VM est la seule façon d'atteindre une IP privée d'école depuis GitHub.** Les
|
||||||
runners hébergés par GitHub ne voient pas `10.101.200.37`. Le runner se connecte en sortie
|
runners hébergés par GitHub ne voient pas `<IP-VM-G3>`. Le runner se connecte en sortie
|
||||||
vers GitHub, aucun port entrant n'est nécessaire. C'était le choix 16 du dossier EC01 : il
|
vers GitHub, aucun port entrant n'est nécessaire. C'était le choix 16 du dossier EC01 : il
|
||||||
redevient tenu.
|
redevient tenu.
|
||||||
- **La promotion existe déjà dans la stratégie de branches** : `dev` puis `main` par PR. Le
|
- **La promotion existe déjà dans la stratégie de branches** : `dev` puis `main` par PR. Le
|
||||||
@@ -71,9 +71,9 @@ Ce qui ne change pas : `docker-compose.yml`, la configuration Nginx, `infra/terr
|
|||||||
| # | Qui | Quoi | Durée |
|
| # | Qui | Quoi | Durée |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1 | **ineszang** (seule admin du dépôt) | Environnement `prod` : branche autorisée `main`, un relecteur requis. Environnement `rec` : branche `dev`. Settings > Actions : « Require approval for all outside collaborators ». Générer le jeton d'enregistrement du runner (Settings > Actions > Runners > New self-hosted runner, Linux x64) et le transmettre à Johan | 10 min |
|
| 1 | **ineszang** (seule admin du dépôt) | Environnement `prod` : branche autorisée `main`, un relecteur requis. Environnement `rec` : branche `dev`. Settings > Actions : « Require approval for all outside collaborators ». Générer le jeton d'enregistrement du runner (Settings > Actions > Runners > New self-hosted runner, Linux x64) et le transmettre à Johan | 10 min |
|
||||||
| 2 | **Johan** | Déposer sa clé sur la VM : `ssh-copy-id -i ~/.ssh/id_ed25519.pub root@10.101.200.37`, mot de passe du compte administrateur local des postes de l'école | 2 min |
|
| 2 | **Johan** | Déposer sa clé sur la VM : `ssh-copy-id -i ~/.ssh/id_ed25519.pub root@<IP-VM-G3>`, mot de passe du compte administrateur local des postes de l'école | 2 min |
|
||||||
| 3 | Johan + Claude | **Fait à 15h** : branche locale `feat/deploy-rec-prod` avec tous les changements du §3, image frontend reconstruite avec succès, fusion Compose vérifiée pour les deux environnements. Reste : commit, push, PR vers `dev` | fait |
|
| 3 | Johan + Claude | **Fait à 15h** : branche locale `feat/deploy-rec-prod` avec tous les changements du §3, image frontend reconstruite avec succès, fusion Compose vérifiée pour les deux environnements. Reste : commit, push, PR vers `dev` | fait |
|
||||||
| 4 | Claude, par SSH | `scripts/provision-host.sh` sur la VM. Écrire les deux `.env` (secrets générés sur la VM, jamais dans git). Certificats : `PUBLIC_HOST=rec.enervision.local PUBLIC_IP=10.101.200.37 make tls-selfsigned` dans `rec`, idem avec `enervision.local` dans `prod`. Puis `make stack-up` dans chaque dossier | 20 min plus la construction des images |
|
| 4 | Claude, par SSH | `scripts/provision-host.sh` sur la VM. Écrire les deux `.env` (secrets générés sur la VM, jamais dans git). Certificats : `PUBLIC_HOST=rec.enervision.local PUBLIC_IP=<IP-VM-G3> make tls-selfsigned` dans `rec`, idem avec `enervision.local` dans `prod`. Puis `make stack-up` dans chaque dossier | 20 min plus la construction des images |
|
||||||
| 5 | Johan, sur la VM | Installer le runner sous un utilisateur non-root membre du groupe `docker`, label `eni-g3`, en service systemd (`./config.sh --unattended --labels eni-g3`, `sudo ./svc.sh install && sudo ./svc.sh start`) | 10 min |
|
| 5 | Johan, sur la VM | Installer le runner sous un utilisateur non-root membre du groupe `docker`, label `eni-g3`, en service systemd (`./config.sh --unattended --labels eni-g3`, `sudo ./svc.sh install && sudo ./svc.sh start`) | 10 min |
|
||||||
| 6 | Équipe | Merger la PR dans `dev` : la recette se redéploie seule. Ouvrir la PR `dev` vers `main` : la prod se déploie après approbation dans l'onglet Environments | 15 min |
|
| 6 | Équipe | Merger la PR dans `dev` : la recette se redéploie seule. Ouvrir la PR `dev` vers `main` : la prod se déploie après approbation dans l'onglet Environments | 15 min |
|
||||||
| 7 | Tous | Vérifier depuis un poste de l'équipe, `/etc/hosts` renseigné : connexion, tableau de bord, Airflow par tunnel SSH | 15 min |
|
| 7 | Tous | Vérifier depuis un poste de l'équipe, `/etc/hosts` renseigné : connexion, tableau de bord, Airflow par tunnel SSH | 15 min |
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
- Statut : accepté
|
- Statut : accepté
|
||||||
- Date : 2026-09-23
|
- Date : 2026-09-23
|
||||||
|
- Note du 24/09 : au gel, les règles de branche ne sont pas posées : `prod` n'accepte que `main` mais sans relecteur, `rec` et `dev` n'ont aucune règle, aucune branche n'est protégée. L'approbation des workflows externes n'est pas lisible avec les droits d'un membre.
|
||||||
- Complète : [0009](0009-deux-environnements-compose-sur-la-vm-eni.md), qui reste en vigueur
|
- Complète : [0009](0009-deux-environnements-compose-sur-la-vm-eni.md), qui reste en vigueur
|
||||||
|
|
||||||
## Contexte
|
## Contexte
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Chaque poste devait éditer son `/etc/hosts` et accepter trois avertissements du
|
|||||||
rien de présentable à un jury, et rien d'utilisable par quelqu'un qui n'a pas la main sur son
|
rien de présentable à un jury, et rien d'utilisable par quelqu'un qui n'a pas la main sur son
|
||||||
poste.
|
poste.
|
||||||
|
|
||||||
Contraintes : la VM n'a qu'une IP privée, `10.101.200.37`, que ni Internet ni Let's Encrypt ne
|
Contraintes : la VM n'a qu'une IP privée, `<IP-VM-G3>`, que ni Internet ni Let's Encrypt ne
|
||||||
joignent, et le réseau de l'école ne doit pas être touché. Vérifications faites le 23/09 : les
|
joignent, et le réseau de l'école ne doit pas être touché. Vérifications faites le 23/09 : les
|
||||||
résolveurs de l'école rendent bien une adresse privée pour un nom public, la VM sort en HTTPS
|
résolveurs de l'école rendent bien une adresse privée pour un nom public, la VM sort en HTTPS
|
||||||
vers Let's Encrypt et vers l'API de dynv6, mais le filtrage de l'école bloque duckdns.org, site
|
vers Let's Encrypt et vers l'API de dynv6, mais le filtrage de l'école bloque duckdns.org, site
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -19,22 +19,22 @@ parce qu'ils disent ce que le projet doit prouver, et donc à quoi sert chaque d
|
|||||||
|
|
||||||
## Contexte
|
## Contexte
|
||||||
|
|
||||||
Statut : `Cible`. Les acteurs et les sources de mesures ne sont pas arrêtés, c'est l'objet du
|
Statut : `Fait`. Les sources de mesures ont été arrêtées au jalon J2 : le dataset historique
|
||||||
jalon J2.
|
fourni par l'école (CSV et métadonnées JSON, 2023 et 2024) et l'API Mock, interrogée chaque heure.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
exploitant["Exploitant<br/>consulte les courbes"]
|
exploitant["Exploitant<br/>consulte les courbes"]
|
||||||
admin["Administrateur<br/>exploite la plateforme"]
|
admin["Administrateur<br/>exploite la plateforme"]
|
||||||
sources["Sources de mesures<br/>à définir en J2"]
|
sources["Sources de mesures<br/>dataset CSV et API Mock"]
|
||||||
|
|
||||||
subgraph systeme["EnerVision"]
|
subgraph systeme["EnerVision"]
|
||||||
plateforme["Collecte, stockage,<br/>analyse et restitution<br/>de séries temporelles"]
|
plateforme["Collecte, stockage,<br/>analyse et restitution<br/>de séries temporelles"]
|
||||||
end
|
end
|
||||||
|
|
||||||
sources -.-> plateforme
|
sources --> plateforme
|
||||||
exploitant -.-> plateforme
|
exploitant --> plateforme
|
||||||
admin -.-> plateforme
|
admin --> plateforme
|
||||||
```
|
```
|
||||||
|
|
||||||
## Conteneurs
|
## Conteneurs
|
||||||
@@ -46,7 +46,8 @@ flowchart TB
|
|||||||
navigateur["Navigateur"]
|
navigateur["Navigateur"]
|
||||||
|
|
||||||
subgraph machine["Machine on-premise"]
|
subgraph machine["Machine on-premise"]
|
||||||
proxy["Reverse proxy Nginx<br/>:80 et :443"]
|
frontal["Frontal SNI<br/>:80 et :443"]
|
||||||
|
proxy["Reverse proxy Nginx<br/>un par environnement"]
|
||||||
front["Frontend Angular 22<br/>apps/frontend"]
|
front["Frontend Angular 22<br/>apps/frontend"]
|
||||||
api["API FastAPI<br/>apps/backend"]
|
api["API FastAPI<br/>apps/backend"]
|
||||||
db[("PostgreSQL 17<br/>TimescaleDB")]
|
db[("PostgreSQL 17<br/>TimescaleDB")]
|
||||||
@@ -55,10 +56,11 @@ flowchart TB
|
|||||||
grafana["Grafana<br/>profil monitoring"]
|
grafana["Grafana<br/>profil monitoring"]
|
||||||
end
|
end
|
||||||
|
|
||||||
navigateur --> proxy
|
navigateur --> frontal
|
||||||
|
frontal --> proxy
|
||||||
proxy --> front
|
proxy --> front
|
||||||
proxy --> api
|
proxy --> api
|
||||||
front -.-> api
|
front --> api
|
||||||
api --> db
|
api --> db
|
||||||
airflow --> db
|
airflow --> db
|
||||||
prom --> api
|
prom --> api
|
||||||
@@ -66,15 +68,24 @@ flowchart TB
|
|||||||
grafana --> prom
|
grafana --> prom
|
||||||
```
|
```
|
||||||
|
|
||||||
Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API, mais un
|
Le lien `front --> api` est en trait plein : les fixtures sont coupées (`useMockFixtures: false`
|
||||||
intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
|
dans les deux fichiers d'environnement Angular), et chaque service HTTP du frontend interroge
|
||||||
[30-frontend.md](30-frontend.md).
|
l'API réelle. Voir [30-frontend.md](30-frontend.md). Sur la machine, un frontal SNI reçoit les
|
||||||
|
ports 80 et 443 et aiguille chaque nom vers le proxy de son environnement
|
||||||
|
([ADR 0018](../adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md)).
|
||||||
|
|
||||||
Le lien `airflow --> db` est maintenant en trait plein : cinq DAGs tournent, deux pour
|
Le lien `airflow --> db` est maintenant en trait plein : sept 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), `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) et `retention`, qui exporte les chunks anciens de `reading`
|
||||||
|
vers Garage avant de les supprimer (issue #36). 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
|
||||||
@@ -87,13 +98,14 @@ ailleurs ([ADR 0016](../adr/0016-supervision-en-profil-compose.md),
|
|||||||
| Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement |
|
| Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) |
|
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) |
|
||||||
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle |
|
| Frontend | Angular 22, Node 26 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), huit services HTTP, graphiques Chart.js, vues sites, recommandations et supervision des capteurs. Tous branchés sur l'API réelle (`useMockFixtures: false`) ; l'intercepteur de fixtures ne sert plus qu'au développement hors ligne |
|
||||||
| 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 en service sur la machine, un proxy par environnement derrière un frontal SNI ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md), [ADR 0018](../adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md)). Provisionnement de la VM par Terraform, appliqué : Docker installé, trois environnements préparés, runner enregistré ; le coffre LUKS n'est pas appliqué, la machine étant un conteneur LXC ([ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md), [ADR 0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.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. Sept DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes`, `historical_import`, `mock_api_import`, `derive` (quotidien, surveillance de dérive) et `retention` (quotidien, export vers Garage puis suppression). 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` | `Fait` | 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 ([ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)), et toute autre branche à la demande dans `dev` ([ADR 0017](../adr/0017-environnement-dev-a-la-demande.md)). Sept déploiements de production réussis, le dernier sur le commit gelé ; l'environnement `prod` n'exige aucun relecteur (relevé du 24/09). Détail dans [50-cicd.md](50-cicd.md) |
|
||||||
|
|
||||||
## Flux bout en bout
|
## Flux bout en bout
|
||||||
|
|
||||||
@@ -102,7 +114,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
|
||||||
@@ -114,7 +127,6 @@ sequenceDiagram
|
|||||||
|
|
||||||
S->>A: mesures horodatées
|
S->>A: mesures horodatées
|
||||||
A->>T: insertion dans l'hypertable
|
A->>T: insertion dans l'hypertable
|
||||||
T->>T: rafraîchissement de l'agrégat continu
|
|
||||||
U->>API: GET /api/v1/...
|
U->>API: GET /api/v1/...
|
||||||
API->>T: agrégation sur la fenêtre demandée
|
API->>T: agrégation sur la fenêtre demandée
|
||||||
T-->>API: lignes
|
T-->>API: lignes
|
||||||
@@ -133,8 +145,14 @@ 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).
|
||||||
- **Interdire par défaut.** Toute route exige un jeton, sauf quatre exceptions listées dans un
|
- **Chiffrement au repos.** Les archives de mesures déposées sur Garage sont chiffrées par clé
|
||||||
fichier de test qui interroge réellement chaque route sans identifiant.
|
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 huit routes publiques listées
|
||||||
|
nommément dans `tests/api/acces.py`, et un test 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
|
||||||
changement de rôle prend effet à la requête suivante, pas au bout de 15 minutes.
|
changement de rôle prend effet à la requête suivante, pas au bout de 15 minutes.
|
||||||
- **Limitation de débit à fenêtre glissante** sur trois clés, évaluée avant le hachage. Pas de
|
- **Limitation de débit à fenêtre glissante** sur trois clés, évaluée avant le hachage. Pas de
|
||||||
@@ -154,7 +172,9 @@ consolidée.
|
|||||||
jeton, exigé dès que la supervision tourne, sonde de disponibilité qui ne publie plus la version de TimescaleDB.
|
jeton, exigé dès que la supervision tourne, sonde de disponibilité qui ne publie plus la version de TimescaleDB.
|
||||||
- **CI backend bloquante** : format, lint, typage strict et tests avec seuil de couverture.
|
- **CI backend bloquante** : format, lint, typage strict et tests avec seuil de couverture.
|
||||||
- **Conteneur backend non-root**, déclaré dans `apps/backend/Dockerfile`.
|
- **Conteneur backend non-root**, déclaré dans `apps/backend/Dockerfile`.
|
||||||
- **Terminaison TLS au frontal** : un reverse proxy Nginx est le seul service publié, il redirige
|
- **Terminaison TLS au proxy de chaque environnement**, derrière un frontal SNI qui est le seul
|
||||||
|
composant publié sur la machine et aiguille sans déchiffrer. Certificats Let's Encrypt par
|
||||||
|
DNS-01 ([ADR 0018](../adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md)). Le proxy redirige
|
||||||
80 vers 443, sert le SPA et l'API sous la même origine, pose **HSTS** et **CSP** que
|
80 vers 443, sert le SPA et l'API sous la même origine, pose **HSTS** et **CSP** que
|
||||||
l'application refuse délibérément de poser, et ajoute une **limitation de débit au frontal**
|
l'application refuse délibérément de poser, et ajoute une **limitation de débit au frontal**
|
||||||
distincte de celle de l'application. Voir
|
distincte de celle de l'application. Voir
|
||||||
@@ -172,9 +192,9 @@ consolidée.
|
|||||||
arrêteraient une application compromise. Même raison de report.
|
arrêteraient une application compromise. Même raison de report.
|
||||||
- **Portée par site** dans l'autorisation : les rôles sont globaux, un opérateur du site A peut
|
- **Portée par site** dans l'autorisation : les rôles sont globaux, un opérateur du site A peut
|
||||||
agir sur le site B. C'est la limite connue du modèle.
|
agir sur le site B. C'est la limite connue du modèle.
|
||||||
- **Certificat reconnu** : aucun nom de domaine public ne résout vers la machine, donc le défi
|
- **Approbation humaine avant la production** : l'environnement GitHub `prod` n'accepte que
|
||||||
HTTP-01 de Let's Encrypt ne peut pas aboutir. Le certificat servi est auto-signé, le chemin ACME
|
`main` mais n'exige aucun relecteur, et aucune branche n'est protégée. Réglage réservé à
|
||||||
est livré et documenté mais pas exercé.
|
l'administratrice du dépôt.
|
||||||
- **Analyse des images de conteneur** dans la CI. Celle des dépendances, elle, est en place
|
- **Analyse des images de conteneur** dans la CI. Celle des dépendances, elle, est en place
|
||||||
(`pip-audit`, `npm audit`, Dependabot sur 5 écosystèmes), de même que le SAST Bandit. Voir
|
(`pip-audit`, `npm audit`, Dependabot sur 5 écosystèmes), de même que le SAST Bandit. Voir
|
||||||
[50-cicd.md](50-cicd.md).
|
[50-cicd.md](50-cicd.md).
|
||||||
@@ -193,5 +213,15 @@ Elles vivent dans `../adr/`, pas ici.
|
|||||||
| [0006](../adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
|
| [0006](../adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
|
||||||
| [0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
|
| [0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
|
||||||
| [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) | Un projet Compose par environnement sur la VM ENI, déployé par un runner auto-hébergé (deux environnements à l'origine, trois depuis l'ADR 0017) |
|
||||||
| [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 |
|
||||||
|
| [0011](../adr/0011-enervision-procedure-deploiement.md) | Procédure de déploiement, telle qu'exécutée le 22/09/2026 |
|
||||||
|
| [0012](../adr/0012-enervision-deploiement-rec-prod-vm-eni.md) | État de la recette et de la production sur la VM ENI |
|
||||||
|
| [0013](../adr/0013-surveillance-de-derive-dans-le-backend.md) | La surveillance de dérive vit dans le backend et écrit sa propre table |
|
||||||
|
| [0014](../adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md) | Un pipeline CI unique appelle les workflows de composant et conditionne le déploiement |
|
||||||
|
| [0015](../adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md) | Les tests de bout en bout et de charge visent la stack Compose déployée |
|
||||||
|
| [0016](../adr/0016-supervision-en-profil-compose.md) | La supervision vit dans un profil Compose, active en prod |
|
||||||
|
| [0017](../adr/0017-environnement-dev-a-la-demande.md) | Un troisième environnement, `dev`, déployé à la demande depuis n'importe quelle branche |
|
||||||
|
| [0018](../adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md) | Noms publics, certificats Let's Encrypt par DNS-01 et frontal SNI sans port |
|
||||||
|
| [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 |
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ dans quel contexte, quelles décisions sont arrêtées, et ce qui manque encore
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Docker Compose | Développer et recetter sur le poste | `Fait` |
|
| Docker Compose | Développer et recetter sur le poste | `Fait` |
|
||||||
| Docker Compose plus reverse proxy | Déployer sur la machine on-premise | `Fait` |
|
| Docker Compose plus reverse proxy | Déployer sur la machine on-premise | `Fait` |
|
||||||
| Deux projets Compose sur la VM ENI, recette et production | Déploiement continu depuis GitHub | `En cours` |
|
| Trois projets Compose sur la VM ENI, dev, recette et production | Déploiement continu depuis GitHub | `Fait` |
|
||||||
| Provisionnement Terraform de la VM | Préparer la machine et enregistrer le runner | `En cours` |
|
| Provisionnement Terraform de la VM | Préparer la machine et enregistrer le runner | `Fait` |
|
||||||
| k3s single-node | Cible à terme | `En cours` |
|
| k3s single-node | Cible à terme | `En cours` |
|
||||||
| MLflow (`ml/`) | Tracker les expériences et le registre de modèles en local | `Fait`, non relié aux autres topologies |
|
| MLflow (`ml/`) | Tracker les expériences et le registre de modèles en local | `Fait`, non relié aux autres topologies |
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -175,9 +179,9 @@ du `docker-compose.yml` principal (réseau, volumes et démarrage séparés).
|
|||||||
| `mlflow` | Construite depuis `ml/` | Expose l'UI et l'API MLflow sur `127.0.0.1:5000`. Artefacts sur volume `mlflow-artifacts`, tracking store sur `mlflow-db` |
|
| `mlflow` | Construite depuis `ml/` | Expose l'UI et l'API MLflow sur `127.0.0.1:5000`. Artefacts sur volume `mlflow-artifacts`, tracking store sur `mlflow-db` |
|
||||||
|
|
||||||
Portée actuelle : environnement de tracking et de registre de modèles pour le développement
|
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 trois
|
||||||
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
|
||||||
@@ -195,8 +199,8 @@ d'entrainement Airflow et locaux n'a encore ete identifie.
|
|||||||
## Machine cible, exécution Docker
|
## Machine cible, exécution Docker
|
||||||
|
|
||||||
Statut : `Fait`. Défini par l'overlay `docker-compose.prod.yml`, appliqué par-dessus le
|
Statut : `Fait`. Défini par l'overlay `docker-compose.prod.yml`, appliqué par-dessus le
|
||||||
`docker-compose.yml`. Écrit et validé sur le poste, **jamais encore lancé sur le serveur de
|
`docker-compose.yml`. En service sur la machine du groupe, une stack par environnement (sept
|
||||||
l'école**. Décision et motifs dans l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
|
déploiements de production entre le 23/09 et le 24/09). Décision et motifs dans l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -221,8 +225,9 @@ flowchart LR
|
|||||||
sup -->|"alertes par courriel"| mail
|
sup -->|"alertes par courriel"| mail
|
||||||
```
|
```
|
||||||
|
|
||||||
Le proxy est **le seul service à publier des ports** sur le réseau. Backend et frontend ne sont
|
Sur le poste, le proxy est **le seul service à publier des ports** sur le réseau ; sur la
|
||||||
plus publiés du tout, la base et l'interface Mailpit sont ramenées sur `127.0.0.1`, donc joignables
|
machine, même lui n'écoute que sur `127.0.0.1`, derrière le frontal SNI (section suivante).
|
||||||
|
Backend et frontend ne sont plus publiés du tout, la base et l'interface Mailpit sont ramenées sur `127.0.0.1`, donc joignables
|
||||||
par tunnel SSH et pas autrement. Le détail du routage, les deux modes d'obtention du certificat et
|
par tunnel SSH et pas autrement. Le détail du routage, les deux modes d'obtention du certificat et
|
||||||
la commande de validation hors exécution sont dans [`infra/proxy/README.md`](../../infra/proxy/README.md).
|
la commande de validation hors exécution sont dans [`infra/proxy/README.md`](../../infra/proxy/README.md).
|
||||||
|
|
||||||
@@ -235,7 +240,7 @@ Deux conséquences se propagent jusqu'à l'application, et elles ne se devinent
|
|||||||
|
|
||||||
### Trois environnements sur la même machine
|
### Trois environnements sur la même machine
|
||||||
|
|
||||||
Statut : `En cours`. Décision et motifs dans
|
Statut : `Fait`. Décision et motifs dans
|
||||||
l'[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md), étendue à un troisième
|
l'[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md), étendue à un troisième
|
||||||
environnement par l'[ADR 0017](../adr/0017-environnement-dev-a-la-demande.md) ; noms,
|
environnement par l'[ADR 0017](../adr/0017-environnement-dev-a-la-demande.md) ; noms,
|
||||||
certificats et frontal sans port dans l'[ADR 0018](../adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md).
|
certificats et frontal sans port dans l'[ADR 0018](../adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md).
|
||||||
@@ -251,6 +256,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` |
|
||||||
|
|
||||||
@@ -269,7 +275,9 @@ la VM aligne le dossier sur la branche poussée et lance `make stack-up`.
|
|||||||
|
|
||||||
### Provisionnement de la machine
|
### Provisionnement de la machine
|
||||||
|
|
||||||
Statut : `En cours`. Décision et frontière dans
|
Statut : `Fait`. Appliqué : le state local porte Docker, les trois environnements et le runner ;
|
||||||
|
le coffre LUKS n'est pas appliqué, la machine étant un conteneur LXC
|
||||||
|
([ADR 0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md)). Décision et frontière dans
|
||||||
l'[ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md) : **Terraform
|
l'[ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md) : **Terraform
|
||||||
provisionne la machine, GitHub Actions déploie l'application**. La racine
|
provisionne la machine, GitHub Actions déploie l'application**. La racine
|
||||||
`infra/terraform/environments/vm-eni/` fait trois choses, et rien d'autre.
|
`infra/terraform/environments/vm-eni/` fait trois choses, et rien d'autre.
|
||||||
@@ -295,6 +303,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@<IP-VM-G3>:/tmp/
|
||||||
|
ssh root@<IP-VM-G3> 'COFFRE_TAILLE=30G COFFRE_MIGRER=1 bash /tmp/coffre-luks.sh'
|
||||||
|
ssh root@<IP-VM-G3> 'findmnt /var/lib/docker/volumes && lsblk /dev/mapper/enervision-coffre && docker ps'
|
||||||
|
ssh root@<IP-VM-G3> '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
|
||||||
@@ -354,7 +391,7 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
|
|||||||
| Terraform provisionne, GitHub Actions déploie | Deux chemins pour le même acte de livraison, c'est ce que la revue de #141 relève sur la VM | [ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md) |
|
| Terraform provisionne, GitHub Actions déploie | Deux chemins pour le même acte de livraison, c'est ce que la revue de #141 relève sur la VM | [ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md) |
|
||||||
| Connexion SSH par clé, jamais par mot de passe | Une variable de mot de passe finit en clair dans le state, ou dans les `triggers` qui y sont persistés | `environments/vm-eni/variables.tf`, `modules/k3s/main.tf` |
|
| Connexion SSH par clé, jamais par mot de passe | Une variable de mot de passe finit en clair dans le state, ou dans les `triggers` qui y sont persistés | `environments/vm-eni/variables.tf`, `modules/k3s/main.tf` |
|
||||||
| Terminaison TLS par un reverse proxy Nginx en Compose | L'ingress k3s supposait un registre et des manifestes qui n'existent pas, à quatre jours du rendu | `docker-compose.prod.yml`, [ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) |
|
| Terminaison TLS par un reverse proxy Nginx en Compose | L'ingress k3s supposait un registre et des manifestes qui n'existent pas, à quatre jours du rendu | `docker-compose.prod.yml`, [ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) |
|
||||||
| Certificat auto-signé par défaut, chemin ACME câblé | Aucun domaine public ne résout vers la machine : le défi HTTP-01 ne peut pas aboutir | `scripts/tls-selfsigned.sh`, `infra/proxy/acme-deploy-hook.sh` |
|
| Certificats Let's Encrypt par défi DNS-01 sur la machine, auto-signé sur le poste | La machine n'a qu'une adresse privée : le défi HTTP-01 ne peut pas aboutir, le défi DNS-01 ne demande qu'un enregistrement TXT dans la zone publique | `make tls-dns01`, `scripts/provision-host.sh`, `scripts/tls-selfsigned.sh`, [ADR 0018](../adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md) |
|
||||||
| Un projet Compose par environnement, sur la même machine | Une seule VM, et l'isolation par nom de projet ne demande ni cluster ni registre | `.env` de chaque dossier, [ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md) |
|
| Un projet Compose par environnement, sur la même machine | Une seule VM, et l'isolation par nom de projet ne demande ni cluster ni registre | `.env` de chaque dossier, [ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md) |
|
||||||
| Runner GitHub Actions auto-hébergé sur la VM | Les runners hébergés par GitHub ne joignent pas une adresse privée d'école | `.github/workflows/deploy.yml` |
|
| Runner GitHub Actions auto-hébergé sur la VM | Les runners hébergés par GitHub ne joignent pas une adresse privée d'école | `.github/workflows/deploy.yml` |
|
||||||
| Secrets dans le `.env` de chaque environnement, sur la machine | Ni dans git, ni dans GitHub : le runner n'a rien à recevoir | `scripts/provision-host.sh` |
|
| Secrets dans le `.env` de chaque environnement, sur la machine | Ni dans git, ni dans GitHub : le runner n'a rien à recevoir | `scripts/provision-host.sh` |
|
||||||
@@ -388,8 +425,6 @@ question à trancher, avant toute ressource Kubernetes.
|
|||||||
- **Quel ingress** remplace Traefik le jour de la bascule k3s. Qui termine le TLS est tranché par
|
- **Quel ingress** remplace Traefik le jour de la bascule k3s. Qui termine le TLS est tranché par
|
||||||
l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md), mais la réponse vaut pour la
|
l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md), mais la réponse vaut pour la
|
||||||
topologie Compose, pas pour Kubernetes.
|
topologie Compose, pas pour Kubernetes.
|
||||||
- **Quel nom de domaine public**, sans lequel Let's Encrypt reste hors d'atteinte et le certificat
|
|
||||||
reste auto-signé.
|
|
||||||
- **Quel registre d'images**, et comment il est alimenté sans CI.
|
- **Quel registre d'images**, et comment il est alimenté sans CI.
|
||||||
- **Quel stockage persistant** côté Kubernetes pour PostgreSQL, et si la base tourne dans le
|
- **Quel stockage persistant** côté Kubernetes pour PostgreSQL, et si la base tourne dans le
|
||||||
cluster ou à côté.
|
cluster ou à côté.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -478,7 +488,7 @@ Quatre fichiers méritent d'être connus avant de toucher à l'authentification
|
|||||||
- **Rôles PostgreSQL cantonnés** pour l'ETL et le travail d'apprentissage, plus le `REVOKE` sur
|
- **Rôles PostgreSQL cantonnés** pour l'ETL et le travail d'apprentissage, plus le `REVOKE` sur
|
||||||
`audit_log`. Dette assumée, décrite dans les ADR 0003 et 0004.
|
`audit_log`. Dette assumée, décrite dans les ADR 0003 et 0004.
|
||||||
- **Pagination et fenêtrage** : posés sur `GET /readings` (fenêtre plafonnée à 90 jours,
|
- **Pagination et fenêtrage** : posés sur `GET /readings` (fenêtre plafonnée à 90 jours,
|
||||||
`limit`/`offset` plafonné à 2000), mais toujours en `limit`/`offset` simple — pas de curseur ni
|
`limit`/`offset` plafonné à 2000), mais toujours en `limit`/`offset` simple : pas de curseur ni
|
||||||
de plan de secours si un `offset` élevé sur une fenêtre dense devient lent en pratique.
|
de plan de secours si un `offset` élevé sur une fenêtre dense devient lent en pratique.
|
||||||
`statement_timeout` reste absent au niveau de la connexion, donc rien n'empêche une requête
|
`statement_timeout` reste absent au niveau de la connexion, donc rien n'empêche une requête
|
||||||
individuelle de tourner longtemps si les plafonds au-dessus d'elle s'avéraient insuffisants.
|
individuelle de tourner longtemps si les plafonds au-dessus d'elle s'avéraient insuffisants.
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ Application Angular 22, 100 % standalone, testée avec Vitest. Source dans `apps
|
|||||||
|
|
||||||
## État actuel
|
## État actuel
|
||||||
|
|
||||||
Statut : `En cours`. L'application sert le tableau de bord, la liste et le détail des sites, la
|
Statut : `En cours`. L'application sert le tableau de bord, la liste et le détail des sites, les
|
||||||
supervision des capteurs (admin) et le flux des alertes actives, tous branchés sur l'API réelle.
|
recommandations, la supervision des capteurs (admin) et le flux des alertes actives, tous
|
||||||
|
branchés sur l'API réelle.
|
||||||
|
|
||||||
Ce qui est en place :
|
Ce qui est en place :
|
||||||
|
|
||||||
@@ -13,10 +14,11 @@ Ce qui est en place :
|
|||||||
- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et
|
- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et
|
||||||
`provideHttpClient(withInterceptors([authInterceptor, mockApiInterceptor]))`.
|
`provideHttpClient(withInterceptors([authInterceptor, mockApiInterceptor]))`.
|
||||||
- Des routes en composants différés (`/dashboard`, `/sites`, `/sites/:siteId`,
|
- Des routes en composants différés (`/dashboard`, `/sites`, `/sites/:siteId`,
|
||||||
`/monitoring/sensors` réservée au rôle `admin`) et une redirection depuis la racine.
|
`/recommendations`, `/monitoring/sensors` réservée au rôle `admin`, et les pages
|
||||||
|
d'authentification) et une redirection depuis la racine.
|
||||||
- `core/services` porte un service HTTP par domaine (`StatsService`, `AlertsService` avec ses
|
- `core/services` porte un service HTTP par domaine (`StatsService`, `AlertsService` avec ses
|
||||||
filtres `site_id` et `severity`, `PredictionsService`, `SitesService`, `ReadingsService`,
|
filtres `site_id` et `severity`, `PredictionsService`, `SitesService`, `ReadingsService`,
|
||||||
`SensorsService`, `AuthService`), `core/interceptors` l'intercepteur de fixtures et l'intercepteur
|
`RecommendationsService`, `SensorsService`, `AuthService`), `core/interceptors` l'intercepteur de fixtures et l'intercepteur
|
||||||
d'authentification (jeton porteur, rafraîchissement sur 401), `core/guards` la garde `authGuard`.
|
d'authentification (jeton porteur, rafraîchissement sur 401), `core/guards` la garde `authGuard`.
|
||||||
- `features/` porte une page par domaine. `shared/components` porte la jauge de consommation et
|
- `features/` porte une page par domaine. `shared/components` porte la jauge de consommation et
|
||||||
les graphiques Chart.js, le widget `app-alert-feed` (flux d'alertes filtrable par site et
|
les graphiques Chart.js, le widget `app-alert-feed` (flux d'alertes filtrable par site et
|
||||||
|
|||||||
@@ -136,8 +136,10 @@ origine, en HTTPS**. C'est cela, et rien d'autre, qui rend le cookie `__Secure-e
|
|||||||
utilisable : servi en HTTP simple ou depuis une autre origine, il n'est jamais posé et
|
utilisable : servi en HTTP simple ou depuis une autre origine, il n'est jamais posé et
|
||||||
l'authentification ne survit pas à un rechargement de page.
|
l'authentification ne survit pas à un rechargement de page.
|
||||||
|
|
||||||
Ce qui reste à surveiller : le certificat est auto-signé tant qu'aucun domaine public ne résout
|
Sur la machine, le certificat vient de Let's Encrypt par DNS-01
|
||||||
vers la machine. Un navigateur qui refuse l'exception refusera aussi le cookie.
|
([ADR 0018](../adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md)) : le navigateur n'a
|
||||||
|
aucune exception à accepter. Sur le poste, il reste auto-signé, et un navigateur qui refuse
|
||||||
|
l'exception refusera aussi le cookie.
|
||||||
|
|
||||||
Et au moins une fois avant la soutenance, lancer le front **sans le proxy**, en cross-origin
|
Et au moins une fois avant la soutenance, lancer le front **sans le proxy**, en cross-origin
|
||||||
réel : c'est le seul moyen d'exercer le préflight CORS et `SameSite`, que la même origine masque.
|
réel : c'est le seul moyen d'exercer le préflight CORS et `SameSite`, que la même origine masque.
|
||||||
|
|||||||
+122
-33
@@ -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()`
|
||||||
@@ -73,10 +74,11 @@ Les mécanismes d'ingestion sont maintenant implémentés pour les deux sources
|
|||||||
- le dataset historique CSV/JSON avec `historical_import.py` ;
|
- le dataset historique CSV/JSON avec `historical_import.py` ;
|
||||||
- l'API Mock avec `mock_api_import.py`.
|
- l'API Mock avec `mock_api_import.py`.
|
||||||
|
|
||||||
Les traitements sont actuellement exécutables directement depuis le backend.
|
Les traitements restent exécutables directement depuis le backend, et Airflow les orchestre :
|
||||||
|
`historical_import` se lance à la demande, `mock_api_import` chaque heure à la minute 45.
|
||||||
|
|
||||||
L'orchestration avec Apache Airflow reste une cible, tout comme les agrégats continus,
|
Les agrégats continus et la compression restent des cibles. La rétention est faite : le DAG `retention` exporte chaque chunk de `reading` plus
|
||||||
la compression et les politiques de rétention.
|
vieux que `READING_RETENTION_DAYS` vers Garage, puis le supprime.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -86,12 +88,13 @@ flowchart LR
|
|||||||
hist --> hy[("Hypertable reading")]
|
hist --> hy[("Hypertable reading")]
|
||||||
api --> hy
|
api --> hy
|
||||||
|
|
||||||
airflow["Airflow"] -.-> hist
|
airflow["Airflow"] --> hist
|
||||||
airflow -.-> api
|
airflow --> api
|
||||||
|
|
||||||
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,14 +537,15 @@ 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 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Timeout | `APP_MOCK_API_TIMEOUT_SECONDS`, dix secondes par défaut |
|
| Timeout | `APP_MOCK_API_TIMEOUT_SECONDS`, dix secondes par défaut |
|
||||||
| 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, dérivé de la fenêtre par `limit_for_window()` |
|
||||||
| 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.
|
||||||
@@ -531,9 +622,10 @@ imputed_values = NULL
|
|||||||
imputation_method = NULL
|
imputation_method = NULL
|
||||||
```
|
```
|
||||||
|
|
||||||
### Validation de l'import API Mock
|
### Validation initiale de l'import API Mock (18/09)
|
||||||
|
|
||||||
Un scénario de validation a été exécuté pour les 7 sites sur la période :
|
Un premier scénario de validation a été exécuté le 18/09, avant la clôture de la réconciliation
|
||||||
|
(#15), pour les 7 sites sur la période :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
15/06/2024 12:00 UTC
|
15/06/2024 12:00 UTC
|
||||||
@@ -555,6 +647,10 @@ Résultat :
|
|||||||
420 lectures récupérées
|
420 lectures récupérées
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Ce scénario ne se rejoue plus tel quel depuis le 23/09 : la fenêtre recouvre le dataset
|
||||||
|
historique, donc `refuse_if_overlaps_historical_dataset()` la refuse, et `limit` n'est plus
|
||||||
|
fourni par l'appelant (une lecture par heure, voir plus haut).
|
||||||
|
|
||||||
Les données ont été chargées dans PostgreSQL/TimescaleDB puis contrôlées directement en base.
|
Les données ont été chargées dans PostgreSQL/TimescaleDB puis contrôlées directement en base.
|
||||||
|
|
||||||
Les contrôles ont confirmé :
|
Les contrôles ont confirmé :
|
||||||
@@ -582,9 +678,9 @@ Les tests automatisés couvrent également :
|
|||||||
- la conservation des données sources ;
|
- la conservation des données sources ;
|
||||||
- l'idempotence en base.
|
- l'idempotence en base.
|
||||||
|
|
||||||
## Évolution prévue
|
## Orchestration par Airflow
|
||||||
|
|
||||||
La prochaine étape consiste à orchestrer les deux mécanismes d'ingestion avec Apache Airflow.
|
Les deux mécanismes d'ingestion sont orchestrés par Apache Airflow (`etl/airflow/dags`) :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
CSV / JSON ----------------+
|
CSV / JSON ----------------+
|
||||||
@@ -605,18 +701,11 @@ historical_import.py mock_api_import.py
|
|||||||
PostgreSQL / TimescaleDB
|
PostgreSQL / TimescaleDB
|
||||||
```
|
```
|
||||||
|
|
||||||
Airflow servira à :
|
`historical_import` n'a pas de planification (lancement à la demande) ; `mock_api_import` tourne
|
||||||
|
chaque heure à la minute 45. Airflow planifie, ordonne, suit l'état et remonte les erreurs ; il ne
|
||||||
|
remplace pas la logique ETL : les scripts Python restent responsables de l'extraction, de la
|
||||||
|
validation, de la transformation et du chargement, appelés tels quels par des `BashOperator`.
|
||||||
|
|
||||||
- planifier les traitements ;
|
Le même Airflow porte la suite de la chaîne : entraînement et scoring du modèle (`ml_train`,
|
||||||
- définir leur ordre d'exécution ;
|
`ml_score`), alertes et recommandations (`alertes`), dérive (`derive`) et rétention
|
||||||
- suivre leur état ;
|
(`retention`), soit sept DAGs.
|
||||||
- gérer et remonter les erreurs ;
|
|
||||||
- faciliter les exécutions récurrentes.
|
|
||||||
|
|
||||||
Airflow ne remplacera pas la logique ETL déjà implémentée.
|
|
||||||
|
|
||||||
Les scripts Python resteront responsables de l'extraction, de la validation, de la transformation
|
|
||||||
et du chargement des données.
|
|
||||||
|
|
||||||
Le pipeline servira ensuite de base à la préparation des données nécessaires au modèle
|
|
||||||
de Machine Learning.
|
|
||||||
|
|||||||
@@ -6,14 +6,15 @@ vérifié, ce qui bloque, et ce qui ne l'est pas.
|
|||||||
| Étage | Sert à | Statut |
|
| Étage | Sert à | Statut |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Intégration continue | Interdire le merge d'un code qui casse la qualité, les tests ou la sécurité | `Fait` |
|
| Intégration continue | Interdire le merge d'un code qui casse la qualité, les tests ou la sécurité | `Fait` |
|
||||||
| Livraison continue | Déployer chaque branche d'intégration sur son environnement de la VM ENI | `En cours` |
|
| Livraison continue | Déployer chaque branche d'intégration sur son environnement de la VM ENI | `Fait` |
|
||||||
|
|
||||||
Le **D** de CI/CD est écrit depuis le 21/09 : `deploy.yml` déploie `dev` en recette et `main` en
|
Le **D** de CI/CD est écrit depuis le 21/09 : `deploy.yml` déploie `dev` en recette et `main` en
|
||||||
production sur la VM de l'école, par un runner auto-hébergé (issue #21,
|
production sur la VM de l'école, par un runner auto-hébergé (issue #21,
|
||||||
[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)). Depuis le 23/09, il ne part
|
[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)). Depuis le 23/09, il ne part
|
||||||
plus qu'une fois la CI du commit verte ([ADR 0014](../adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md)).
|
plus qu'une fois la CI du commit verte ([ADR 0014](../adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md)).
|
||||||
Il n'a encore rien déployé : le runner n'est pas enregistré sur la machine. Statut à
|
Le runner est enregistré sur la machine (`null_resource.runner_github` dans le state
|
||||||
basculer sur `Fait` au premier déploiement vert. Sa limite, nommée ici plutôt que découverte en
|
Terraform) et l'environnement `prod` compte sept déploiements entre le 23/09 11h37 et le 24/09
|
||||||
|
11h12, le dernier sur le commit gelé. Sa limite, nommée ici plutôt que découverte en
|
||||||
soutenance : les images sont construites sur la machine à chaque déploiement, aucun artefact
|
soutenance : les images sont construites sur la machine à chaque déploiement, aucun artefact
|
||||||
n'est publié puis promu d'un environnement à l'autre.
|
n'est publié puis promu d'un environnement à l'autre.
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ n'est ouvert. Il n'a pas de déclencheur propre en dehors de `workflow_dispatch`
|
|||||||
| Événement | Environnement GitHub | Dossier sur la VM | Garde |
|
| Événement | Environnement GitHub | Dossier sur la VM | Garde |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `push` sur `dev`, « CI ok » vert | `rec` | `/srv/enervision/rec` | aucune de plus : la recette suit `dev` |
|
| `push` sur `dev`, « CI ok » vert | `rec` | `/srv/enervision/rec` | aucune de plus : la recette suit `dev` |
|
||||||
| `push` sur `main`, « CI ok » vert | `prod` | `/srv/enervision/prod` | approbation d'un relecteur dans l'environnement `prod`, branche `main` seule autorisée |
|
| `push` sur `main`, « CI ok » vert | `prod` | `/srv/enervision/prod` | branche `main` seule autorisée ; **aucun relecteur requis** (relevé par l'API le 24/09), l'approbation prévue n'est pas activée |
|
||||||
| `workflow_dispatch` sur toute autre branche | `dev` | `/srv/enervision/dev` | droit d'écriture sur le dépôt, seul à pouvoir lancer un workflow ([ADR 0017](../adr/0017-environnement-dev-a-la-demande.md)) |
|
| `workflow_dispatch` sur toute autre branche | `dev` | `/srv/enervision/dev` | droit d'écriture sur le dépôt, seul à pouvoir lancer un workflow ([ADR 0017](../adr/0017-environnement-dev-a-la-demande.md)) |
|
||||||
|
|
||||||
Le job aligne le clone sur **le commit testé** (`fetch`, `checkout`, `reset --hard $GITHUB_SHA`),
|
Le job aligne le clone sur **le commit testé** (`fetch`, `checkout`, `reset --hard $GITHUB_SHA`),
|
||||||
@@ -134,20 +135,23 @@ de `pull_request` dans `deploy.yml` ne suffit donc pas. Ce qui protège vraiment
|
|||||||
|
|
||||||
- le dépôt exige l'approbation des workflows de tous les contributeurs externes (Settings,
|
- le dépôt exige l'approbation des workflows de tous les contributeurs externes (Settings,
|
||||||
Actions, « Require approval for all external contributors ») ;
|
Actions, « Require approval for all external contributors ») ;
|
||||||
- les environnements `rec` et `prod` n'acceptent que leur branche (`dev`, `main` avec un
|
- l'environnement `prod` n'accepte que `main`, ce qui bloque un job qui le déclare depuis une
|
||||||
relecteur), ce qui bloque un job qui les déclare avant qu'il atteigne le runner ;
|
autre branche avant qu'il atteigne le runner. `rec` et `dev` n'ont, eux, aucune règle
|
||||||
|
(relevé par l'API le 24/09) ;
|
||||||
- le runner tourne sous un utilisateur dédié membre du groupe `docker`, jamais root.
|
- le runner tourne sous un utilisateur dédié membre du groupe `docker`, jamais root.
|
||||||
|
|
||||||
Les deux réglages de dépôt restent à activer par l'administratrice. Tous les autres workflows
|
Restent à activer par l'administratrice : l'approbation des workflows de contributeurs
|
||||||
restent sur `ubuntu-latest`.
|
externes (non vérifiable sans droit d'administration), un relecteur requis sur `prod`, et la
|
||||||
|
restriction de `rec` à `dev`. Tous les autres workflows restent sur `ubuntu-latest`.
|
||||||
|
|
||||||
Cet utilisateur dédié doit posséder `/srv/enervision` : sinon git refuse les deux clones pour
|
Cet utilisateur dédié doit posséder `/srv/enervision` : sinon git refuse les clones pour
|
||||||
propriété douteuse et le `.env` en `600` lui échappe. `PROPRIETAIRE=<utilisateur du runner>`
|
propriété douteuse et le `.env` en `600` lui échappe. `PROPRIETAIRE=<utilisateur du runner>`
|
||||||
passé à `scripts/provision-host.sh` fixe ce propriétaire.
|
passé à `scripts/provision-host.sh` fixe ce propriétaire.
|
||||||
|
|
||||||
La machine se prépare avec `scripts/provision-host.sh`, qui vérifie Docker et Compose 2.24.4 ou
|
La machine se prépare avec `scripts/provision-host.sh`, qui vérifie Docker et Compose 2.24.4 ou
|
||||||
plus, clone les deux branches, génère les secrets de chaque `.env` et les certificats
|
plus, prépare les trois clones (`prod` sur `main`, `rec` et `dev` sur `dev`), génère les secrets
|
||||||
auto-signés, et ne démarre rien. Le détail des trois environnements, ports et noms d'hôte, est
|
de chaque `.env`, obtient les certificats Let's Encrypt par DNS-01 (un auto-signé ne reste en
|
||||||
|
place qu'en cas d'échec) et planifie leur renouvellement, et ne démarre rien. Le détail des trois environnements, ports et noms d'hôte, est
|
||||||
dans [10-infra.md](10-infra.md).
|
dans [10-infra.md](10-infra.md).
|
||||||
|
|
||||||
## Ce qui bloque un merge
|
## Ce qui bloque un merge
|
||||||
@@ -171,6 +175,8 @@ dans [10-infra.md](10-infra.md).
|
|||||||
| Formatage et validité Terraform | infra | `fmt -check -recursive`, puis `init` et `validate` par racine | Bloque |
|
| Formatage et validité Terraform | infra | `fmt -check -recursive`, puis `init` et `validate` par racine | Bloque |
|
||||||
| Verrous uv à jour | backend, ml, airflow | `uv sync --locked` : un `uv.lock` qui ne suit plus `pyproject.toml` échoue | Bloque |
|
| Verrous uv à jour | backend, ml, airflow | `uv sync --locked` : un `uv.lock` qui ne suit plus `pyproject.toml` échoue | Bloque |
|
||||||
| Fichiers Compose | infra | `docker compose config` sur la stack de dev et la stack déployée, tous profils | Bloque |
|
| Fichiers Compose | infra | `docker compose config` sur la stack de dev et la stack déployée, tous profils | Bloque |
|
||||||
|
| Frontal SNI | infra | `docker compose config` et `nginx -t` de `infra/front` | Bloque |
|
||||||
|
| Stockage objet | infra | fumée S3 sur Garage démarré par Compose : aller-retour, suppression, lecture refusée sans clé SSE-C (`tests/garage`) | Bloque |
|
||||||
| Supervision | infra | `promtool check config`, `promtool test rules` (un cas par alerte), `amtool check-config`, JSON des tableaux | Bloque |
|
| Supervision | infra | `promtool check config`, `promtool test rules` (un cas par alerte), `amtool check-config`, JSON des tableaux | Bloque |
|
||||||
| Workflows | infra | `actionlint`, shellcheck compris sur les blocs `run:` | Bloque |
|
| Workflows | infra | `actionlint`, shellcheck compris sur les blocs `run:` | Bloque |
|
||||||
| Parcours de bout en bout | e2e | 18 parcours Playwright contre la stack de prod (proxy TLS) | Bloque |
|
| Parcours de bout en bout | e2e | 18 parcours Playwright contre la stack de prod (proxy TLS) | Bloque |
|
||||||
@@ -187,8 +193,8 @@ Deux seuils portent une décision qu'il faut savoir défendre :
|
|||||||
sans bloquer. Sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace. Le
|
sans bloquer. Sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace. Le
|
||||||
revers à connaître : cette seconde étape porte `continue-on-error`, donc le job reste **vert**
|
revers à connaître : cette seconde étape porte `continue-on-error`, donc le job reste **vert**
|
||||||
même quand elle relève quelque chose ; un LOW ne se voit qu'en ouvrant le journal. Au
|
même quand elle relève quelque chose ; un LOW ne se voit qu'en ouvrant le journal. Au
|
||||||
21/09/2026, les deux modules sont à **zéro constat, tous niveaux confondus**, sur 5 904 lignes
|
24/09/2026, les deux modules sont à **zéro constat, tous niveaux confondus**, sur 6 858 lignes
|
||||||
analysées.
|
analysées (6 136 pour le backend, 722 pour le ML).
|
||||||
- **La version de Bandit est épinglée** (`uvx bandit==1.9.4`) dans les deux jobs. Sans épingle,
|
- **La version de Bandit est épinglée** (`uvx bandit==1.9.4`) dans les deux jobs. Sans épingle,
|
||||||
une nouvelle version passerait la CI au rouge sans qu'une seule ligne du dépôt ait changé, et
|
une nouvelle version passerait la CI au rouge sans qu'une seule ligne du dépôt ait changé, et
|
||||||
le rejeu à l'identique promis plus bas n'existerait pas.
|
le rejeu à l'identique promis plus bas n'existerait pas.
|
||||||
@@ -209,7 +215,7 @@ partie de la suite, et son taux n'aurait aucun sens face au seuil de 85 %.
|
|||||||
|
|
||||||
### Pourquoi le job d'intégration ML installe aussi le backend
|
### Pourquoi le job d'intégration ML installe aussi le backend
|
||||||
|
|
||||||
Le schéma de la base n'a qu'une source, les six révisions Alembic de `apps/backend/alembic` : le
|
Le schéma de la base n'a qu'une source, les sept révisions Alembic de `apps/backend/alembic` : le
|
||||||
backend est propriétaire du schéma, `ml/` n'en est que consommateur. Reconstruire ce schéma à la
|
backend est propriétaire du schéma, `ml/` n'en est que consommateur. Reconstruire ce schéma à la
|
||||||
main dans le job ML donnerait un job vert sur une base qui n'est pas la nôtre, exactement l'erreur
|
main dans le job ML donnerait un job vert sur une base qui n'est pas la nôtre, exactement l'erreur
|
||||||
qu'évite déjà le choix de l'image `timescaledb-ha` plutôt qu'un `postgres` nu. Le job installe
|
qu'évite déjà le choix de l'image `timescaledb-ha` plutôt qu'un `postgres` nu. Le job installe
|
||||||
@@ -281,7 +287,7 @@ les tests ne se merge pas.
|
|||||||
| Préfixes de branche | `feat/`, `fix/`, `chore/`, `docs/`, `test/` |
|
| Préfixes de branche | `feat/`, `fix/`, `chore/`, `docs/`, `test/` |
|
||||||
| Messages de commit | Conventional Commits |
|
| Messages de commit | Conventional Commits |
|
||||||
| Branche d'intégration | `dev` ; `main` est la branche par défaut du dépôt public |
|
| Branche d'intégration | `dev` ; `main` est la branche par défaut du dépôt public |
|
||||||
| Revue | Toute PR passe par une revue écrite avant merge |
|
| Revue | Relecture écrite par un autre membre avant merge : 55 PR de fonctionnalité sur 62 au gel (89 %). Convention d'équipe, non imposée par une protection de branche |
|
||||||
| ADR | Toute décision structurante porte son ADR dans la même PR |
|
| ADR | Toute décision structurante porte son ADR dans la même PR |
|
||||||
| Vues d'architecture | Toute PR qui change un composant met à jour sa vue **dans la même PR** |
|
| Vues d'architecture | Toute PR qui change un composant met à jour sa vue **dans la même PR** |
|
||||||
|
|
||||||
@@ -354,7 +360,7 @@ du runner qui l'a écrit, sans remappage automatique.
|
|||||||
depuis le début. Corrigé par `sudo chown 1000:1000` du fichier avant de le passer à `644`.
|
depuis le début. Corrigé par `sudo chown 1000:1000` du fichier avant de le passer à `644`.
|
||||||
- Ce `chown` déplace la propriété du fichier hors de l'utilisateur du runner : un `chmod` qui
|
- Ce `chown` déplace la propriété du fichier hors de l'utilisateur du runner : un `chmod` qui
|
||||||
suit sans `sudo` échoue alors (« Operation not permitted »), et le `-e` implicite des étapes
|
suit sans `sudo` échoue alors (« Operation not permitted »), et le `-e` implicite des étapes
|
||||||
bash de GitHub Actions arrête toute l'étape avant même `docker run` — un scan « réussi » en une
|
bash de GitHub Actions arrête toute l'étape avant même `docker run` : un scan « réussi » en une
|
||||||
fraction de seconde, sans le moindre journal ni rapport produit. Les deux commandes doivent
|
fraction de seconde, sans le moindre journal ni rapport produit. Les deux commandes doivent
|
||||||
passer par `sudo`.
|
passer par `sudo`.
|
||||||
|
|
||||||
@@ -427,6 +433,9 @@ voir `tests/load/README.md`.
|
|||||||
| Tir de charge nominal automatisé | #47 | Seul le smoke tourne en CI ; la charge à 50 utilisateurs se lance à la main en recette (`make load-test`), rec et prod partageant la VM |
|
| Tir de charge nominal automatisé | #47 | Seul le smoke tourne en CI ; la charge à 50 utilisateurs se lance à la main en recette (`make load-test`), rec et prod partageant la VM |
|
||||||
| Cache de couches Docker en CI | aucune | Le job E2E reconstruit les images backend et frontend à chaque run, deux à quatre minutes de plus |
|
| Cache de couches Docker en CI | aucune | Le job E2E reconstruit les images backend et frontend à chaque run, deux à quatre minutes de plus |
|
||||||
| Scan d'image de conteneur | aucune | Les images sont construites par le job E2E, pas analysées |
|
| Scan d'image de conteneur | aucune | Les images sont construites par le job E2E, pas analysées |
|
||||||
|
| Scan de secrets et d'IaC en CI | aucune | gitleaks, Trivy et Checkov ne tournent qu'à la main, pour le rapport de sécurisation : un secret commité ne serait vu qu'au passage suivant |
|
||||||
|
| `pip-audit` sur les verrous ML et Airflow, `npm audit` sur `tests/e2e` | aucune | Seuls les verrous du backend et du frontend sont audités en CI : la CVE-2026-41016 d'`apache-airflow-providers-smtp`, relevée le 23/09, y reste invisible |
|
||||||
|
| Approbation humaine avant la production | aucune | L'environnement `prod` n'exige aucun relecteur : un push sur `main` au CI vert part en production sans autre garde |
|
||||||
|
|
||||||
## Reproduire la CI en local
|
## Reproduire la CI en local
|
||||||
|
|
||||||
@@ -437,7 +446,7 @@ Les tests d'intégration demandent une base **migrée**, et `db/init` ne crée `
|
|||||||
vide :
|
vide :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make db-up migrate-test # la base de test reçoit les six révisions Alembic
|
make db-up migrate-test # la base de test reçoit les sept révisions Alembic
|
||||||
make test-integration # backend, marqueur `integration`
|
make test-integration # backend, marqueur `integration`
|
||||||
make ml-test-integration # pipeline ML, marqueur `integration`
|
make ml-test-integration # pipeline ML, marqueur `integration`
|
||||||
make test-chaine # vrais binaires ML puis relecture par l'API, marqueur `chaine`
|
make test-chaine # vrais binaires ML puis relecture par l'API, marqueur `chaine`
|
||||||
|
|||||||
@@ -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 :
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# 70 · Pilotage des traitements automatisés
|
||||||
|
|
||||||
|
Ce document dit à l'opérateur quoi surveiller, comment lire ce qu'il voit et quoi faire quand un
|
||||||
|
traitement déraille. Il ne redit pas le fonctionnement : la table des DAGs vit dans
|
||||||
|
[10-infra.md](10-infra.md), la chaîne de données dans [40-data.md](40-data.md), la dérive dans
|
||||||
|
l'[ADR 0013](../adr/0013-surveillance-de-derive-dans-le-backend.md), la rétention dans
|
||||||
|
l'[ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md), la supervision
|
||||||
|
dans [60-observabilite.md](60-observabilite.md).
|
||||||
|
|
||||||
|
## Carte des traitements
|
||||||
|
|
||||||
|
Les planifications sont en **UTC** : Airflow n'a pas de fuseau configuré. En heure de Paris
|
||||||
|
l'été, ajouter deux heures.
|
||||||
|
|
||||||
|
| DAG | Planification (UTC) | Ce qu'il fait | Même traitement à la main |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `mock_api_import` | chaque heure à :45 | importe la mesure de l'heure pile de l'API Mock, une par site | `python -m app.etl.mock_api_import --start-time … --end-time …` |
|
||||||
|
| `ml_score` | chaque heure pile | score le pas horaire suivant, écrit `prediction` | `make ml-score` |
|
||||||
|
| `alertes` | chaque heure à :15 | détecte les alertes internes, puis génère les recommandations | `make detect-alerts`, puis `make recommendations` |
|
||||||
|
| `retention` | 03:20 | exporte vers Garage les chunks de `reading` de plus de 1 095 jours, puis les supprime | `python -m app.etl.reading_retention` |
|
||||||
|
| `derive` | 05:30 | calcule le rapport de dérive du modèle sur 168 h | `python -m app.monitoring.drift` |
|
||||||
|
| `ml_train` | manuel | réentraîne LightGBM et **écrase** le modèle | `make ml-train` |
|
||||||
|
| `historical_import` | manuel | importe le jeu historique CSV | `python -m app.etl.historical_import --csv … --metadata …` |
|
||||||
|
|
||||||
|
Les modules `app.*` se lancent depuis `apps/backend` (`uv run python -m …`). Tous les DAGs ont
|
||||||
|
`max_active_runs=1` et `catchup=False` : un retard ne rejoue pas les heures manquées.
|
||||||
|
|
||||||
|
## Où regarder
|
||||||
|
|
||||||
|
Sur la machine, chaque environnement vit dans `/srv/enervision/<env>` et n'écoute que sur la
|
||||||
|
boucle locale : on y accède par tunnel SSH.
|
||||||
|
|
||||||
|
| Quoi | Production | Recette | Dev |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Interface Airflow | `127.0.0.1:8080` | `127.0.0.1:8082` | `127.0.0.1:8084` |
|
||||||
|
| Grafana (production seulement) | `127.0.0.1:3001` | - | - |
|
||||||
|
| Mailpit, où arrivent les alertes | `127.0.0.1:8025` | `127.0.0.1:8026` | `127.0.0.1:8027` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -L 8080:127.0.0.1:8080 -L 3001:127.0.0.1:3001 -L 8025:127.0.0.1:8025 root@<IP-VM-G3>
|
||||||
|
```
|
||||||
|
|
||||||
|
En ligne de commande, depuis `/srv/enervision/<env>` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
compose="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
|
||||||
|
$compose exec airflow-apiserver airflow dags list-runs derive # derniers passages
|
||||||
|
$compose exec airflow-apiserver airflow dags trigger ml_train # lancement manuel
|
||||||
|
$compose logs --tail=100 airflow-scheduler # les tâches tournent ici
|
||||||
|
```
|
||||||
|
|
||||||
|
**Aucune alerte ne signale l'échec d'un DAG** ([60-observabilite.md](60-observabilite.md)) :
|
||||||
|
un coup d'œil quotidien à l'interface Airflow reste nécessaire.
|
||||||
|
|
||||||
|
## Lire le rapport de dérive
|
||||||
|
|
||||||
|
`GET /api/v1/monitoring/drift` (rôle `operateur`) rend le dernier rapport par site et une ligne
|
||||||
|
globale. Le verdict suit cet ordre (`app/services/drift.py`) :
|
||||||
|
|
||||||
|
| Verdict | Condition | Ce que ça veut dire | Quoi faire |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `indetermine` | moins de 24 prévisions vérifiées sur la fenêtre | pas assez de recul pour conclure | attendre ; si ça dure, vérifier `ml_score` |
|
||||||
|
| `derive`, couverture | moins de 80 % des prévisions ont trouvé leur mesure réelle | **le pipeline**, pas le modèle | vérifier `mock_api_import` et `ml_score` dans Airflow |
|
||||||
|
| `derive`, erreur | MAE au-delà de 1,25 fois celle de la fenêtre de référence | le modèle se trompe plus qu'avant | réentraîner (ci-dessous) |
|
||||||
|
| `derive`, biais | biais absolu au-delà du seuil, désactivé par défaut | le modèle se trompe toujours du même côté | réentraîner, après en avoir cherché la cause dans les données |
|
||||||
|
| `stable` | aucune des conditions précédentes | rien à faire | - |
|
||||||
|
|
||||||
|
## Réentraîner sans perdre le modèle en service
|
||||||
|
|
||||||
|
`ml_train` reste manuel parce que `train.py` écrase le modèle sans comparer ses métriques à
|
||||||
|
celles de l'ancien ([10-infra.md](10-infra.md)). Garder une copie avant de lancer :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$compose exec airflow-scheduler cp /opt/ml/state/models/lightgbm-consumption.txt \
|
||||||
|
/opt/ml/state/models/lightgbm-consumption.txt.avant
|
||||||
|
$compose exec airflow-apiserver airflow dags trigger ml_train
|
||||||
|
```
|
||||||
|
|
||||||
|
Comparer ensuite les métriques des deux derniers runs, que `train.py` enregistre dans le magasin
|
||||||
|
MLflow du volume (`/opt/ml/state/mlflow.db`). Aucune interface MLflow n'est servie sur la
|
||||||
|
machine : `ml/README.md` décrit `mlflow ui`. Si le nouveau modèle est moins bon, remettre la
|
||||||
|
copie en place : le prochain `ml_score` l'utilisera.
|
||||||
|
|
||||||
|
## Rétention et archives
|
||||||
|
|
||||||
|
`retention` supprime de la base les chunks de `reading` plus vieux que
|
||||||
|
`APP_READING_RETENTION_DAYS` (1 095 jours), **après** les avoir exportés en CSV gzip dans le
|
||||||
|
bucket Garage de l'environnement, chiffrés en SSE-C. Il est idempotent : une reprise ne réécrit
|
||||||
|
pas un objet déjà exporté et ne retrouve plus un chunk déjà supprimé.
|
||||||
|
|
||||||
|
- **La clé `GARAGE_SSE_KEY` du `.env` est la seule qui déchiffre les archives.** Garage ne la
|
||||||
|
garde pas. Perdue, les archives sont illisibles : elle se sauvegarde hors de la machine.
|
||||||
|
- Restaurer un chunk : `get_object` avec la clé SSE-C, `gunzip`, puis
|
||||||
|
`COPY reading FROM STDIN CSV HEADER`. La contrainte `uq_reading_source` refuse les doublons
|
||||||
|
([ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)).
|
||||||
|
|
||||||
|
## Reprendre après un incident
|
||||||
|
|
||||||
|
| Situation | Reprise |
|
||||||
|
|---|---|
|
||||||
|
| `mock_api_import` a manqué des heures | relancer à la main avec `--start-time` et `--end-time` sur la fenêtre manquante ; le module refuse toute fenêtre qui recouvre le CSV historique |
|
||||||
|
| `alertes` a échoué | relancer : les deux tâches sont idempotentes (`ON CONFLICT DO NOTHING`) |
|
||||||
|
| `derive` a échoué | relancer : un index d'unicité par fenêtre empêche les doublons |
|
||||||
|
| `retention` a échoué | relancer : idempotent, voir plus haut |
|
||||||
|
| `ml_score` en échec répété | lire les journaux du scheduler ; deux tentatives et un plafond de 30 minutes par passage |
|
||||||
@@ -17,13 +17,16 @@ contredisent, c'est l'ADR qui fait foi et la vue qui est en retard.
|
|||||||
| [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle |
|
| [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle |
|
||||||
| [50-cicd.md](50-cicd.md) | Orchestrateur `ci.yml`, gates bloquantes, e2e et charge, SonarCloud, Dependabot, ce qui manque |
|
| [50-cicd.md](50-cicd.md) | Orchestrateur `ci.yml`, gates bloquantes, e2e et charge, SonarCloud, Dependabot, ce qui manque |
|
||||||
| [60-observabilite.md](60-observabilite.md) | Métriques, Prometheus, alertes, tableaux de bord Grafana, ce qui manque |
|
| [60-observabilite.md](60-observabilite.md) | Métriques, Prometheus, alertes, tableaux de bord Grafana, ce qui manque |
|
||||||
|
| [70-pilotage.md](70-pilotage.md) | Runbook de pilotage des traitements automatisés : entraîner, lire la dérive, rejouer un DAG, rétention |
|
||||||
|
| [owasp-traceabilite.md](owasp-traceabilite.md) | Traçabilité OWASP Top 10 et API Top 10 : couvert, partiel, ouvert |
|
||||||
|
|
||||||
La CI/CD a son document : un orchestrateur et ses workflows de composant, c'est assez de
|
La CI/CD a son document : un orchestrateur et ses workflows de composant, c'est assez de
|
||||||
matière pour qu'une section de plus dans une autre vue devienne illisible. L'observabilité a le
|
matière pour qu'une section de plus dans une autre vue devienne illisible. L'observabilité a le
|
||||||
sien depuis l'issue #26, qui lui a donné de la matière : collecte, alertes et tableaux de bord.
|
sien depuis l'issue #26, qui lui a donné de la matière : collecte, alertes et tableaux de bord.
|
||||||
|
|
||||||
L'orchestration Airflow, elle, en a depuis les issues #115 et #116 : trois DAGs, leur image et
|
L'orchestration Airflow, elle, en a depuis les issues #115 et #116 : les sept DAGs, leur image et
|
||||||
leurs contraintes sont décrits dans [10-infra.md](10-infra.md).
|
leurs contraintes sont décrits dans [10-infra.md](10-infra.md), leur pilotage au quotidien dans
|
||||||
|
[70-pilotage.md](70-pilotage.md).
|
||||||
|
|
||||||
La sécurité applicative, elle, a désormais de la matière : la vue consolidée reste dans
|
La sécurité applicative, elle, a désormais de la matière : la vue consolidée reste dans
|
||||||
[00-vue-ensemble.md](00-vue-ensemble.md), le détail dans [20-backend.md](20-backend.md), la
|
[00-vue-ensemble.md](00-vue-ensemble.md), le détail dans [20-backend.md](20-backend.md), la
|
||||||
@@ -38,6 +41,11 @@ GitHub rend Mermaid nativement dans les fichiers `.md`. Un diagramme est donc du
|
|||||||
relit en revue, il se diffe, et il ne se périme pas dans un binaire que plus personne ne sait
|
relit en revue, il se diffe, et il ne se périme pas dans un binaire que plus personne ne sait
|
||||||
rouvrir six mois plus tard. Aucune image exportée, aucun `.drawio`, aucun `.png`.
|
rouvrir six mois plus tard. Aucune image exportée, aucun `.drawio`, aucun `.png`.
|
||||||
|
|
||||||
|
Une exception existe, et elle est connue : le schéma de données de
|
||||||
|
[40-data.md](40-data.md) est une image, `images/EnerVision-schema-donnees.png`, versionnée le 15/09,
|
||||||
|
quelques heures après l'adoption de la règle, sans que la revue le relève. Elle se relit à côté de la description des tables qui la suit,
|
||||||
|
qui fait foi ; la migrer en Mermaid reste à faire.
|
||||||
|
|
||||||
### Chaque section porte son statut
|
### Chaque section porte son statut
|
||||||
|
|
||||||
Une large part de la stack n'est pas écrite. Une vue qui mélange l'existant et la cible sans le
|
Une large part de la stack n'est pas écrite. Une vue qui mélange l'existant et la cible sans le
|
||||||
|
|||||||
@@ -43,15 +43,18 @@ lecture seule ; plusieurs lignes resteront à compléter une fois les endpoints
|
|||||||
| Refus de rétrograder ou désactiver le dernier administrateur actif | `app/services/user.py` | A04 Insecure Design |
|
| Refus de rétrograder ou désactiver le dernier administrateur actif | `app/services/user.py` | A04 Insecure Design |
|
||||||
| Amorçage du premier administrateur hors dépôt, mot de passe jamais dans `argv` ni dans Git | `app/cli.py` | A02, A05 |
|
| Amorçage du premier administrateur hors dépôt, mot de passe jamais dans `argv` ni dans Git | `app/cli.py` | A02, A05 |
|
||||||
| Réponse de l'API Mock bornée avant écriture : timeout, plafond de sites et de mesures, bornes physiques par grandeur, recopie des seuls champs attendus | `app/etl/mock_api_import.py` | API10 Unsafe Consumption of APIs |
|
| Réponse de l'API Mock bornée avant écriture : timeout, plafond de sites et de mesures, bornes physiques par grandeur, recopie des seuls champs attendus | `app/etl/mock_api_import.py` | API10 Unsafe Consumption of APIs |
|
||||||
| CI bloquante : format, lint avec règles Bandit, typage strict, tests avec seuil de couverture | `.github/workflows/backend.yml` | A06 Vulnerable and Outdated Components |
|
| CI bloquante : format, lint avec règles Bandit, typage strict, tests avec seuil de couverture, SAST Bandit à partir de MEDIUM, `pip-audit` sur le verrou du backend, `npm audit --audit-level=high` sur le frontend | `.github/workflows/backend.yml`, `ml.yml`, `frontend.yml` | A06 Vulnerable and Outdated Components |
|
||||||
| Terminaison TLS au frontal, redirection 80 vers 443, HSTS et CSP posés par le proxy, limitation de débit au frontal | `infra/proxy/conf.d/enervision.conf`, ADR 0007 | API8 Security Misconfiguration, A05 |
|
| Terminaison TLS au proxy de chaque stack, derrière un frontal SNI qui aiguille sans déchiffrer ; certificats Let's Encrypt par DNS-01 ; redirection 80 vers 443, HSTS et CSP posés par le proxy, limitation de débit sur l'adresse réelle du client (PROXY protocol) | `infra/proxy/conf.d/enervision.conf`, `infra/front/nginx.conf`, ADR 0007, ADR 0018 | API8 Security Misconfiguration, A05 |
|
||||||
|
|
||||||
Note sur A06 : le jeu de règles `S` de ruff, déjà actif dans `pyproject.toml`, est le portage des
|
Note sur A06 : le jeu de règles `S` de ruff, actif dans `pyproject.toml`, est le portage des
|
||||||
règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu'annonce l'EC01.
|
règles Bandit. Bandit lui-même a tout de même rejoint la CI le 21/09 (PR #121), bloquant à partir
|
||||||
|
de MEDIUM sur le backend et le ML, comme l'annonçait l'EC01 : les deux se recouvrent, redondance
|
||||||
|
assumée pour disposer d'un rapport SAST dédié et d'une version épinglée.
|
||||||
|
|
||||||
Note sur API8 : le transport est couvert, le certificat ne l'est qu'à moitié. Tant qu'aucun nom de
|
Note sur API8 : le transport et le certificat sont couverts. La machine n'a qu'une adresse privée,
|
||||||
domaine public ne résout vers la machine, le défi HTTP-01 de Let's Encrypt ne peut pas aboutir et
|
le défi HTTP-01 ne peut pas aboutir : les certificats Let's Encrypt sont obtenus par défi DNS-01,
|
||||||
le certificat servi reste auto-signé. Le chemin ACME est livré et documenté, pas exercé.
|
sur un domaine public dont la zone publie les enregistrements de validation (ADR 0018). L'auto-signé
|
||||||
|
ne sert plus qu'au poste de développement et aux tests e2e.
|
||||||
|
|
||||||
## Non couvert, et pourquoi
|
## Non couvert, et pourquoi
|
||||||
|
|
||||||
@@ -60,9 +63,9 @@ le certificat servi reste auto-signé. Le chemin ACME est livré et documenté,
|
|||||||
| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` et `GET /recommendations/{recommendation_id}` répondent à tout compte `lecteur` pour n'importe quel site ou recommandation, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. |
|
| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` et `GET /recommendations/{recommendation_id}` répondent à tout compte `lecteur` pour n'importe quel site ou recommandation, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. |
|
||||||
| **API4, lectures de séries temporelles** | **partiel** | `GET /readings` plafonne la fenêtre temporelle (90 jours) et la pagination (`limit` ≤ 2000), voir plus haut. Reste ouvert : pagination en `limit`/`offset` simple plutôt qu'en curseur (un `offset` élevé sur une fenêtre dense reste coûteux), et aucun `statement_timeout` au niveau de la connexion pour borner une requête individuelle si les plafonds au-dessus s'avéraient insuffisants. |
|
| **API4, lectures de séries temporelles** | **partiel** | `GET /readings` plafonne la fenêtre temporelle (90 jours) et la pagination (`limit` ≤ 2000), voir plus haut. Reste ouvert : pagination en `limit`/`offset` simple plutôt qu'en curseur (un `offset` élevé sur une fenêtre dense reste coûteux), et aucun `statement_timeout` au niveau de la connexion pour borner une requête individuelle si les plafonds au-dessus s'avéraient insuffisants. |
|
||||||
| **API10 Unsafe Consumption of APIs** | **partiel, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse est traitée comme une entrée hostile par `app/etl/mock_api_import.py`, son seul consommateur à ce jour : les quatre garde-fous attendus sont en place, voir la ligne correspondante plus haut. Reste ouvert : 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 `APP_MOCK_API_BASE_URL` n'impose pas `https`, donc les identifiants Basic partiraient en clair sur une URL en `http`. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. |
|
| **API10 Unsafe Consumption of APIs** | **partiel, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse est traitée comme une entrée hostile par `app/etl/mock_api_import.py`, son seul consommateur à ce jour : les quatre garde-fous attendus sont en place, voir la ligne correspondante plus haut. Reste ouvert : 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 `APP_MOCK_API_BASE_URL` n'impose pas `https`, donc les identifiants Basic partiraient en clair sur une URL en `http`. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. |
|
||||||
| **A08 Software and Data Integrity Failures** | **partiel** | La CI vérifie le code mais n'analyse ni les dépendances ni les images. `.terraform.lock.hcl` reste ignoré par git, ce qui contredit une chaîne d'approvisionnement maîtrisée. |
|
| **A08 Software and Data Integrity Failures** | **partiel** | La CI audite les dépendances du backend (`pip-audit` sur le verrou figé) et du frontend (`npm audit`), et les `.terraform.lock.hcl` sont versionnés. Restent ouverts : les verrous ML et Airflow ne sont pas audités (une CVE MEDIUM de `apache-airflow-providers-smtp` y reste invisible), aucune image n'est analysée, aucun scan de secrets ne tourne en CI, et les images sont reconstruites sur la machine plutôt que promues par empreinte. |
|
||||||
| **A10 Server-Side Request Forgery** | **sans objet aujourd'hui** | Aucune URL sortante n'est pilotée par une donnée utilisateur. Le jour où l'adresse d'une source devient un champ de configuration, il faudra une liste blanche de schémas et d'hôtes, sans suivi de redirection. |
|
| **A10 Server-Side Request Forgery** | **sans objet aujourd'hui** | Aucune URL sortante n'est pilotée par une donnée utilisateur. Le jour où l'adresse d'une source devient un champ de configuration, il faudra une liste blanche de schémas et d'hôtes, sans suivi de redirection. |
|
||||||
| **Cantonnement des accès ETL et ML** | **dette assumée** | Le compte applicatif porte l'identité, le rôle PostgreSQL porterait le cantonnement. Voir ADR 0003. Plus coûteuse depuis Airflow (#115) : ce service publie le port 8080, détient les identifiants Postgres complets (`ML_DATABASE_URL`, mêmes que le backend) et permet de déclencher l'exécution de code depuis son interface. Un compte Airflow compromis atteint donc toute la base, pas seulement `reading`/`site`. Aggravée par #116 : le conteneur reçoit aussi `DATABASE_URL` et exécute le code du backend en sous-processus (ADR 0008). Atténuations en place : le compte admin Airflow est distinct des `app_user` et son mot de passe passe par l'environnement, jamais par `argv` ; et l'`APP_SECRET_KEY` donnée à Airflow est distincte de celle de l'API, pour qu'une compromission ne livre pas la clé de signature des JWT. |
|
| **Cantonnement des accès ETL et ML** | **dette assumée** | Le compte applicatif porte l'identité, le rôle PostgreSQL porterait le cantonnement. Voir ADR 0003. Plus coûteuse depuis Airflow (#115) : ce service publie le port 8080 (sur le poste de développement ; sur la machine, sur la boucle locale seulement), détient les identifiants Postgres complets (`ML_DATABASE_URL`, mêmes que le backend) et permet de déclencher l'exécution de code depuis son interface. Un compte Airflow compromis atteint donc toute la base, pas seulement `reading`/`site`. Aggravée par #116 : le conteneur reçoit aussi `DATABASE_URL` et exécute le code du backend en sous-processus (ADR 0008). Atténuations en place : le compte admin Airflow est distinct des `app_user` et son mot de passe passe par l'environnement, jamais par `argv` ; et l'`APP_SECRET_KEY` donnée à Airflow est distincte de celle de l'API, pour qu'une compromission ne livre pas la clé de signature des JWT. |
|
||||||
| **Non-répudiation de l'audit** | **dette assumée** | Les déclencheurs arrêtent les accidents, pas un compte détenant `ALTER TABLE`. Voir ADR 0004. |
|
| **Non-répudiation de l'audit** | **dette assumée** | Les déclencheurs arrêtent les accidents, pas un compte détenant `ALTER TABLE`. Voir ADR 0004. |
|
||||||
|
|
||||||
## Ce qu'il faut répondre, et ne pas répondre
|
## Ce qu'il faut répondre, et ne pas répondre
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
# EC02 · Management de projet : rapport collectif
|
||||||
|
|
||||||
|
**Groupe 3 (HEADL_015B) · Projet EnerVision.**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Fichier source** | `EADL26_EC02 - Rapport Collectif HEADL_015B-G3.md`, encodage UTF-8, aucun média externe |
|
||||||
|
| **Version figée** | `EADL26_EC02 - Rapport Collectif HEADL_015B-G3.pdf`, produite par la chaîne Markdown → HTML → CSS de pagination → PDF |
|
||||||
|
| **Dépôt** | Devoir Teams, à côté du ZIP du dépôt Git, **et** dans le dépôt Git lui-même (`docs/livrables/EC02/`) |
|
||||||
|
| **Échéance** | Vendredi 25/09/2026, 9h00 (gel technique) |
|
||||||
|
| **Relevé** | **24/09/2026 à 14h25**, sur le commit gelé `9f343e9` (`dev` = `main`) et par l'API GitHub : dépôt, tracker, board, jalons, PR et revues relevés au même instant, heures en heure locale (CEST) |
|
||||||
|
|
||||||
|
Chaque chiffre de ce rapport est reproductible par une commande citée en fin de document : le
|
||||||
|
critère officiel est un compte rendu d'activité « complet et **honnête** ». Les chiffres du
|
||||||
|
rapport du 23/09 (board du 18/09, tracker du 21/09) sont remplacés, pas complétés.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Organisation de l'équipe
|
||||||
|
|
||||||
|
Le pilotage passe par un **GitHub Project** (« EnerVision », projet n°2), avec assignation
|
||||||
|
nominative, et par le dépôt `ProjetPiscine_EnerVision`, branche d'intégration `dev`, branche de
|
||||||
|
production `main`.
|
||||||
|
|
||||||
|
### Activité par membre
|
||||||
|
|
||||||
|
| Membre | Compte GitHub | Commits sur `dev`, hors merges | PR mergées, auteur principal | PR mergées par lui | Board : Done / En cours / Todo | Domaines observés dans ses PR |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Johan LEROY | `JohanLeroy` | 172 | 34 | 59 | 36 / 0 / 0 | Socle backend et sécurité, moteur de règles, vues frontend, déploiement et environnements, CI, supervision, stockage objet |
|
||||||
|
| Dorian PESCE | `phyri0s` | 42 | 11 | 11 | 16 / 0 / 0 | Terraform k3s, endpoints, pipeline LightGBM, scoring, alertes internes, DAGs ML, DAST, en-tête CORP, réconciliation des sources |
|
||||||
|
| Inès ZANG | `ineszang` | 54 | 4 | 5 | 5 / 2 / 0 | Terraform initial, pipeline CI, SonarCloud, administration du dépôt, procédures de déploiement |
|
||||||
|
| Meryem EL GHAM | `Meryemel-gham` | 23 | 6 | 2 | 7 / 2 / 0 | Schéma de données, imports historique et API Mock, DAGs d'import |
|
||||||
|
| Valentin DE FARIA RODRIGUES | `ValentinDeFaria` | 20 | 8 | 4 | 12 / 0 / 0 | Frontend et ses tests, auth frontend, audit de dépendances, supervision des capteurs, registre MLflow, amorce Garage |
|
||||||
|
| Dependabot | - | 12 | 12 | - | - | Mises à jour de dépendances |
|
||||||
|
| *(remontées `dev` → `main`)* | - | - | 6 | - | - | - |
|
||||||
|
| *(non assigné)* | - | - | - | - | 1 / 0 / 1 | - |
|
||||||
|
|
||||||
|
Totaux : **323 commits** hors merges sur `dev` (458 avec merges), **81 PR mergées**, 69 éléments
|
||||||
|
au board. Méthode : l'**auteur principal** d'une PR est l'auteur majoritaire des commits de sa
|
||||||
|
branche, qui peut différer du membre qui l'a ouverte. Les six remontées de `dev` vers `main` ne sont attribuées à
|
||||||
|
personne. Le nombre de commits mesure une activité, pas une valeur : les pratiques de découpage
|
||||||
|
diffèrent d'un membre à l'autre.
|
||||||
|
|
||||||
|
**Correspondances nom / identifiant.** Établies par Git : `Dorian`, `Dorian PESCE` et
|
||||||
|
`Phyrios` sont le compte `phyri0s` ; `ineszang` et `ineszang44` partagent la même adresse,
|
||||||
|
`Valentin` et `valentin` aussi.
|
||||||
|
|
||||||
|
**Rôles principaux** (Tech Lead, Cloud/DevOps, Data & IA, Fullstack Dev, PO). Seul celui de
|
||||||
|
Tech Lead a été nommé au départ ; les autres se lisent dans les PR de chacun :
|
||||||
|
|
||||||
|
| Membre | Rôle principal exercé |
|
||||||
|
|---|---|
|
||||||
|
| Johan LEROY | **Tech Lead** : architecture, intégration, sécurité, déploiement |
|
||||||
|
| Dorian PESCE | **Data & IA** : modèle LightGBM, DAGs ML, scoring, scan DAST |
|
||||||
|
| Inès ZANG | **Cloud / DevOps** : Terraform initial, pipeline CI, SonarCloud, administration du dépôt |
|
||||||
|
| Meryem EL GHAM | **Data** : schéma de données, imports historique et API Mock, DAGs d'import |
|
||||||
|
| Valentin DE FARIA RODRIGUES | **Fullstack Dev** : frontend et ses tests, supervision des capteurs, registre MLflow |
|
||||||
|
|
||||||
|
Le rôle de **PO** n'a pas eu de titulaire nommé : les arbitrages de périmètre ont été pris aux
|
||||||
|
points d'avancement, dont la coupe du 21/09. Le formateur demandait une **rotation** des rôles
|
||||||
|
sur les deux semaines : elle n'a pas eu lieu. Chacun est resté sur son domaine d'origine, ce qui a
|
||||||
|
favorisé la vitesse au détriment de la polyvalence.
|
||||||
|
|
||||||
|
### RACI
|
||||||
|
|
||||||
|
Reconstitué depuis l'historique des PR mergées, chaque PR étant rattachée aux chantiers dont elle
|
||||||
|
touche les fichiers : *Responsible* = qui écrit, *Accountable* = qui valide le merge,
|
||||||
|
*Consulted* = qui relit (revue ou commentaire), *Informed* = toute l'équipe, par le board et les
|
||||||
|
points d'avancement.
|
||||||
|
|
||||||
|
| Chantier | Responsible | Accountable | Consulted | Informed |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Backend / API | les cinq membres | `ineszang`, `JohanLeroy`, `Meryemel-gham`, `phyri0s` | `JohanLeroy`, `Meryemel-gham`, `phyri0s`, `ValentinDeFaria` | équipe |
|
||||||
|
| Frontend | `ineszang`, `JohanLeroy`, `phyri0s`, `ValentinDeFaria` | `ineszang`, `JohanLeroy`, `phyri0s`, `ValentinDeFaria` | `JohanLeroy`, `Meryemel-gham`, `phyri0s` | équipe |
|
||||||
|
| Data & ML | `JohanLeroy`, `Meryemel-gham`, `phyri0s`, `ValentinDeFaria` | les cinq membres | `JohanLeroy`, `phyri0s` | équipe |
|
||||||
|
| Infra / CI-CD | les cinq membres | les cinq membres | `JohanLeroy`, `Meryemel-gham`, `phyri0s` | équipe |
|
||||||
|
| Sécurité | `JohanLeroy`, `Meryemel-gham`, `phyri0s`, `ValentinDeFaria` | `JohanLeroy`, `phyri0s` | `JohanLeroy`, `Meryemel-gham`, `phyri0s` | équipe |
|
||||||
|
|
||||||
|
Membres cités par ordre alphabétique de leur compte. Lecture : chaque chantier compte au moins
|
||||||
|
quatre contributeurs, plusieurs membres ont validé des merges sur chacun, et chacun a été relu
|
||||||
|
par au moins deux membres. Le RACI n'a pas été posé en amont : il est reconstitué depuis les
|
||||||
|
merges et les revues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Méthodologie, backlog, user stories
|
||||||
|
|
||||||
|
Constat factuel tiré du GitHub Project, du tracker d'issues et du dépôt :
|
||||||
|
|
||||||
|
- **Méthodologie** : **Kanban à jalons**, pratiqué sans avoir été nommé en amont. Le board a
|
||||||
|
**3 colonnes** (`Todo` / `In progress` / `Done`), découpé en **2 itérations** (« Première
|
||||||
|
semaine », « Seconde semaine ») et **6 jalons datés**.
|
||||||
|
- **Priorisation MoSCoW** appliquée à chaque ticket, et **respectée dans les faits** : au
|
||||||
|
24/09, **56 `Must` faits sur 56**, 5 `Should` sur 5, 4 `Could` sur 6. Au 18/09, 66 % des
|
||||||
|
`Must` étaient faits contre 0 % des `Should` et des `Could` : aucun ticket de confort n'a été
|
||||||
|
pris avant un ticket essentiel.
|
||||||
|
- **Estimation en taille de tee-shirt** : 48 S, 13 M, 5 XS, 3 sans taille.
|
||||||
|
- **Traçabilité ticket → PR → commit** : chaque ticket livré porte ses PR liées.
|
||||||
|
- **Revue de code avant merge.** **55 des 62 PR de fonctionnalité (89 %)** ont été relues par
|
||||||
|
un autre membre avant merge, par revue formelle ou commentaire ; les remontées de `dev` vers
|
||||||
|
`main` ne portent que des PR déjà relues.
|
||||||
|
- **Intégration.** **81 PR mergées** : 62 vers `dev`, 19 vers `main` (6 remontées, 1 réglage
|
||||||
|
Sonar, 12 Dependabot). Les cinq membres ont mergé des PR.
|
||||||
|
- **Décisions écrites.** **20 ADR** versionnées dans `docs/adr/`, dont deux procédures de
|
||||||
|
déploiement (0011, 0012) qui relèvent davantage de la note d'exécution que de l'ADR.
|
||||||
|
- **Points d'avancement** les 15, 17, 18 et 21/09, chacun terminé par une décision ; ceux du 15
|
||||||
|
et du 18/09 sont versionnés dans `docs/dailies/`. Le compte rendu du 18/09 a été rédigé après
|
||||||
|
coup, le 21/09.
|
||||||
|
- **Étiquettes par domaine** sur les issues (`feature`, `backend`, `frontend`, `ml`, `infra`,
|
||||||
|
`ci/cd`, `test`, `securite`, `pipeline ETL`, `accessibility`).
|
||||||
|
- **User stories formalisées** (« en tant que... je veux... afin de... ») : non retrouvées
|
||||||
|
telles quelles, le besoin fonctionnel est porté par le corps des issues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Planning, jalons, gestion des risques
|
||||||
|
|
||||||
|
### Jalons internes du projet
|
||||||
|
|
||||||
|
À ne pas confondre avec la numérotation J1 à J10 du calendrier de formation : ce sont deux
|
||||||
|
échelles différentes.
|
||||||
|
|
||||||
|
| Jalon projet | Échéance (API) | Fermées / total au 24/09 | État |
|
||||||
|
|---|---|---|---|
|
||||||
|
| J1 · Environnement et dépôt | 14/09 | 5/5 | clos le 15/09 |
|
||||||
|
| J2 · Périmètre et choix technologiques | 15/09 | 4/4 | clos le 16/09 |
|
||||||
|
| J3 · Ingestion et backend | 21/09 | 22/22 | tout fermé, jalon laissé ouvert |
|
||||||
|
| J4 · Architecture, sécurité, frontend | 22/09 | 24/24 | tout fermé, jalon laissé ouvert |
|
||||||
|
| J5 · Robustesse et livrables | 23/09 | 5/6 | reste #154 (déclaration IA et RGPD, portée par ce rapport) |
|
||||||
|
| J6 · Amélioration possible | 28/09 | 7/9 | créé le 21/09 pour le périmètre coupé (§5) ; restent #11 et #54 |
|
||||||
|
|
||||||
|
Le point d'avancement du 21/09 donnait le 18/09 et le 21/09 pour J3 et J4 ; l'API donne
|
||||||
|
aujourd'hui le 21/09 et le 22/09. L'API ne garde pas l'historique des échéances : l'écart est
|
||||||
|
signalé, pas expliqué.
|
||||||
|
|
||||||
|
### Calendrier institutionnel
|
||||||
|
|
||||||
|
| Jalon | Contenu | Date |
|
||||||
|
|---|---|---|
|
||||||
|
| J1 | Rendu EC01, dossier de conception individuel | fait, 14/09 |
|
||||||
|
| J9 | Oral EC01, 15 min + ~10 min de questions | jeudi 24/09 |
|
||||||
|
| J10 | Gel technique 9h00, rendu EC02 à EC06, oral EC02 (15 min + ~5 min de vidéo + ~5 min de questions) | vendredi 25/09 |
|
||||||
|
|
||||||
|
### Risques identifiés et leur traitement
|
||||||
|
|
||||||
|
| Risque | Impact | Statut au 24/09 |
|
||||||
|
|---|---|---|
|
||||||
|
| **`main` en retard sur `dev`** | `main` est la branche par défaut et celle de la production | **Traité.** Six remontées (#125, #152, #160, #161, #163, #165) ; au gel, `main` et `dev` portent le même commit. Sept déploiements de production réussis, le dernier sur le commit gelé |
|
||||||
|
| Tickets sans assigné (17 au 21/09) | Aucun responsable identifié | **Traité par arbitrage** : coupe du 21/09 (§5), puis assignation. Restent 2 issues ouvertes sans assigné, #55 et #154 |
|
||||||
|
| Jalon J5 sans assigné (#41, #45, #46, #47) | Preuves attendues pour EC03 et EC04 | **Traité** : les quatre livrés et assignés, DAST (#140), tests d'intégration (#147), e2e Playwright et charge k6 (#148) |
|
||||||
|
| Aucun scan de code dans le pipeline | Note DevSecOps EC03 / EC04 | **Traité** : SAST Bandit bloquant (#121), DAST OWASP ZAP (#140). **Restent hors CI** : Trivy et gitleaks, joués à la main pour le rapport EC04 |
|
||||||
|
| Aucun déploiement | Attendu explicite d'EC03 et EC04 | **Traité et constaté** : trois environnements sur la machine du groupe (production sur `main`, recette sur `dev`, dev à la demande), certificats Let's Encrypt, runner auto-hébergé (ADR 0009, 0017, 0018) |
|
||||||
|
| Montée de version majeure d'Airflow par Dependabot (#135) | Provisionnement et déploiement cassés | **Traité le jour même** (#143) |
|
||||||
|
| Trois PR immobilisées par un quality gate mal configuré | Blocage de la chaîne de merge | **Traité le 18/09**, en configuration et non par contournement |
|
||||||
|
| Mémoire de la machine (8 Go) insuffisante pour trois stacks | Arrêts par manque de mémoire | **Traité** : portée à 32 Go sur demande à l'école (ADR 0017) |
|
||||||
|
| Chiffrement au repos (#42) | Données en clair sur le disque | **Partiel, découvert le 24/09** : la machine est un conteneur LXC où LUKS est impossible ; seules les archives sont chiffrées (SSE-C, ADR 0020), demande adressée à l'école |
|
||||||
|
| Production sans approbation humaine, branches non protégées | Un push non relu part en production | **Ouvert** : annoncés par l'ADR 0009, laissés à poser par l'ADR 0014, jamais activés ; seule l'administratrice du dépôt peut le faire |
|
||||||
|
| Services hors dépôt sur la machine : k3s et trois serveurs Vault, installés depuis une branche de travail non fusionnée | Surface exposée que le code livré ne documente pas : l'API k3s et les trois Vault écoutent sur toutes les interfaces, k3s redémarre en boucle depuis le 17/09 | **Découvert le 24/09**, à arbitrer par l'administratrice : le code livré n'en dépend pas (rapport EC04, constat 1) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Compte rendu d'activité honnête
|
||||||
|
|
||||||
|
### Indicateurs, depuis la baseline
|
||||||
|
|
||||||
|
**Baseline : 57 issues créées le 14/09**, jour 1. Série quotidienne relevée par l'API :
|
||||||
|
|
||||||
|
| Jour | Issues créées | Périmètre | Fermées | Cumul fermées | Ouvertes | PR mergées | Cumul PR |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 14/09 | 57 | 57 | 5 | 5 | 52 | 3 | 3 |
|
||||||
|
| 15/09 | 1 | 58 | 10 | 15 | 43 | 7 | 10 |
|
||||||
|
| 16/09 | 1 | 59 | 8 | 23 | 36 | 10 | 20 |
|
||||||
|
| 17/09 | 6 | 65 | 10 | 33 | 32 | 9 | 29 |
|
||||||
|
| 18/09 | 2 | 67 | 6 | 39 | 28 | 9 | 38 |
|
||||||
|
| 21/09 | 5 | 72 | 16 | 55 | 17 | 11 | 49 |
|
||||||
|
| 22/09 | 1 | 73 | 2 | 57 | 16 | 18 | 67 |
|
||||||
|
| 23/09 | 3 | 76 | 11 | 68 | 8 | 12 | 79 |
|
||||||
|
| 24/09 | 0 | 76 | 4 | 72 | 4 | 2 | 81 |
|
||||||
|
|
||||||
|
| Indicateur | 16/09 | 18/09 | 24/09 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Board : Done / In progress / Todo | 19 / 6 / 26 | 33 / 6 / 21 (16h) | **66 / 2 / 1** |
|
||||||
|
| `Must` faits | 45 % | 66 % | **100 %** (56/56) |
|
||||||
|
| `Should` et `Could` faits | 0 % | 0 % | 100 % et 67 % |
|
||||||
|
| Issues fermées / périmètre | 23 / 59 | 39 / 67 | **72 / 76** |
|
||||||
|
|
||||||
|
**Deux lectures à défendre.** La priorisation est tenue dans les faits : le premier `Should`
|
||||||
|
n'a été fermé que le 23/09 à 11h10, quand 54 des 56 `Must` l'étaient déjà. Et le périmètre a dérivé de
|
||||||
|
57 à 76 issues (+33 %), dont 19 créées en cours de route : la dérive a été absorbée par la coupe
|
||||||
|
du 21/09 (§5) plutôt que laissée ouverte. Le 22/09 illustre la limite du comptage : 2 issues
|
||||||
|
fermées, mais 18 PR mergées, le déploiement continu et Terraform, les plus lourdes du projet.
|
||||||
|
|
||||||
|
### Livré depuis le 18/09 à 16h00
|
||||||
|
|
||||||
|
| PR | Mergée le | Contenu |
|
||||||
|
|---|---|---|
|
||||||
|
| #113, #118 | 18 et 21/09 | Détection des alertes internes ; entraînement et scoring LightGBM orchestrés par deux DAGs |
|
||||||
|
| #114, #124 | 21/09 | Moteur de règles de recommandation (ADR 0006) ; DAG d'alertes et de recommandations (ADR 0008) |
|
||||||
|
| #112, #138, #146 | 21 et 22/09 | Import depuis l'API Mock borné ; import historique, puis import horaire, orchestrés par Airflow |
|
||||||
|
| #107, #123 | 21 et 22/09 | Supervision des capteurs par site ; enregistrement du modèle dans le registre MLflow |
|
||||||
|
| #117, #121 | 21/09 | Reverse proxy Nginx et TLS (ADR 0007) ; SAST Bandit et vue CI/CD |
|
||||||
|
| #136, #137 | 21/09 | Flux d'alertes du tableau de bord ; vue recommandations |
|
||||||
|
| #139, #143, #144 | 22/09 | Déploiement continu, runner auto-hébergé (ADR 0009) ; réalignement Airflow 3 ; Terraform provisionne la machine (ADR 0010) |
|
||||||
|
| #140 | 22/09 | Scan dynamique OWASP ZAP de l'API |
|
||||||
|
| #147 | 22/09 | Tests d'intégration API, base et ML ; surveillance de dérive (ADR 0013) |
|
||||||
|
| #148 | 23/09 | CI unifiée (ADR 0014), e2e Playwright et charge k6 (ADR 0015), supervision Prometheus, Alertmanager, Grafana (ADR 0016) |
|
||||||
|
| #149 | 23/09 | En-tête `Cross-Origin-Resource-Policy` sur toutes les réponses |
|
||||||
|
| #151 | 23/09 | Troisième environnement, `dev`, déployé à la demande (ADR 0017) |
|
||||||
|
| #156, #159 | 23/09 | Noms publics, certificats Let's Encrypt par DNS-01, frontal SNI (ADR 0018) |
|
||||||
|
| #162 | 23/09 | Réconciliation des deux sources de relevés |
|
||||||
|
| #164 | 24/09 | Garage par environnement, rétention exportée des relevés, chiffrement des archives (ADR 0019, 0020) |
|
||||||
|
| 6 remontées, #142, 12 Dependabot | 21 au 24/09 | Mises en production, analyse Sonar sautée sur les PR Dependabot, mises à jour de dépendances |
|
||||||
|
|
||||||
|
### Ce qui n'a pas été livré, et pourquoi
|
||||||
|
|
||||||
|
- **#11 Responsive et #54 comparateur de scénarios** : `Could`, en cours au gel, jalon J6.
|
||||||
|
Démonstration sur poste, sans usage mobile dans le scénario du client pilote.
|
||||||
|
- **#55 Bouton de pic fictif** : ni assigné, ni au board, rien dans le code.
|
||||||
|
- **#43 Accessibilité** : fermée en doublon le 23/09, au motif qu'elle serait couverte par les
|
||||||
|
tests Playwright ; **aucun test d'accessibilité n'existe**. La fermeture est à corriger dans
|
||||||
|
l'outil, pas à défendre.
|
||||||
|
- **#42 Chiffrement au repos** : fermée comme faite, livrée **en partie** (archives seulement),
|
||||||
|
pour une raison d'infrastructure découverte le 24/09 (§3).
|
||||||
|
- **Loki, Trivy et gitleaks en CI, approbation de la production** : prévus ou annoncés, non
|
||||||
|
faits.
|
||||||
|
- **Rotation des rôles** : elle n'a pas eu lieu (§1).
|
||||||
|
|
||||||
|
Lecture honnête : tout ticket assigné à quelqu'un a été mené au bout ou reste en cours au gel.
|
||||||
|
Le retard du 21/09 n'était pas un problème d'exécution individuelle mais de **répartition** : il
|
||||||
|
a été traité par la coupe et l'assignation, puis rattrapé. Les faiblesses restantes sont de
|
||||||
|
rigueur de processus, pas de livraison : fermetures d'issues trop généreuses (#42, #43) et
|
||||||
|
réglages de protection jamais activés.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Périmètre coupé
|
||||||
|
|
||||||
|
**Un périmètre coupé et argumenté est un acte de management ; une issue laissée ouverte sans
|
||||||
|
rien est un trou.** La coupe a été faite le 21/09, jour de l'échéance du J4, et tracée dans
|
||||||
|
l'outil : un jalon **J6 « Amélioration possible »**, échéance 28/09, donc après le gel. Elle a
|
||||||
|
servi à ordonner, pas à renoncer : une fois les `Must` faits, une partie du J6 a été livrée
|
||||||
|
avant le gel.
|
||||||
|
|
||||||
|
| Issue | Sujet | Raison de la coupe au 21/09 | Au gel |
|
||||||
|
|---|---|---|---|
|
||||||
|
| #11 | Responsive | Démonstration sur poste, aucun usage mobile | en cours, non livré |
|
||||||
|
| #24 | MinIO, couche bronze | La charge brute est déjà en base (`raw_data`) | **livré autrement** : Garage, jugé plus léger (ADR 0019) |
|
||||||
|
| #26 | Monitoring Prometheus et Grafana | Classé bonus par le sujet | **livré** (#148, ADR 0016) |
|
||||||
|
| #36 | Rétention des données | Jeu de démonstration borné | **livré** : export vers Garage puis suppression (#164) |
|
||||||
|
| #42 | Chiffrement au repos | Données synthétiques, priorité au chiffrement en transit | **partiel** : archives chiffrées, base en clair (ADR 0020) |
|
||||||
|
| #43 | Accessibilité | Hors des critères de notation technique | fermée en doublon, **non livrée** |
|
||||||
|
| #54 | Comparateur de scénarios | Confort (`Could`) | en cours, non livré |
|
||||||
|
|
||||||
|
Les tests e2e (#46) et de charge (#47), dont la coupe était proposée le 21/09, ont finalement
|
||||||
|
été livrés (#148). Deux sacrifices restent assumés : **Big Data**, hors de portée dans le temps
|
||||||
|
imparti, et **RPA avancé**, au-delà de l'orchestration Airflow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Usage de l'intelligence artificielle
|
||||||
|
|
||||||
|
Déclaration exigée par le formateur : outils utilisés, tâches réalisées, valeur ajoutée,
|
||||||
|
limites constatées, vérifications humaines.
|
||||||
|
|
||||||
|
### Déclaration de Johan LEROY
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Outil** | Claude Code (Anthropic), en assistant de développement dans le terminal et l'IDE |
|
||||||
|
| **Tâches** | Aide à la rédaction de code backend, d'infrastructure et de tests, relecture de PR en amont de la revue humaine, rédaction et mise à jour de la documentation d'architecture et des ADR, analyse d'écarts entre le dépôt et les attendus, relevés chiffrés de ce rapport |
|
||||||
|
| **Valeur ajoutée** | Vitesse sur le travail répétitif (gabarits de tests, migrations, documentation), et surtout **recoupement systématique** : détection d'incohérences entre documentation et code qu'une relecture humaine laisse passer |
|
||||||
|
| **Limites constatées** | Des **chiffres plausibles mais faux** quand ils ne sont pas recalculés ; des **références à des fichiers inexistants** ; une tendance à **présenter comme acquis** ce qui n'est que prévu (l'approbation de la production, annoncée par deux ADR, n'a jamais été activée) ; des **horodatages en UTC recopiés comme heure locale** |
|
||||||
|
| **Vérifications humaines** | Tout code généré passe par la CI (lint, typage, tests, couverture, SAST) et par une revue de PR. Tout chiffre publié est réobtenu par une commande (`gh`, `git log`) au moment de la rédaction. Les décisions d'architecture restent prises et signées en ADR par un humain |
|
||||||
|
|
||||||
|
### Déclarations des autres membres
|
||||||
|
|
||||||
|
Non transmises au moment du gel. Le gabarit reste celui de la déclaration ci-dessus : outil,
|
||||||
|
tâches, valeur ajoutée, limites, vérifications.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Licences logicielles
|
||||||
|
|
||||||
|
**Licence du dépôt** : aucun fichier `LICENSE` au gel. Faute de licence explicite, le code reste
|
||||||
|
sous le régime par défaut : tous droits réservés à ses auteurs, aucune réutilisation accordée.
|
||||||
|
|
||||||
|
**Dépendances, relevées le 24/09** par `pip-licenses` (verrous figés, sans dépendances de dev)
|
||||||
|
et `license-checker --production` :
|
||||||
|
|
||||||
|
| Périmètre | Résultat |
|
||||||
|
|---|---|
|
||||||
|
| Backend, ML, Airflow (Python) | MIT, BSD, Apache 2.0 et PSF pour l'essentiel. **Aucune GPL ni AGPL embarquée.** À noter : `psycopg` (ML) sous LGPL 3.0, utilisé comme bibliothèque, sans obligation sur le code appelant ; `certifi` et `pathspec` sous MPL 2.0, copyleft limité au fichier, non modifiés ; `text-unidecode` (Airflow) sous double licence Artistic ou GPL, retenue sous Artistic |
|
||||||
|
| Frontend, dépendances de production | 12 paquets : 10 MIT, 1 Apache 2.0, 1 0BSD |
|
||||||
|
|
||||||
|
**Composants exécutés à côté du produit**, chacun dans son conteneur, sans modification :
|
||||||
|
|
||||||
|
| Composant | Rôle | Licence |
|
||||||
|
|---|---|---|
|
||||||
|
| Nginx | Reverse proxy, frontal SNI | BSD 2-Clause |
|
||||||
|
| PostgreSQL · TimescaleDB | Base, séries temporelles | PostgreSQL License · Apache 2.0 pour le cœur, Timescale License pour certaines fonctions de l'image utilisée |
|
||||||
|
| Apache Airflow, MLflow | Orchestration, registre de modèles | Apache 2.0 |
|
||||||
|
| Prometheus, Alertmanager, exporteurs, cAdvisor | Supervision | Apache 2.0 |
|
||||||
|
| **Grafana, Garage, k6** | Tableaux de bord, stockage objet, tirs de charge en CI | **AGPL 3.0** |
|
||||||
|
| acme.sh | Certificats Let's Encrypt | GPL 3.0 |
|
||||||
|
| Mailpit | Courriel de développement et d'alerte | MIT |
|
||||||
|
| Terraform | Infrastructure as code | **BUSL 1.1** : usage interne autorisé, redistribution concurrente interdite |
|
||||||
|
|
||||||
|
Les composants AGPL et GPL tournent sans modification, dans des conteneurs séparés : ils
|
||||||
|
n'imposent rien au code du produit. Point de cohérence : l'ADR 0019 écarte MinIO en citant
|
||||||
|
notamment sa licence AGPL, que Garage partage ; les autres raisons de l'ADR restent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Anonymisation et conformité RGPD
|
||||||
|
|
||||||
|
- **Les données du projet sont synthétiques.** Les séries de consommation proviennent des CSV
|
||||||
|
fournis par l'organisme de formation et de l'API Mock simulée. Aucune donnée de consommation
|
||||||
|
réelle d'un client identifiable n'est présente dans le dépôt.
|
||||||
|
- **Les sites sont désignés par identifiants** (`SITE001` à `SITE007`), sans raison sociale ni
|
||||||
|
adresse.
|
||||||
|
- **Les comptes applicatifs** de test utilisent des adresses de domaine fictif et des mots de
|
||||||
|
passe de test. Le compte du scan DAST est un compte `lecteur` jetable.
|
||||||
|
- **Aucun secret dans le dépôt, vérifié** : scan gitleaks sur tout l'historique le 24/09, 342
|
||||||
|
commits hors merges, 8 constats, tous faux positifs après tri ligne à ligne ; aucun `.env`,
|
||||||
|
clé, certificat ni state Terraform jamais commité (rapport EC04, preuve 05). C'est le ZIP
|
||||||
|
`.git` complet qui est remis au jury : le contrôle porte donc bien sur l'historique.
|
||||||
|
- **Adresse de la machine** : l'adresse privée de la machine du groupe apparaît dans des
|
||||||
|
documents d'exploitation de l'historique git. Adresse non routable, joignable depuis le seul
|
||||||
|
réseau de l'école ; elle est masquée dans l'arbre livré.
|
||||||
|
- **Journal d'audit** : les accès sont tracés en ajout seul (ADR 0004). Cette table contient des
|
||||||
|
identifiants d'utilisateurs applicatifs : sur un déploiement réel, elle relèverait d'une durée
|
||||||
|
de conservation définie. Elle n'est pas fixée à ce jour : à arrêter avant tout déploiement réel.
|
||||||
|
- **Dans ce rapport et les supports d'oral** : aucune URL, adresse IP, identifiant de connexion
|
||||||
|
ou coordonnée personnelle n'est reproduite. Les identifiants GitHub sont des pseudonymes
|
||||||
|
publics, conservés parce qu'ils sont la seule clé de traçabilité vérifiable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
Relevé du 24/09/2026 à 14h25, commit `9f343e9` :
|
||||||
|
|
||||||
|
- **Git** : `git rev-list --count origin/dev` et `--no-merges` · `git log origin/dev
|
||||||
|
--no-merges --format=%an | sort | uniq -c` (alias regroupés) · `git log --no-merges
|
||||||
|
<merge>^1..<merge>^2` (auteur principal) · `git ls-tree --name-only origin/main docs/adr/` ·
|
||||||
|
`git rev-list --count origin/main..origin/dev`
|
||||||
|
- **API GitHub** : `gh issue list --state all --limit 300 --json
|
||||||
|
number,state,assignees,milestone,createdAt,closedAt` · `gh project item-list 2 --owner
|
||||||
|
ineszang --format json` · `gh api repos/ineszang/ProjetPiscine_EnerVision/milestones?state=all`
|
||||||
|
· `gh pr list --state all --limit 300 --json number,author,mergedBy,mergedAt,baseRefName,reviews`
|
||||||
|
· `gh api .../deployments?environment=prod`
|
||||||
|
- **Licences** : `uv run --frozen --no-dev --with pip-licenses pip-licenses --from=mixed` dans
|
||||||
|
chaque projet Python · `npx license-checker@25 --production --summary` dans `apps/frontend`
|
||||||
|
- `CONSIGNES-PROJET.md`, documents officiels 01, 02 et 05, points d'avancement des 15, 17, 18 et
|
||||||
|
21/09, rapport de sécurisation EC04
|
||||||
Binary file not shown.
@@ -0,0 +1,431 @@
|
|||||||
|
# EC04 · Rapport de sécurisation · EnerVision
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Auteur** | Johan LEROY, Groupe 3 (HEADL_015B), Tech Lead |
|
||||||
|
| **Date du relevé** | 24/09/2026, de 14h45 à 15h15 |
|
||||||
|
| **Référence du code** | commit gelé `9f343e9` du 24/09/2026 à 11h08, porté à la fois par `dev` et par `main` |
|
||||||
|
| **Périmètre** | la plateforme EnerVision et sa mise en ligne sur la machine du groupe (conteneur LXC sur l'hôte Proxmox de l'école) : trois environnements Docker Compose (production, recette, dev), chacun derrière son reverse proxy Nginx, et un frontal SNI commun |
|
||||||
|
| **Preuves** | dossier `preuves/`, quatorze fichiers numérotés, sorties brutes datées et anonymisées, chacune avec la commande qui la rejoue |
|
||||||
|
| **Format** | source Markdown UTF-8 sans média externe, version figée PDF produite par la chaîne Markdown vers HTML vers CSS de pagination vers PDF |
|
||||||
|
|
||||||
|
Ce rapport compile des preuves, il ne décrit pas des intentions. Chaque affirmation porte l'un de
|
||||||
|
trois marqueurs :
|
||||||
|
|
||||||
|
| Marqueur | Sens |
|
||||||
|
|---|---|
|
||||||
|
| **[Prouvé]** | vérifiable dans le dépôt ou par l'API GitHub, rejoué le 24/09 avec sa sortie dans `preuves/` |
|
||||||
|
| **[Constaté]** | relevé sur la machine le 24/09 par une commande en lecture seule (`12-constats-machine.txt`) |
|
||||||
|
| **[Absent]** | non fait, avec sa raison et son coût |
|
||||||
|
|
||||||
|
Anonymisation : aucune adresse IP, URL, identifiant de connexion, adresse électronique ni secret
|
||||||
|
n'est reproduit, ici comme dans les preuves. La machine est notée « la machine du groupe », ses
|
||||||
|
noms publics `<env>.<domaine-du-groupe>`. Le dépôt, lui, a porté l'adresse privée de la machine
|
||||||
|
dans des documents d'exploitation : elle est masquée dans l'arbre livré, mais l'historique git la
|
||||||
|
conserve (section 3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Synthèse
|
||||||
|
|
||||||
|
| Axe demandé | État | Ce qui le prouve |
|
||||||
|
|---|---|---|
|
||||||
|
| Scans IaC et code | **[Prouvé]** onze contrôles rejoués le 24/09 sur le commit gelé, **aucune vulnérabilité haute ou critique dans le code ni dans le produit livré** ; DAST : 0 échec sur 118 règles | Bandit, pip-audit, npm audit, Checkov, gitleaks, Trivy, `terraform validate`, `nginx -t`, OWASP ZAP |
|
||||||
|
| Gestion des secrets | **[Prouvé]** aucun secret dans l'historique git (342 commits hors merges, toutes branches) ; seize secrets générés sur la machine, jamais transmis | gitleaks, `git log` sur les fichiers sensibles, `scripts/provision-host.sh`, gardes de `config.py` |
|
||||||
|
| Règles réseau | **[Prouvé] [Constaté]** la plateforme n'expose que le frontal SNI, en 80 et 443, tout le reste sur la boucle locale ; **[Constaté]** la machine expose en plus SSH, k3s et trois Vault, hors code livré ; **[Absent]** pare-feu hôte | ports calculés par `docker compose config` et `provision-host.sh`, relevés par `ss` sur la machine |
|
||||||
|
| Authentification | **[Prouvé]** JWT court, rafraîchissement opaque avec rotation, Argon2id, RBAC à 3 rôles, **matrice d'accès testée** | 86 tests rejoués le 24/09, ADR 0002 et 0003 |
|
||||||
|
| Audits d'accès et d'infrastructure | **[Prouvé]** journal d'audit en ajout seul garanti par la base ; historique des déploiements ; supervision Prometheus active en production | ADR 0004 et 0016, state Terraform, API GitHub |
|
||||||
|
|
||||||
|
**Sept constats à traiter**, aucun bloquant pour le produit livré, détaillés en section 2.2 :
|
||||||
|
|
||||||
|
1. **La machine expose plus que la plateforme** : SSH en root avec mot de passe, l'API k3s et
|
||||||
|
trois serveurs Vault écoutent sur toutes les interfaces, sans pare-feu hôte. k3s et Vault ne
|
||||||
|
viennent pas du code livré (`12-constats-machine.txt`).
|
||||||
|
2. **Aucune approbation humaine avant la production**, contrairement à ce qu'annonce l'ADR 0009,
|
||||||
|
et **aucune branche protégée** (`13-github-reglages.txt`).
|
||||||
|
3. **Le chiffrement au repos est impossible sur cette machine** : c'est un conteneur LXC, où LUKS
|
||||||
|
ne peut pas fonctionner. La base est en clair sur le disque ; seules les archives sont
|
||||||
|
chiffrées (SSE-C, ADR 0020).
|
||||||
|
4. **Une vulnérabilité MEDIUM** (CVE-2026-41016) dans une dépendance transitive d'Airflow, que
|
||||||
|
**la CI ne peut pas voir** : son audit de dépendances ne porte que sur le verrou du backend.
|
||||||
|
5. **Le jeton de réinitialisation de mot de passe passe dans l'URL**, et le journal d'accès du
|
||||||
|
proxy l'enregistre en clair pendant ses quinze minutes de validité (`14-dast-zap.txt`).
|
||||||
|
6. **Du code non relu peut tourner sur la machine de production** : toute branche d'un membre,
|
||||||
|
par l'environnement `dev` (ADR 0017), et le workflow qu'apporterait une PR de fork, si son
|
||||||
|
exécution n'est pas soumise à approbation.
|
||||||
|
7. **Des fichiers sensibles dans l'arbre de travail du poste** (clé TLS locale, `.env`, state et
|
||||||
|
variables Terraform), ignorés par git mais **qui finiraient dans un ZIP fabriqué à partir du
|
||||||
|
dossier**. Le ZIP du rendu part donc d'un clone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Scans de sécurité
|
||||||
|
|
||||||
|
### 2.1 Résultats
|
||||||
|
|
||||||
|
Tous les contrôles ont été lancés le 24/09/2026 entre 14h45 et 15h00 sur `9f343e9`. Checkov,
|
||||||
|
Trivy config et `terraform validate` portent sur un export `git archive` du commit, pour
|
||||||
|
qu'aucun fichier ignoré du poste ne s'y mêle ; Trivy fs porte volontairement sur l'arbre de
|
||||||
|
travail, pour le constat 7.
|
||||||
|
|
||||||
|
| # | Outil | Périmètre | Résultat | Preuve |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | **Bandit 1.9.4** (SAST Python) | `apps/backend/app`, `ml/enervision_ml`, `etl/airflow/dags` | **0 constat, tous niveaux**, sur 7 110 lignes (6 136 + 722 + 252) | `01` |
|
||||||
|
| 2 | **pip-audit** (verrou figé, sans dev) | backend 50 paquets, ML 94, Airflow 128 | backend **0**, ML **0**, Airflow **1 vulnérabilité** (PYSEC-2026-24) | `02` |
|
||||||
|
| 3 | **npm audit** (`--package-lock-only`) | frontend 511 dépendances, tests e2e 26 | **0, tous niveaux**, dans les deux verrous | `03` |
|
||||||
|
| 4 | **Checkov 3.3.19** (IaC) | Terraform, Dockerfiles, workflows GitHub, secrets | Terraform **0 ressource évaluable** · Dockerfile 268 réussis, **3 échecs** · workflows **596 réussis, 0 échec** · secrets 0 | `04` |
|
||||||
|
| 5 | **gitleaks** (secrets) | **tout l'historique**, toutes branches | 8 constats, **8 faux positifs** après tri ligne à ligne | `05` |
|
||||||
|
| 6 | **Trivy config** (IaC) | dépôt entier | **3 LOW** (HEALTHCHECK), Terraform propre | `06` |
|
||||||
|
| 7 | **Trivy fs** (dépendances et secrets) | arbre de travail du poste | la même CVE Airflow, et la clé TLS locale (fichier ignoré par git) | `07` |
|
||||||
|
| 8 | Tests d'accès du backend | matrice rôle × route, protection des routes, durcissement, JWT | **86 réussis**, 5 tests d'intégration désélectionnés (joués en CI) | `08` |
|
||||||
|
| 9 | `terraform fmt` et `validate` | les deux racines | **formatage conforme, deux configurations valides** | `09` |
|
||||||
|
| 10 | `nginx -t` et ports fusionnés | proxy de stack, frontal SNI, `docker-compose.prod.yml` | **deux configurations valides**, un seul composant exposé | `10` |
|
||||||
|
| 11 | State Terraform local | racine `vm-eni` | 3 ressources appliquées sur 4 déclarées | `11` |
|
||||||
|
| 13 | Réglages GitHub | environnements, branches, secrets, runs | voir constat 2 | `13` |
|
||||||
|
| 14 | **OWASP ZAP 2.17.0** (DAST) | l'API en fonctionnement, authentifiée | **0 échec, 0 avertissement, 118 règles passées**, 4 alertes informatives | `14` |
|
||||||
|
|
||||||
|
**Ce que la CI rejoue**, sur chaque PR et chaque push vers `dev` ou `main`, filtré par chemins,
|
||||||
|
derrière le check unique « CI ok » (ADR 0014) : Bandit bloquant à partir de MEDIUM sur le backend
|
||||||
|
et le ML, `pip-audit` sur le verrou du backend, `npm audit --audit-level=high` sur le frontend,
|
||||||
|
`terraform fmt` et `validate`, actionlint et shellcheck sur les workflows, validation des
|
||||||
|
fichiers Compose, `nginx -t` du frontal, `promtool` et `amtool` sur la supervision, une fumée S3
|
||||||
|
sur Garage avec chiffrement SSE-C, les parcours Playwright et deux tirs k6 (fumée et contrôle du
|
||||||
|
429 par le proxy), et une analyse SonarCloud. Dependabot suit sept entrées chaque semaine. Le
|
||||||
|
DAST tourne chaque lundi, à la demande, et sur les PR qui modifient son propre workflow.
|
||||||
|
**Ne sont pas en CI** : Checkov, Trivy, gitleaks, `pip-audit` sur les verrous ML et Airflow,
|
||||||
|
`npm audit` sur le verrou des tests e2e. Ils ont été joués pour ce rapport.
|
||||||
|
|
||||||
|
### 2.2 Lecture des constats
|
||||||
|
|
||||||
|
**1. La machine expose plus que la plateforme.** Relevé par `ss` le 24/09 : outre le frontal en
|
||||||
|
80 et 443, écoutent sur toutes les interfaces SSH (connexion root et mot de passe acceptés),
|
||||||
|
l'API **k3s** en 6443 et **trois serveurs Vault** 2.1.1 en 8200 à 8205, initialisés et
|
||||||
|
descellés. Aucun pare-feu ne filtre : la table nftables est vide, en politique `accept`. Ni k3s
|
||||||
|
ni Vault ne viennent du code livré : `git grep vault` ne trouve rien sur le commit gelé. Vault
|
||||||
|
est visé par le Terraform d'une branche de travail non fusionnée. k3s, installé
|
||||||
|
le 17/09, redémarre en boucle depuis (51 678 redémarrages) sans porter aucun pod. Correctif, sans
|
||||||
|
commit : arrêter k3s, restreindre Vault à la boucle locale ou l'arrêter, puis un pare-feu
|
||||||
|
n'ouvrant que 22, 80 et 443, et SSH par clé seule. C'est l'infrastructure de l'administratrice
|
||||||
|
du dépôt : la décision lui revient.
|
||||||
|
|
||||||
|
**2. Production sans approbation, branches sans protection.** L'environnement GitHub `prod` n'a
|
||||||
|
qu'une règle : il n'accepte que la branche `main`. Aucun relecteur n'est requis : le dernier
|
||||||
|
déploiement est passé de l'attente à l'exécution en une seconde. `main` et `dev` ne sont pas protégées, aucun
|
||||||
|
ruleset n'existe : trois commits ont été poussés directement sur `dev` (`c2f360c`, `cbbfaf4`,
|
||||||
|
`6c09bee`), relus ensuite seulement par les PR de remontée #161 et #163. L'ADR 0009 annonçait une
|
||||||
|
production « après approbation » ; l'ADR 0014 en faisait un réglage restant à poser par
|
||||||
|
l'administratrice. Il ne l'a jamais été : les deux ADR portent désormais une note datée du
|
||||||
|
24/09. Correctif : deux réglages, que seule l'administratrice du dépôt peut activer.
|
||||||
|
|
||||||
|
**3. Chiffrement au repos.** La commande `systemd-detect-virt` répond `lxc` : sans
|
||||||
|
device-mapper ni périphérique loop, LUKS est impossible. `scripts/coffre-luks.sh` le détecte et
|
||||||
|
refuse de démarrer (ADR 0020). La base TimescaleDB et les métadonnées de Garage sont donc en
|
||||||
|
clair sur le disque du conteneur. Ce qui est chiffré dès aujourd'hui : les archives exportées
|
||||||
|
vers Garage par le DAG `retention`, en SSE-C, avec une clé que Garage ne conserve pas. Le
|
||||||
|
chiffrement du disque relève de l'hôte Proxmox, donc de l'administrateur de l'école, à qui la
|
||||||
|
demande est adressée.
|
||||||
|
|
||||||
|
**4. CVE-2026-41016, MEDIUM, `apache-airflow-providers-smtp` 2.3.2.** Le `SmtpHook` d'Airflow
|
||||||
|
négocie STARTTLS sans valider le certificat. Dépendance **transitive** d'`apache-airflow` 3.3.2.
|
||||||
|
**Exposition actuelle : nulle**, aucun DAG n'envoie de courriel et aucune connexion SMTP n'est
|
||||||
|
déclarée dans Airflow. Correctif : `providers-smtp` 3.0.0 ou plus. **Le vrai constat est
|
||||||
|
ailleurs** : la CI audite le verrou du backend et pas ceux du ML ni d'Airflow, qui portent 222
|
||||||
|
paquets, et Dependabot ne suit en `uv` que le backend. La CVE était déjà relevée le 23/09 et elle
|
||||||
|
est toujours là : c'est exactement ce que produit un angle mort.
|
||||||
|
|
||||||
|
**5. Jeton de réinitialisation dans l'URL** (ZAP 10024, informatif). `GET
|
||||||
|
/api/v1/auth/reset-password/validate?token=…` : le jeton est stocké haché, valable quinze
|
||||||
|
minutes, à usage unique, caviardé des journaux de l'API, et `Referrer-Policy: no-referrer` est
|
||||||
|
posé. Mais le journal d'accès du proxy enregistre la requête complète, donc le jeton en clair,
|
||||||
|
lisible par qui administre la machine pendant sa validité. Correctif : passer la validation en
|
||||||
|
`POST`, ou journaliser `$uri` sans ses paramètres sur cette route.
|
||||||
|
|
||||||
|
**6. Du code non relu peut tourner sur la machine de production.** Le troisième environnement,
|
||||||
|
`dev`, se déploie par `workflow_dispatch` depuis n'importe quelle branche (ADR 0017). Il vit sur
|
||||||
|
la même machine et le même démon Docker que la production : tout membre qui a le droit
|
||||||
|
d'écriture peut y exécuter du code non relu. `deploy.yml` n'a jamais de déclencheur
|
||||||
|
`pull_request`, mais cela ne suffit pas, l'ADR 0014 le dit : une PR de fork peut apporter son
|
||||||
|
propre workflow qui cible le runner. La seule protection est alors l'approbation des workflows
|
||||||
|
des contributeurs externes, un réglage que l'API refuse de lire avec les droits d'un membre
|
||||||
|
(403) : non vérifié. Risque accepté pour une équipe de cinq, à fermer avant tout contributeur
|
||||||
|
extérieur.
|
||||||
|
|
||||||
|
**7. Fichiers sensibles du poste.** Trivy fs trouve la clé du certificat auto-signé local, et le
|
||||||
|
poste porte aussi, ignorés : deux `.env`, le state et les variables Terraform, les journaux de
|
||||||
|
session. Tous sont ignorés par git et n'ont jamais été versionnés (`05`). **Conséquence
|
||||||
|
opérationnelle pour vendredi : le ZIP du dépôt, `.git` inclus, se fabrique à partir d'un clone**,
|
||||||
|
jamais en compressant le dossier de travail.
|
||||||
|
|
||||||
|
**HEALTHCHECK absent** (Checkov CKV_DOCKER_2, Trivy DS-0026, LOW) sur les images frontend,
|
||||||
|
Airflow et ML. Le backend en porte un, et c'est lui que le proxy attend avant de démarrer ; la
|
||||||
|
base, l'API Airflow et Garage ont le leur dans `docker-compose.yml`. Reste le frontend, un nginx
|
||||||
|
statique qui tomberait sans être signalé. Impact faible.
|
||||||
|
|
||||||
|
**Terraform : Checkov n'évalue aucune ressource.** Les six ressources du dépôt sont des
|
||||||
|
`null_resource` qui agissent par SSH, pour lesquelles Checkov n'a aucune politique. Ce scan ne
|
||||||
|
prouve rien, dans un sens comme dans l'autre. Les garanties réelles sont ailleurs : validation en
|
||||||
|
CI, clé SSH seule, jeton du runner en variable `sensitive` et hors des triggers (`11`, ADR 0010).
|
||||||
|
|
||||||
|
**gitleaks : huit faux positifs.** Six viennent de la règle `generic-api-key` qui prend
|
||||||
|
`api_history` et `api_current`, deux valeurs de la colonne `source` des relevés, pour des clés.
|
||||||
|
Les deux nouveaux sont des valeurs de test : un identifiant S3 factice (`settings_s3()`) et le mot
|
||||||
|
de passe provisoire simulé d'un test Angular.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Gestion des secrets
|
||||||
|
|
||||||
|
| Mesure | Preuve | État |
|
||||||
|
|---|---|---|
|
||||||
|
| Aucun secret dans l'historique git, toutes branches ; aucun `.env`, `.pem`, `.key`, `.tfvars`, `.tfstate` ni jeton DNS jamais commité | `05` | [Prouvé] |
|
||||||
|
| `.env`, `*.pem`, `*.tfvars`, `*.tfstate` ignorés par git ; seuls les `*.example` sont versionnés ; `infra/garage/garage.toml`, versionné, ne porte aucun secret | `.gitignore`, `05` | [Prouvé] |
|
||||||
|
| **Seize secrets générés sur la machine** par `openssl rand` (base, API, jeton des métriques, cinq pour Airflow dont sa clé Fernet, six pour Garage dont la clé SSE-C, Grafana, rôle de supervision), `.env` écrit sous `umask 077` puis en `600`. Les secrets existants sont conservés ; le reste du fichier est réécrit à chaque passage pour réaligner hôte, ports et profils | `scripts/provision-host.sh` | [Prouvé] le script · [Constaté] les droits |
|
||||||
|
| Refus d'écrire le `.env` si une valeur d'exemple `change_me` subsiste, hors identifiants de l'API Mock posés à la main | `provision-host.sh`, fonction `preparer` | [Prouvé] |
|
||||||
|
| L'API **refuse de démarrer** si `APP_SECRET_KEY` fait moins de 32 caractères ou vaut une sentinelle, si `APP_DEBUG` est vrai hors local, ou si les origines CORS sont en joker ou absentes | `apps/backend/app/core/config.py` | [Prouvé] |
|
||||||
|
| Secrets typés `SecretStr`, clés S3 et SSE-C comprises, donc jamais journalisés ; jetons, mots de passe et cookies caviardés dans les journaux | `config.py`, `app/core/logging.py` | [Prouvé] |
|
||||||
|
| Compose exige par `${VAR:?}` les secrets de la base et de l'API ; ceux d'Airflow, de Garage et de la supervision sont gardés par `airflow-init` et par les gardes du `Makefile` avant tout démarrage | `docker-compose.yml`, `Makefile` | [Prouvé] |
|
||||||
|
| Clé de signature d'Airflow **distincte** de celle de l'API | `docker-compose.yml` | [Prouvé] |
|
||||||
|
| Côté GitHub, **un seul secret** (`SONAR_TOKEN`) ; le déploiement n'en consomme aucun | `13` | [Prouvé] |
|
||||||
|
| Terraform : clé SSH seule, jeton du runner en variable `sensitive`, absent des triggers | `11`, ADR 0010 | [Prouvé] |
|
||||||
|
| Jeton du scan DAST éphémère et caviardé des journaux publiés | `dast.yml`, `scripts/dast-token.sh` | [Prouvé] |
|
||||||
|
|
||||||
|
**Ce qui manque.** Le dossier EC01 prévoyait **SOPS + age** : non fait. Les secrets vivent en
|
||||||
|
clair sur le disque de la machine, protégés par les droits du fichier seulement, et la rotation
|
||||||
|
est manuelle. Le 23/09, ce rapport jugeait la situation acceptable pour deux environnements,
|
||||||
|
mais plus pour trois : **le seuil est franchi**, avec trois `.env`, un jeton DNS et une clé SSE-C dont
|
||||||
|
la perte rendrait les archives illisibles. L'ADR 0019 demande de sauvegarder cette clé hors de la
|
||||||
|
machine : c'est une procédure, rien ne le vérifie. Enfin, l'adresse privée de la machine figure
|
||||||
|
dans l'historique git (ADR d'exploitation du 21 et du 22/09) : adresse non routable, joignable
|
||||||
|
seulement depuis le réseau de l'école, masquée dans l'arbre livré.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Règles réseau
|
||||||
|
|
||||||
|
### 4.1 Surface exposée
|
||||||
|
|
||||||
|
Sur la machine, `provision-host.sh` ramène tous les ports des trois stacks sur la boucle locale
|
||||||
|
(`10`) :
|
||||||
|
|
||||||
|
| Composant | Publication sur la machine | Joignable depuis |
|
||||||
|
|---|---|---|
|
||||||
|
| **Frontal SNI** (`infra/front`, nginx `stream`) | réseau de l'hôte, **80 et 443** | le réseau |
|
||||||
|
| Proxy Nginx de chaque stack | `127.0.0.1` : 10443, 8443, 9443 (HTTPS) et l'écouteur PROXY protocol du frontal | la machine seule |
|
||||||
|
| Base, Mailpit, API Airflow | `127.0.0.1`, un port par environnement | la machine, donc par tunnel SSH |
|
||||||
|
| Garage (S3 et administration) | `127.0.0.1`, un port par environnement ; pas encore en `dev`, resté sur le commit du 23/09 | la machine seule |
|
||||||
|
| Prometheus, Alertmanager, Grafana (production) | `127.0.0.1` | la machine seule |
|
||||||
|
| Backend, frontend, scheduler et processeur Airflow, exporteurs | aucune | le réseau interne de Compose |
|
||||||
|
| *Hors plateforme* : SSH, API k3s, trois Vault | toutes les interfaces : 22, 6443, 8200 à 8205 | **le réseau** (constat 1) |
|
||||||
|
|
||||||
|
**Un seul composant exposé par la plateforme.** Le frontal lit le nom demandé (SNI) sans
|
||||||
|
déchiffrer et relaie la connexion, en PROXY protocol, vers le proxy de la stack visée : l'adresse
|
||||||
|
réelle du client arrive jusqu'à la limitation de débit. Les trois environnements sont trois
|
||||||
|
projets Compose distincts, sans réseau ni volume partagé (ADR 0009, 0017, 0018). Sur la
|
||||||
|
machine, `ss` confirme les ports de la plateforme, mais relève aussi les services hors plateforme
|
||||||
|
du constat 1. [Constaté, `12`]
|
||||||
|
|
||||||
|
### 4.2 Reverse proxy et TLS
|
||||||
|
|
||||||
|
| Directive | Valeur | Effet |
|
||||||
|
|---|---|---|
|
||||||
|
| Certificats | **Let's Encrypt par défi DNS-01** (acme.sh, domaine dynv6), vérifiés chaque nuit par une tâche cron et renouvelés à échéance ; l'auto-signé ne sert plus qu'au poste et aux tests e2e | chaîne de confiance publique, sans port 80 ouvert pour le défi (ADR 0018) |
|
||||||
|
| Protocoles | TLS 1.2 et 1.3, tickets de session désactivés | pas de protocole obsolète |
|
||||||
|
| Redirection | 80 vers 443 | pas de trafic applicatif en clair |
|
||||||
|
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | le navigateur refuse ensuite le HTTP |
|
||||||
|
| `Content-Security-Policy` | `default-src 'self'`, `frame-ancestors 'none'`... | limite l'injection de script et le clickjacking |
|
||||||
|
| En-têtes de l'API | `nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, `Cross-Origin-Resource-Policy: same-origin` | défense en profondeur si le proxy manquait |
|
||||||
|
| Limitation de débit | `api` 20 req/s, `auth` 30 req/min sur les routes qui vérifient un secret ; contrôlée par un tir k6 en CI | freine la force brute sans pénaliser la navigation derrière le NAT de l'école |
|
||||||
|
| `server_tokens off`, corps limité à 2 Mo | | pas de version publiée, pas de requête démesurée |
|
||||||
|
|
||||||
|
Les deux configurations sont validées par `nginx -t` sur l'image de production (`10`).
|
||||||
|
Certificats servis : Let's Encrypt (émetteur YE1) sur les trois noms, échéance au 22/12/2026, et
|
||||||
|
les trois sondes de santé répondent 200 par le nom public avec un certificat vérifié, sans `-k`.
|
||||||
|
[Constaté, `12`]
|
||||||
|
|
||||||
|
### 4.3 Accès à la machine et déploiement
|
||||||
|
|
||||||
|
- Le **runner GitHub Actions** auto-hébergé initie lui-même la connexion vers GitHub : **aucun
|
||||||
|
port entrant** n'est ouvert pour déployer. [Prouvé]
|
||||||
|
- `deploy.yml` ne se déclenche **jamais sur `pull_request`** : sur un dépôt public, une PR venue
|
||||||
|
d'un fork exécuterait sinon son code sur la machine. Il n'est appelé qu'après une CI verte sur
|
||||||
|
un push, et refuse de revenir à un commit plus ancien que celui déployé. [Prouvé]
|
||||||
|
- La production n'accepte que `main`, **sans approbation humaine** (constat 2). [Prouvé, `13`]
|
||||||
|
- Terraform se connecte par **clé SSH**, jamais par mot de passe. [Prouvé]
|
||||||
|
|
||||||
|
**Ce qui manque.** **Aucun pare-feu hôte** n'est configuré ni documenté : la restriction repose
|
||||||
|
sur la publication des ports et sur le réseau de l'école ; la table nftables est vide, en
|
||||||
|
politique `accept` [Constaté, `12`]. **SSH n'est pas durci** : connexion root et authentification
|
||||||
|
par mot de passe acceptées [Constaté, `12`] ; Ansible était prévu au dossier EC01 et n'a pas été
|
||||||
|
fait. Pas de
|
||||||
|
réseaux Docker nommés : l'intention du dossier EC01 (la base jamais exposée) est tenue par
|
||||||
|
l'absence de publication, plus fragile à la relecture qu'une politique explicite. Azure n'est pas
|
||||||
|
utilisé, par choix d'architecture on-premise : il n'y a ni NSG ni Application Gateway à auditer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Authentification et autorisation
|
||||||
|
|
||||||
|
| Mécanisme | Détail | Trace |
|
||||||
|
|---|---|---|
|
||||||
|
| Jeton d'accès | JWT HS256, **15 minutes**, algorithme épinglé, `aud`, `iss` et `typ` vérifiés, `alg: none` rejeté, gardé en mémoire côté navigateur | ADR 0002, `app/core/security.py` |
|
||||||
|
| Jeton de rafraîchissement | chaîne **opaque de 256 bits**, stockée hachée, **rotation à chaque usage et détection de réutilisation** ; cookie `HttpOnly`, `Secure`, `SameSite=Strict`, chemin restreint | ADR 0002, `app/services/auth.py` |
|
||||||
|
| Mots de passe | **Argon2id** (m=19 456 Kio, t=2, p=1), re-hachage passif si les paramètres changent | `app/core/hashing.py` |
|
||||||
|
| Réinitialisation | jeton haché, 15 minutes, usage unique ; voir le constat 5 | `app/models/password_reset_token.py` |
|
||||||
|
| Force brute | limitation à fenêtre glissante sur trois clés, **évaluée avant le hachage** ; pas de verrouillage de compte, qui serait un déni de service | ADR 0002, `login_attempt` |
|
||||||
|
| Énumération de comptes | message et temps de réponse identiques quelle que soit la cause | `app/services/auth.py` |
|
||||||
|
| Autorisation | **RBAC à trois rôles ordonnés** (`lecteur`, `operateur`, `admin`), décision prise sur **la ligne en base relue à chaque requête**, jamais sur le claim | ADR 0003, `app/api/deps.py` |
|
||||||
|
| Révocation | immédiate : compte désactivé ou mot de passe changé invalide les jetons antérieurs | `credentials_changed_at` |
|
||||||
|
| Refus par défaut | **16 routes sous rôle**, 3 authentifiées sans rôle, 1 par cookie, 8 publiques listées nommément (dont `/metrics`, gardée par son propre jeton) ; un test appelle réellement chaque route sans jeton | `tests/api/acces.py`, `test_route_protection.py` |
|
||||||
|
| Matrice d'accès | chaque route gardée croisée avec les trois rôles, sur les routes réelles, puis rejouée avec de vrais jetons contre une vraie base en CI | `test_matrice_acces.py` |
|
||||||
|
| Garde-fous d'administration | refus de rétrograder ou désactiver le dernier administrateur ; premier administrateur créé hors dépôt | `app/services/user.py`, `app/cli.py` |
|
||||||
|
|
||||||
|
**Preuve d'exécution** : 86 tests d'accès, de protection des routes, de durcissement et de JWT,
|
||||||
|
réussis le 24/09 (`08`) ; le DAST authentifié ne relève ni échec ni avertissement (`14`).
|
||||||
|
|
||||||
|
**Ce qui reste ouvert, et c'est écrit dans le dépôt** (`owasp-traceabilite.md`) : **pas
|
||||||
|
d'autorisation par objet** (OWASP API1). Les rôles sont globaux, un compte `lecteur` lit tous les
|
||||||
|
sites. Sans conséquence tant que les routes métier sont en lecture seule ; à corriger par une table
|
||||||
|
d'affectation compte-site avant la première route d'écriture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Audits d'accès et d'infrastructure
|
||||||
|
|
||||||
|
### 6.1 Traçabilité applicative
|
||||||
|
|
||||||
|
- **Journal d'audit en ajout seul, garanti par PostgreSQL** : deux déclencheurs refusent
|
||||||
|
`UPDATE`, `DELETE` et `TRUNCATE` sur `audit_log`. Les champs de détail passent par une liste
|
||||||
|
blanche ; l'acteur est dénormalisé pour survivre à la suppression d'un compte (ADR 0004).
|
||||||
|
[Prouvé] par les tests d'intégration · en production, 5 créations de compte et 3 changements
|
||||||
|
de mot de passe [Constaté, `12`]
|
||||||
|
- **Tentatives de connexion** journalisées à part (`login_attempt`). [Prouvé]
|
||||||
|
- **Limite assumée** : les déclencheurs arrêtent l'accident, pas un compte qui détient
|
||||||
|
`ALTER TABLE`. Le journal n'est pas une preuve de non-répudiation.
|
||||||
|
|
||||||
|
### 6.2 Traçabilité de l'infrastructure
|
||||||
|
|
||||||
|
- **Provisionnement** : le state Terraform porte trois ressources appliquées (Docker, les trois
|
||||||
|
environnements, le runner). La quatrième, le coffre LUKS, n'a jamais été appliquée (`11`,
|
||||||
|
constat 3).
|
||||||
|
- **Déploiements** : chaque déploiement est un run de `deploy.yml` rattaché à un environnement
|
||||||
|
GitHub. Sept déploiements de production entre le 23/09 11h37 et le 24/09 11h12, le dernier sur
|
||||||
|
le commit gelé (`13`). [Prouvé]
|
||||||
|
- **Dépôt** : une seule administratrice, aucune branche protégée (constat 2).
|
||||||
|
|
||||||
|
### 6.3 Supervision
|
||||||
|
|
||||||
|
**[Prouvé]** dans le dépôt, active en production (profil Compose `monitoring`, ADR 0016) :
|
||||||
|
Prometheus lit `/metrics` avec son jeton, ainsi que la base, l'hôte, les conteneurs et Garage.
|
||||||
|
Neuf règles d'alerte sont testées par `promtool` en CI : API indisponible, erreurs serveur,
|
||||||
|
latence, base indisponible ou saturée, mémoire, disque et CPU de l'hôte, cible injoignable.
|
||||||
|
Alertmanager les envoie à Mailpit. Trois tableaux Grafana couvrent l'API, les données et
|
||||||
|
l'infrastructure. En production, les sept cibles sont `up` et les neuf règles chargées
|
||||||
|
[Constaté, `12`].
|
||||||
|
|
||||||
|
**Ce qui manque** : aucune règle sur des **événements de sécurité** (pic de 401, de 403 ou de
|
||||||
|
429) ; des alertes qui restent dans Mailpit et ne réveillent personne ; pas de Loki, donc aucune
|
||||||
|
centralisation des journaux ; aucune alerte sur l'échec d'un DAG.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Écarts avec le dossier EC01, et leur coût
|
||||||
|
|
||||||
|
| Prévu au dossier EC01 | Livré | Coût |
|
||||||
|
|---|---|---|
|
||||||
|
| Traefik en terminaison TLS | **Nginx** par stack (ADR 0007), plus un **frontal SNI** (ADR 0018) | configuration écrite à la main, mais explicite et validée en CI |
|
||||||
|
| Trivy, Bandit, gitleaks en CI | **Bandit bloquant en CI** ; Trivy, gitleaks et Checkov joués pour ce rapport | un secret commité demain ne serait vu qu'au prochain passage manuel |
|
||||||
|
| SOPS + age | secrets générés sur la machine, jamais transmis | secrets en clair sur disque, sans sauvegarde vérifiée |
|
||||||
|
| Ansible pour le durcissement | rien | pare-feu et SSH non durcis de façon reproductible |
|
||||||
|
| Deux réseaux Docker | un seul composant exposé | propriété portée par une absence, fragile à la relecture |
|
||||||
|
| Prometheus, Grafana, Loki | **Prometheus, Alertmanager, Grafana** actifs en production ; pas de Loki | journaux dispersés, aucune alerte de sécurité |
|
||||||
|
| Scan d'image de conteneur | aucun | les images construites sur la machine ne sont pas analysées |
|
||||||
|
| Chiffrement au repos | **SSE-C des archives** ; LUKS écrit mais impossible sur LXC (ADR 0020) | base en clair sur le disque du conteneur |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Plan d'action
|
||||||
|
|
||||||
|
### 8.1 Avant le gel du 25/09, 9h00
|
||||||
|
|
||||||
|
1. **Fabriquer le ZIP du dépôt depuis un clone**, `.git` inclus, et vérifier qu'il ne contient
|
||||||
|
ni `.env`, ni `*.pem`, ni `*.tfvars`, ni `*.tfstate`.
|
||||||
|
2. Activer, par l'administratrice : un relecteur requis sur l'environnement `prod`, et la
|
||||||
|
protection de `main`. Deux réglages, sans commit.
|
||||||
|
3. Arrêter k3s, qui redémarre en boucle, et restreindre les trois Vault à la boucle locale
|
||||||
|
(constat 1). Sans commit, sur décision de l'administratrice.
|
||||||
|
|
||||||
|
### 8.2 Après le gel, par ordre de valeur
|
||||||
|
|
||||||
|
1. Pare-feu hôte n'ouvrant que 22, 80 et 443, et SSH par clé seule, dans un script rejouable.
|
||||||
|
2. `pip-audit` sur les trois verrous, gitleaks et Trivy en CI ; montée de `providers-smtp`.
|
||||||
|
3. Validation du jeton de réinitialisation en `POST`, ou journal d'accès sans paramètres.
|
||||||
|
4. Chiffrement du disque par l'hôte Proxmox, ou une vraie machine virtuelle pour dérouler le
|
||||||
|
coffre LUKS déjà écrit.
|
||||||
|
5. Environnement `dev` sur une autre machine, ou limité aux branches relues.
|
||||||
|
6. Règles d'alerte de sécurité et Loki ; seuil bloquant sur le DAST.
|
||||||
|
7. Autorisation par site (API1) ; SOPS + age ; HEALTHCHECK du frontend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Contribution personnelle
|
||||||
|
|
||||||
|
Attribution vérifiée par `git log` sur chaque fichier cité.
|
||||||
|
|
||||||
|
| Sujet | Auteur principal |
|
||||||
|
|---|---|
|
||||||
|
| Authentification, RBAC, journal d'audit, gardes de configuration, caviardage des journaux, ADR 0002 à 0004 | **Johan** |
|
||||||
|
| Matrice d'accès et protection des routes, et leurs tests | **Johan** |
|
||||||
|
| Reverse proxy Nginx et TLS, `docker-compose.prod.yml`, ADR 0007 | **Johan** |
|
||||||
|
| Trois environnements, `provision-host.sh`, `deploy.yml`, Terraform `vm-eni`, ADR 0009, 0010 et 0017 | **Johan** |
|
||||||
|
| CI unifiée (`ci.yml`), e2e et k6, supervision, ADR 0014 à 0016 | **Johan** |
|
||||||
|
| Certificats DNS-01 et frontal SNI, ADR 0018 | **Johan** |
|
||||||
|
| Garage par environnement, rétention, SSE-C, coffre LUKS, ADR 0019 et 0020 | **Johan**, sur une amorce de Valentin |
|
||||||
|
| Scan DAST OWASP ZAP (`dast.yml`, `dast-token.sh`) | Dorian ; ma part est la revue, trois points bloquants dont une fuite du jeton dans les artefacts |
|
||||||
|
| En-tête `Cross-Origin-Resource-Policy`, module Terraform k3s | Dorian |
|
||||||
|
| Workflow frontend, configuration SonarCloud, administration du dépôt | Inès |
|
||||||
|
| Ce rapport et les contrôles du 24/09 | **Johan** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Usage de l'IA
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Outil** | Claude Code (Anthropic), en assistant dans le terminal |
|
||||||
|
| **Tâches** | lancement des scans et mise en forme de leurs sorties, recoupement entre la documentation, le code et l'API GitHub, première rédaction de ce rapport |
|
||||||
|
| **Vérifications humaines** | chaque chiffre est lu dans une sortie de `preuves/`, rejouable par la commande en tête du fichier ; les huit constats gitleaks et les quatre alertes ZAP ont été triés ligne à ligne ; les constats sur la machine viennent de commandes lancées par l'auteur |
|
||||||
|
| **Limite constatée** | l'outil tend à présenter comme acquis ce qui n'est que prévu, et à conclure avant d'avoir vérifié (une première lecture de l'alerte ZAP citait des codes HTTP non relevés) : d'où les trois marqueurs et le tri ligne à ligne |
|
||||||
|
|
||||||
|
## 11. Licences
|
||||||
|
|
||||||
|
Outils de ce rapport : Bandit, pip-audit, Checkov, Trivy et OWASP ZAP sous licence Apache 2.0,
|
||||||
|
gitleaks sous licence MIT. Aucun n'est embarqué dans le produit. Composants ajoutés depuis le
|
||||||
|
dossier EC01 : Prometheus, Alertmanager, les exporteurs, cAdvisor, Playwright et boto3 sous Apache
|
||||||
|
2.0 ; acme.sh sous GPL 3.0 ; **Garage, Grafana et k6 sous AGPL 3.0**. Ils sont utilisés sans
|
||||||
|
modification, chacun dans son propre conteneur, ce qui n'emporte aucune obligation de publication.
|
||||||
|
L'ADR 0019 écarte pourtant MinIO en citant notamment sa licence AGPL, que Garage partage :
|
||||||
|
l'argument ne tient pas, les autres raisons de l'ADR restent. Le recensement complet est dans le
|
||||||
|
rapport collectif EC02.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Annexe · Index des preuves
|
||||||
|
|
||||||
|
| Fichier | Contenu |
|
||||||
|
|---|---|
|
||||||
|
| `01-bandit.txt` | Bandit, seuil de la CI puis tous niveaux, trois modules |
|
||||||
|
| `02-pip-audit.txt` | pip-audit sur les trois verrous Python |
|
||||||
|
| `03-npm-audit.txt` | npm audit, frontend et tests e2e |
|
||||||
|
| `04-checkov.txt` | Checkov, quatre frameworks |
|
||||||
|
| `05-gitleaks.txt` | gitleaks sur tout l'historique, constats caviardés et tri |
|
||||||
|
| `06-trivy-config.txt` | Trivy config sur le commit |
|
||||||
|
| `07-trivy-fs.txt` | Trivy fs sur l'arbre de travail, clé retirée |
|
||||||
|
| `08-tests-acces.txt` | tests d'accès et d'authentification du backend |
|
||||||
|
| `09-terraform-validate.txt` | `terraform fmt` et `validate` sur les deux racines |
|
||||||
|
| `10-proxy-tls.txt` | `nginx -t` du proxy et du frontal, directives, ports |
|
||||||
|
| `11-terraform-state.txt` | ressources appliquées sur la machine |
|
||||||
|
| `12-constats-machine.txt` | relevés en lecture seule sur la machine |
|
||||||
|
| `13-github-reglages.txt` | environnements, protection des branches, secrets, déploiements |
|
||||||
|
| `14-dast-zap.txt` | dernier rapport OWASP ZAP et tri des alertes |
|
||||||
|
|
||||||
|
Chaque fichier donne en tête sa date, le commit analysé et la commande exacte pour le rejouer.
|
||||||
Binary file not shown.
@@ -0,0 +1,142 @@
|
|||||||
|
# Bandit 1.9.4 · 2026-09-24 14:45 CEST · commit 9f343e9 (dev = main)
|
||||||
|
|
||||||
|
## apps/backend/app · seuil CI (MEDIUM, confiance MEDIUM)
|
||||||
|
Run started:2026-09-24 12:45:08.896999+00:00
|
||||||
|
|
||||||
|
Test results:
|
||||||
|
No issues identified.
|
||||||
|
|
||||||
|
Code scanned:
|
||||||
|
Total lines of code: 6136
|
||||||
|
Total lines skipped (#nosec): 0
|
||||||
|
Total potential issues skipped due to specifically being disabled (e.g., #nosec BXXX): 0
|
||||||
|
|
||||||
|
Run metrics:
|
||||||
|
Total issues (by severity):
|
||||||
|
Undefined: 0
|
||||||
|
Low: 0
|
||||||
|
Medium: 0
|
||||||
|
High: 0
|
||||||
|
Total issues (by confidence):
|
||||||
|
Undefined: 0
|
||||||
|
Low: 0
|
||||||
|
Medium: 0
|
||||||
|
High: 0
|
||||||
|
Files skipped (0):
|
||||||
|
exit=0
|
||||||
|
|
||||||
|
## apps/backend/app · tous niveaux
|
||||||
|
Test results:
|
||||||
|
No issues identified.
|
||||||
|
|
||||||
|
Code scanned:
|
||||||
|
Total lines of code: 6136
|
||||||
|
Total lines skipped (#nosec): 0
|
||||||
|
Total potential issues skipped due to specifically being disabled (e.g., #nosec BXXX): 0
|
||||||
|
|
||||||
|
Run metrics:
|
||||||
|
Total issues (by severity):
|
||||||
|
Undefined: 0
|
||||||
|
Low: 0
|
||||||
|
Medium: 0
|
||||||
|
High: 0
|
||||||
|
Total issues (by confidence):
|
||||||
|
Undefined: 0
|
||||||
|
Low: 0
|
||||||
|
Medium: 0
|
||||||
|
High: 0
|
||||||
|
Files skipped (0):
|
||||||
|
|
||||||
|
## ml/enervision_ml · seuil CI (MEDIUM, confiance MEDIUM)
|
||||||
|
Run started:2026-09-24 12:45:09.551911+00:00
|
||||||
|
|
||||||
|
Test results:
|
||||||
|
No issues identified.
|
||||||
|
|
||||||
|
Code scanned:
|
||||||
|
Total lines of code: 722
|
||||||
|
Total lines skipped (#nosec): 0
|
||||||
|
Total potential issues skipped due to specifically being disabled (e.g., #nosec BXXX): 0
|
||||||
|
|
||||||
|
Run metrics:
|
||||||
|
Total issues (by severity):
|
||||||
|
Undefined: 0
|
||||||
|
Low: 0
|
||||||
|
Medium: 0
|
||||||
|
High: 0
|
||||||
|
Total issues (by confidence):
|
||||||
|
Undefined: 0
|
||||||
|
Low: 0
|
||||||
|
Medium: 0
|
||||||
|
High: 0
|
||||||
|
Files skipped (0):
|
||||||
|
exit=0
|
||||||
|
|
||||||
|
## ml/enervision_ml · tous niveaux
|
||||||
|
Test results:
|
||||||
|
No issues identified.
|
||||||
|
|
||||||
|
Code scanned:
|
||||||
|
Total lines of code: 722
|
||||||
|
Total lines skipped (#nosec): 0
|
||||||
|
Total potential issues skipped due to specifically being disabled (e.g., #nosec BXXX): 0
|
||||||
|
|
||||||
|
Run metrics:
|
||||||
|
Total issues (by severity):
|
||||||
|
Undefined: 0
|
||||||
|
Low: 0
|
||||||
|
Medium: 0
|
||||||
|
High: 0
|
||||||
|
Total issues (by confidence):
|
||||||
|
Undefined: 0
|
||||||
|
Low: 0
|
||||||
|
Medium: 0
|
||||||
|
High: 0
|
||||||
|
Files skipped (0):
|
||||||
|
|
||||||
|
## etl/airflow/dags · seuil CI (MEDIUM, confiance MEDIUM)
|
||||||
|
Run started:2026-09-24 12:45:09.903724+00:00
|
||||||
|
|
||||||
|
Test results:
|
||||||
|
No issues identified.
|
||||||
|
|
||||||
|
Code scanned:
|
||||||
|
Total lines of code: 252
|
||||||
|
Total lines skipped (#nosec): 0
|
||||||
|
Total potential issues skipped due to specifically being disabled (e.g., #nosec BXXX): 0
|
||||||
|
|
||||||
|
Run metrics:
|
||||||
|
Total issues (by severity):
|
||||||
|
Undefined: 0
|
||||||
|
Low: 0
|
||||||
|
Medium: 0
|
||||||
|
High: 0
|
||||||
|
Total issues (by confidence):
|
||||||
|
Undefined: 0
|
||||||
|
Low: 0
|
||||||
|
Medium: 0
|
||||||
|
High: 0
|
||||||
|
Files skipped (0):
|
||||||
|
exit=0
|
||||||
|
|
||||||
|
## etl/airflow/dags · tous niveaux
|
||||||
|
Test results:
|
||||||
|
No issues identified.
|
||||||
|
|
||||||
|
Code scanned:
|
||||||
|
Total lines of code: 252
|
||||||
|
Total lines skipped (#nosec): 0
|
||||||
|
Total potential issues skipped due to specifically being disabled (e.g., #nosec BXXX): 0
|
||||||
|
|
||||||
|
Run metrics:
|
||||||
|
Total issues (by severity):
|
||||||
|
Undefined: 0
|
||||||
|
Low: 0
|
||||||
|
Medium: 0
|
||||||
|
High: 0
|
||||||
|
Total issues (by confidence):
|
||||||
|
Undefined: 0
|
||||||
|
Low: 0
|
||||||
|
Medium: 0
|
||||||
|
High: 0
|
||||||
|
Files skipped (0):
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# pip-audit (méthode du job security-audit de backend.yml : verrou figé, sans dépendances de dev) · 2026-09-24 14:45 CEST · commit 9f343e9
|
||||||
|
|
||||||
|
## apps/backend/uv.lock
|
||||||
|
paquets figés : 50
|
||||||
|
Installed 28 packages in 17ms
|
||||||
|
WARNING:pip_audit._cli:--no-deps is supported, but users are encouraged to fully hash their pinned dependencies
|
||||||
|
WARNING:pip_audit._cli:Consider using a tool like `pip-compile`: https://pip-tools.readthedocs.io/en/latest/#using-hashes
|
||||||
|
No known vulnerabilities found
|
||||||
|
exit=0
|
||||||
|
|
||||||
|
## ml/uv.lock
|
||||||
|
paquets figés : 94
|
||||||
|
WARNING:pip_audit._cli:--no-deps is supported, but users are encouraged to fully hash their pinned dependencies
|
||||||
|
WARNING:pip_audit._cli:Consider using a tool like `pip-compile`: https://pip-tools.readthedocs.io/en/latest/#using-hashes
|
||||||
|
No known vulnerabilities found
|
||||||
|
exit=0
|
||||||
|
|
||||||
|
## etl/airflow/uv.lock
|
||||||
|
paquets figés : 128
|
||||||
|
WARNING:pip_audit._cli:--no-deps is supported, but users are encouraged to fully hash their pinned dependencies
|
||||||
|
WARNING:pip_audit._cli:Consider using a tool like `pip-compile`: https://pip-tools.readthedocs.io/en/latest/#using-hashes
|
||||||
|
Found 2 known vulnerabilities in 1 package
|
||||||
|
Name Version ID Fix Versions
|
||||||
|
----------------------------- ------- ------------- ------------
|
||||||
|
apache-airflow-providers-smtp 2.3.2 PYSEC-2026-24 3.0.0
|
||||||
|
apache-airflow-providers-smtp 2.3.2 PYSEC-2026-24 3.0.0
|
||||||
|
exit=1
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# npm audit (méthode du job security-audit de frontend.yml : --package-lock-only) · 2026-09-24 14:45 CEST · commit 9f343e9
|
||||||
|
|
||||||
|
## apps/frontend · seuil CI : --audit-level=high
|
||||||
|
found 0 vulnerabilities
|
||||||
|
exit=0
|
||||||
|
## apps/frontend · synthèse tous niveaux (metadata)
|
||||||
|
vulnerabilites: {"info": 0, "low": 0, "moderate": 0, "high": 0, "critical": 0, "total": 0}
|
||||||
|
dependances: {"prod": 13, "dev": 499, "optional": 150, "peer": 0, "peerOptional": 0, "total": 511}
|
||||||
|
|
||||||
|
## tests/e2e · seuil CI : --audit-level=high
|
||||||
|
found 0 vulnerabilities
|
||||||
|
exit=0
|
||||||
|
## tests/e2e · synthèse tous niveaux (metadata)
|
||||||
|
vulnerabilites: {"info": 0, "low": 0, "moderate": 0, "high": 0, "critical": 0, "total": 0}
|
||||||
|
dependances: {"prod": 1, "dev": 26, "optional": 20, "peer": 0, "peerOptional": 0, "total": 26}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Checkov 3.3.19 · 2026-09-24 14:47 CEST · commit 9f343e9, sur un export git archive du commit (aucun fichier ignoré du poste)
|
||||||
|
# Option --quiet : seuls les contrôles en échec sont listés, le résumé JSON donne les réussis.
|
||||||
|
|
||||||
|
## 1. infra/terraform, framework terraform
|
||||||
|
-- résumé : (aucune ressource) {'passed': 0, 'failed': 0, 'skipped': 0, 'parsing_errors': 0, 'resource_count': 0, 'checkov_version': '3.3.19'}
|
||||||
|
Lecture : resource_count 0. Les 6 ressources Terraform du dépôt sont des null_resource (provisioners SSH), pour lesquelles Checkov n'a aucune politique : ce scan ne prouve rien sur ce Terraform, dans un sens comme dans l'autre.
|
||||||
|
|
||||||
|
## 2. framework dockerfile, dépôt entier
|
||||||
|
dockerfile scan results:
|
||||||
|
Passed checks: 268, Failed checks: 3, Skipped checks: 0
|
||||||
|
Check: CKV_DOCKER_2: "Ensure that HEALTHCHECK instructions have been added to container images"
|
||||||
|
FAILED for resource: /ml/Dockerfile.
|
||||||
|
File: /ml/Dockerfile:1-7
|
||||||
|
Check: CKV_DOCKER_2: "Ensure that HEALTHCHECK instructions have been added to container images"
|
||||||
|
FAILED for resource: /etl/airflow/Dockerfile.
|
||||||
|
File: /etl/airflow/Dockerfile:1-50
|
||||||
|
Check: CKV_DOCKER_2: "Ensure that HEALTHCHECK instructions have been added to container images"
|
||||||
|
FAILED for resource: /apps/frontend/Dockerfile.
|
||||||
|
File: /apps/frontend/Dockerfile:1-47
|
||||||
|
-- résumé : dockerfile {'passed': 268, 'failed': 3, 'skipped': 0, 'parsing_errors': 0, 'resource_count': 4, 'checkov_version': '3.3.19'}
|
||||||
|
|
||||||
|
## 3. framework github_actions, dépôt entier
|
||||||
|
github_actions scan results:
|
||||||
|
Passed checks: 596, Failed checks: 0, Skipped checks: 0
|
||||||
|
-- résumé : github_actions {'passed': 596, 'failed': 0, 'skipped': 0, 'parsing_errors': 0, 'resource_count': 0, 'checkov_version': '3.3.19'}
|
||||||
|
|
||||||
|
## 4. framework secrets, dépôt entier
|
||||||
|
-- résumé : (aucune ressource) {'passed': 0, 'failed': 0, 'skipped': 0, 'parsing_errors': 0, 'resource_count': 0, 'checkov_version': '3.3.19'}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# gitleaks (image zricethezav/gitleaks:latest, sha256:c00b6bd0aeb3) · 2026-09-24 14:48 CEST
|
||||||
|
# Commande : docker run --rm -v <depot>:/repo:ro zricethezav/gitleaks:latest git /repo --log-opts="--all" --redact --report-format json
|
||||||
|
# Périmètre : toutes les branches locales et distantes, 342 commits hors merges (487 avec merges), commit de tête 9f343e9
|
||||||
|
# Champs Author et Email retirés de cette copie (anonymisation). Valeurs caviardées par --redact.
|
||||||
|
|
||||||
|
Résultat brut : 339 commits scannés, ~3,43 Mo, 8 constats, tous de la règle generic-api-key.
|
||||||
|
|
||||||
|
- generic-api-key · apps/backend/tests/etl/test_reading_retention.py:74 · commit fa815f49 · 2026-09-24 · extrait caviardé : s3_access_key": "REDACTED"
|
||||||
|
- generic-api-key · apps/frontend/src/app/features/auth/change-password/change-password.spec.ts:90 · commit c2f360c5 · 2026-09-23 · extrait caviardé : current_password: 'REDACTED'
|
||||||
|
- generic-api-key · apps/backend/tests/repositories/test_drift.py:110 · commit 9bf2f271 · 2026-09-22 · extrait caviardé : api_current", REDACTED
|
||||||
|
- generic-api-key · apps/backend/tests/repositories/test_drift.py:113 · commit 9bf2f271 · 2026-09-22 · extrait caviardé : api_history", REDACTED
|
||||||
|
- generic-api-key · apps/backend/tests/repositories/test_reading.py:202 · commit c059f838 · 2026-09-18 · extrait caviardé : api_history", REDACTED
|
||||||
|
- generic-api-key · apps/backend/tests/repositories/test_reading.py:205 · commit c059f838 · 2026-09-18 · extrait caviardé : api_current", REDACTED
|
||||||
|
- generic-api-key · apps/backend/tests/repositories/test_reading.py:98 · commit b433e01f · 2026-09-18 · extrait caviardé : api_history", REDACTED
|
||||||
|
- generic-api-key · apps/backend/tests/repositories/test_reading.py:101 · commit b433e01f · 2026-09-18 · extrait caviardé : api_current", REDACTED
|
||||||
|
|
||||||
|
Tri manuel : les 8 constats sont des faux positifs.
|
||||||
|
- 6 constats (test_drift.py, test_reading.py), déjà triés le 23/09 : la règle generic-api-key prend pour une clé la valeur qui suit un identifiant commençant par « api_ ». Les lignes visées sont des appels de fabrique de test du type creer_lecture(..., source="api_history", consumption_kwh=20.0) : « api_history » et « api_current » sont deux des trois valeurs admises pour la colonne source de reading (contrainte ck_reading_source, apps/backend/app/models/energy.py), pas des secrets.
|
||||||
|
- apps/backend/tests/etl/test_reading_retention.py:74 : identifiant de clé S3 factice (« GK » suivi de 10 chiffres) construit par la fabrique de test settings_s3(), à côté de SecretStr("un-secret-garage") ; aucun Garage réel ne l'accepte, les vraies clés sont générées sur la machine par scripts/provision-host.sh.
|
||||||
|
- apps/frontend/src/app/features/auth/change-password/change-password.spec.ts:90 : mot de passe provisoire simulé d'un test unitaire Angular (valeur attendue par un mock de AuthService), sans compte réel derrière.
|
||||||
|
|
||||||
|
Contrôle complémentaire : git log --all -- .env apps/backend/.env ml/.env etl/airflow/.env '*.pem' '*.key' '*terraform.tfvars' '*.tfstate' '*.tfstate.backup' dns.token renvoie 0 commit : aucun fichier de secrets, certificat, clé, state Terraform ni jeton DNS n'a jamais été versionné.
|
||||||
|
Seul fichier de configuration sensible versionné : infra/garage/garage.toml (commit e53c7e4, 24/09), par conception : relu ligne à ligne, il ne porte aucun secret, Garage lit GARAGE_RPC_SECRET, GARAGE_ADMIN_TOKEN et GARAGE_METRICS_TOKEN dans son environnement (ADR 0019).
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Trivy config (image aquasec/trivy:latest, sha256:62b1e65e8869) · 2026-09-24 14:46 CEST · commit 9f343e9, sur un export git archive du commit
|
||||||
|
# Commande : docker run --rm -v <export>:/repo:ro aquasec/trivy:latest config /repo
|
||||||
|
|
||||||
|
Report Summary
|
||||||
|
|
||||||
|
┌────────────────────────────────────────┬────────────┬───────────────────┐
|
||||||
|
│ Target │ Type │ Misconfigurations │
|
||||||
|
├────────────────────────────────────────┼────────────┼───────────────────┤
|
||||||
|
│ apps/backend/Dockerfile │ dockerfile │ 0 │
|
||||||
|
├────────────────────────────────────────┼────────────┼───────────────────┤
|
||||||
|
│ apps/frontend/Dockerfile │ dockerfile │ 1 │
|
||||||
|
├────────────────────────────────────────┼────────────┼───────────────────┤
|
||||||
|
│ etl/airflow/Dockerfile │ dockerfile │ 1 │
|
||||||
|
├────────────────────────────────────────┼────────────┼───────────────────┤
|
||||||
|
│ infra/terraform/environments/k3s-cible │ terraform │ 0 │
|
||||||
|
├────────────────────────────────────────┼────────────┼───────────────────┤
|
||||||
|
│ infra/terraform/environments/vm-eni │ terraform │ 0 │
|
||||||
|
├────────────────────────────────────────┼────────────┼───────────────────┤
|
||||||
|
│ ml/Dockerfile │ dockerfile │ 1 │
|
||||||
|
└────────────────────────────────────────┴────────────┴───────────────────┘
|
||||||
|
Legend:
|
||||||
|
- '-': Not scanned
|
||||||
|
- '0': Clean (no security findings detected)
|
||||||
|
|
||||||
|
|
||||||
|
apps/frontend/Dockerfile (dockerfile)
|
||||||
|
=====================================
|
||||||
|
Tests: 27 (SUCCESSES: 26, FAILURES: 1)
|
||||||
|
Failures: 1 (UNKNOWN: 0, LOW: 1, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
|
||||||
|
|
||||||
|
DS-0026 (LOW): Add HEALTHCHECK instruction in your Dockerfile
|
||||||
|
════════════════════════════════════════
|
||||||
|
You should add HEALTHCHECK instruction in your docker container images to perform the health check on running containers.
|
||||||
|
|
||||||
|
See https://avd.aquasec.com/misconfig/ds-0026
|
||||||
|
────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
etl/airflow/Dockerfile (dockerfile)
|
||||||
|
===================================
|
||||||
|
Tests: 27 (SUCCESSES: 26, FAILURES: 1)
|
||||||
|
Failures: 1 (UNKNOWN: 0, LOW: 1, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
|
||||||
|
|
||||||
|
DS-0026 (LOW): Add HEALTHCHECK instruction in your Dockerfile
|
||||||
|
════════════════════════════════════════
|
||||||
|
You should add HEALTHCHECK instruction in your docker container images to perform the health check on running containers.
|
||||||
|
|
||||||
|
See https://avd.aquasec.com/misconfig/ds-0026
|
||||||
|
────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ml/Dockerfile (dockerfile)
|
||||||
|
==========================
|
||||||
|
Tests: 27 (SUCCESSES: 26, FAILURES: 1)
|
||||||
|
Failures: 1 (UNKNOWN: 0, LOW: 1, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
|
||||||
|
|
||||||
|
DS-0026 (LOW): Add HEALTHCHECK instruction in your Dockerfile
|
||||||
|
════════════════════════════════════════
|
||||||
|
You should add HEALTHCHECK instruction in your docker container images to perform the health check on running containers.
|
||||||
|
|
||||||
|
See https://avd.aquasec.com/misconfig/ds-0026
|
||||||
|
────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Trivy fs, scanners vuln et secret (image aquasec/trivy:latest, sha256:62b1e65e8869) · 2026-09-24 14:48 CEST · commit 9f343e9
|
||||||
|
# Commande : docker run --rm -v <depot>:/repo:ro aquasec/trivy:latest fs --scanners vuln,secret --skip-dirs node_modules --skip-dirs .venv --skip-dirs .angular /repo
|
||||||
|
# Périmètre : arbre de travail du poste, donc fichiers ignorés par git compris. Le corps de toute clé détectée est retiré de cette copie.
|
||||||
|
|
||||||
|
Report Summary
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────────┬──────┬─────────────────┬─────────┐
|
||||||
|
│ Target │ Type │ Vulnerabilities │ Secrets │
|
||||||
|
├──────────────────────────────────────────────────────────────────────────────────┼──────┼─────────────────┼─────────┤
|
||||||
|
│ apps/backend/uv.lock │ uv │ 0 │ - │
|
||||||
|
├──────────────────────────────────────────────────────────────────────────────────┼──────┼─────────────────┼─────────┤
|
||||||
|
│ apps/frontend/package-lock.json │ npm │ 0 │ - │
|
||||||
|
├──────────────────────────────────────────────────────────────────────────────────┼──────┼─────────────────┼─────────┤
|
||||||
|
│ etl/airflow/uv.lock │ uv │ 1 │ - │
|
||||||
|
├──────────────────────────────────────────────────────────────────────────────────┼──────┼─────────────────┼─────────┤
|
||||||
|
│ ml/mlruns/1/models/m-07209b6bfc7a4edfa1c8a8b1b4577a34/artifacts/requirements.txt │ pip │ 0 │ - │
|
||||||
|
├──────────────────────────────────────────────────────────────────────────────────┼──────┼─────────────────┼─────────┤
|
||||||
|
│ ml/mlruns/1/models/m-37380043d1da4d578858f40853a7f72d/artifacts/requirements.txt │ pip │ 0 │ - │
|
||||||
|
├──────────────────────────────────────────────────────────────────────────────────┼──────┼─────────────────┼─────────┤
|
||||||
|
│ ml/mlruns/1/models/m-39f8a317ffed49288cbdffb66046ecd4/artifacts/requirements.txt │ pip │ 0 │ - │
|
||||||
|
├──────────────────────────────────────────────────────────────────────────────────┼──────┼─────────────────┼─────────┤
|
||||||
|
│ ml/mlruns/1/models/m-3a8d4c6d896b4ca2b0645b323adfb857/artifacts/requirements.txt │ pip │ 0 │ - │
|
||||||
|
├──────────────────────────────────────────────────────────────────────────────────┼──────┼─────────────────┼─────────┤
|
||||||
|
│ ml/mlruns/1/models/m-57dd632c169a4c43b5777f9fe1a52958/artifacts/requirements.txt │ pip │ 0 │ - │
|
||||||
|
├──────────────────────────────────────────────────────────────────────────────────┼──────┼─────────────────┼─────────┤
|
||||||
|
│ ml/mlruns/1/models/m-d1cbcc5b82a1400ea438c01c12e73536/artifacts/requirements.txt │ pip │ 0 │ - │
|
||||||
|
├──────────────────────────────────────────────────────────────────────────────────┼──────┼─────────────────┼─────────┤
|
||||||
|
│ ml/uv.lock │ uv │ 0 │ - │
|
||||||
|
├──────────────────────────────────────────────────────────────────────────────────┼──────┼─────────────────┼─────────┤
|
||||||
|
│ infra/proxy/tls/privkey.pem │ text │ - │ 1 │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────────┴──────┴─────────────────┴─────────┘
|
||||||
|
Legend:
|
||||||
|
- '-': Not scanned
|
||||||
|
- '0': Clean (no security findings detected)
|
||||||
|
|
||||||
|
|
||||||
|
etl/airflow/uv.lock (uv)
|
||||||
|
========================
|
||||||
|
Total: 1 (UNKNOWN: 0, LOW: 0, MEDIUM: 1, HIGH: 0, CRITICAL: 0)
|
||||||
|
|
||||||
|
┌───────────────────────────────┬────────────────┬──────────┬────────┬───────────────────┬───────────────┬─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │ Title │
|
||||||
|
├───────────────────────────────┼────────────────┼──────────┼────────┼───────────────────┼───────────────┼─────────────────────────────────────────────────────────────┤
|
||||||
|
│ apache-airflow-providers-smtp │ CVE-2026-41016 │ MEDIUM │ fixed │ 2.3.2 │ 3.0.0 │ apache-airflow-providers-smtp: No certificate validation on │
|
||||||
|
│ │ │ │ │ │ │ SMTP STARTTLS connections in SMTP provider │
|
||||||
|
│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2026-41016 │
|
||||||
|
└───────────────────────────────┴────────────────┴──────────┴────────┴───────────────────┴───────────────┴─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
infra/proxy/tls/privkey.pem (secrets)
|
||||||
|
=====================================
|
||||||
|
Total: 1 (HIGH: 1) · AsymmetricPrivateKey (private-key) · infra/proxy/tls/privkey.pem:2-27 · [contenu retiré]
|
||||||
|
|
||||||
|
Lecture :
|
||||||
|
- CVE-2026-41016 (= PYSEC-2026-24 de pip-audit) : dépendance transitive d'apache-airflow 3.3.2, SmtpHook sans validation de certificat au STARTTLS. Aucun DAG n'envoie de courriel et aucune connexion SMTP n'est déclarée dans Airflow : pas d'exposition aujourd'hui. Correctif : providers-smtp >= 3.0.0. Inchangée depuis le 23/09 : le verrou etl/airflow/uv.lock n'a pas bougé et n'est audité ni par la CI ni par Dependabot.
|
||||||
|
- privkey.pem : clé du certificat auto-signé du poste, générée par make tls-selfsigned pour le développement local ; en recette et en production, le certificat vient de Let's Encrypt (ADR 0018). Ignorée par git (.gitignore, motif infra/proxy/tls/*.pem), jamais commitée (git log --all -- '*.pem' vide, cf. 05-gitleaks.txt). Risque réel : la retrouver dans un ZIP fabriqué à partir de l'arbre de travail au lieu d'un clone.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Tests d'accès et d'authentification du backend · 2026-09-24 14:50 CEST · commit 9f343e9
|
||||||
|
# Commande : uv run --frozen pytest tests/api/test_matrice_acces.py tests/api/test_route_protection.py tests/api/test_authorization.py tests/api/test_hardening.py tests/api/test_auth.py tests/core/test_security.py -m 'not integration' --no-cov
|
||||||
|
# Les tests marqués integration (mêmes matrices avec de vrais jetons et une vraie base) tournent en CI sur TimescaleDB, pas ici.
|
||||||
|
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
.............. [100%]
|
||||||
|
86 passed, 5 deselected in 3.36s
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Terraform fmt et validate (mêmes étapes que .github/workflows/infra.yml) · 2026-09-24 14:50 CEST · commit 9f343e9, sur un export git archive · Terraform v1.16.3
|
||||||
|
|
||||||
|
## terraform fmt -check -recursive infra/terraform
|
||||||
|
exit=0
|
||||||
|
|
||||||
|
## infra/terraform/environments/k3s-cible/ : init -backend=false puis validate
|
||||||
|
init exit=0
|
||||||
|
Success! The configuration is valid.
|
||||||
|
|
||||||
|
exit=0
|
||||||
|
|
||||||
|
## infra/terraform/environments/vm-eni/ : init -backend=false puis validate
|
||||||
|
init exit=0
|
||||||
|
Success! The configuration is valid.
|
||||||
|
|
||||||
|
exit=0
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Reverse proxy, frontal SNI et ports publiés · 2026-09-24 14:51 CEST · commit 9f343e9, configuration lue sur un export git archive
|
||||||
|
|
||||||
|
## nginx -t du proxy de stack (image nginx:1.31-alpine, celle de docker-compose.prod.yml, configuration montée en lecture seule)
|
||||||
|
# --add-host backend et frontend : nginx résout les upstream au test, hors du réseau Compose. Certificat : celui du poste, monté en lecture seule.
|
||||||
|
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
|
||||||
|
nginx: configuration file /etc/nginx/nginx.conf test is successful
|
||||||
|
|
||||||
|
## nginx -t du frontal SNI de la machine (même commande que infra.yml)
|
||||||
|
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
|
||||||
|
nginx: configuration file /etc/nginx/nginx.conf test is successful
|
||||||
|
|
||||||
|
## Directives de sécurité du proxy (grep sur infra/proxy)
|
||||||
|
infra/proxy/nginx.conf:1:# Contrainte : les directives `limit_req_zone` ne sont valides que dans le bloc `http`.
|
||||||
|
infra/proxy/nginx.conf:16: server_tokens off;
|
||||||
|
infra/proxy/nginx.conf:26: client_max_body_size 2m;
|
||||||
|
infra/proxy/nginx.conf:35: limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s;
|
||||||
|
infra/proxy/nginx.conf:36: limit_req_zone $binary_remote_addr zone=auth:10m rate=30r/m;
|
||||||
|
infra/proxy/nginx.conf:37: limit_req_status 429;
|
||||||
|
infra/proxy/conf.d/enervision.conf:14: listen 80 default_server;
|
||||||
|
infra/proxy/conf.d/enervision.conf:22: return 301 https://$host$request_uri;
|
||||||
|
infra/proxy/conf.d/enervision.conf:27: listen 443 ssl default_server;
|
||||||
|
infra/proxy/conf.d/enervision.conf:28: listen 4443 ssl proxy_protocol default_server;
|
||||||
|
infra/proxy/conf.d/enervision.conf:34: real_ip_header proxy_protocol;
|
||||||
|
infra/proxy/conf.d/enervision.conf:40: ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
infra/proxy/conf.d/enervision.conf:44: ssl_session_tickets off;
|
||||||
|
infra/proxy/conf.d/enervision.conf:48: add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
infra/proxy/conf.d/enervision.conf:49: add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||||
|
infra/proxy/conf.d/enervision.conf:61: limit_req zone=auth burst=20 nodelay;
|
||||||
|
infra/proxy/conf.d/enervision.conf:67: limit_req zone=api burst=40 nodelay;
|
||||||
|
|
||||||
|
## En-têtes posés par l'API elle-même (apps/backend/app/api/middleware.py)
|
||||||
|
apps/backend/app/api/middleware.py:16: "X-Content-Type-Options": "nosniff",
|
||||||
|
apps/backend/app/api/middleware.py:17: "X-Frame-Options": "DENY",
|
||||||
|
apps/backend/app/api/middleware.py:18: "Referrer-Policy": "no-referrer",
|
||||||
|
apps/backend/app/api/middleware.py:21: "Cross-Origin-Resource-Policy": "same-origin",
|
||||||
|
apps/backend/app/api/middleware.py:37: response.headers["Cache-Control"] = "no-store"
|
||||||
|
|
||||||
|
## Frontal SNI (infra/front/nginx.conf) : aiguillage TCP sans déchiffrement
|
||||||
|
map $ssl_preread_server_name $stack {
|
||||||
|
~^rec\. 127.0.0.1:8444;
|
||||||
|
~^dev\. 127.0.0.1:9444;
|
||||||
|
default 127.0.0.1:10444;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443;
|
||||||
|
ssl_preread on;
|
||||||
|
proxy_pass $stack;
|
||||||
|
proxy_protocol on;
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
|
Réseau : network_mode host (infra/front/compose.yml), ports 80 et 443 de la machine. Il lit le nom demandé (SNI), sans déchiffrer, et relaie en PROXY protocol vers l'écouteur 4443 du proxy de la stack visée, sur 127.0.0.1.
|
||||||
|
|
||||||
|
## Ports publiés après fusion (docker compose 5.5.1, --env-file .env.example, profil monitoring, config --format json)
|
||||||
|
# Commande : docker compose --env-file .env.example -f docker-compose.yml -f docker-compose.prod.yml --profile monitoring config --format json, puis extraction des seuls champs ports (aucune variable recopiée)
|
||||||
|
airflow-apiserver 127.0.0.1:8080->8080
|
||||||
|
airflow-dag-processor aucun port publié
|
||||||
|
airflow-init aucun port publié
|
||||||
|
airflow-scheduler aucun port publié
|
||||||
|
alertmanager 127.0.0.1:9093->9093
|
||||||
|
backend aucun port publié
|
||||||
|
cadvisor aucun port publié
|
||||||
|
db 127.0.0.1:5433->5432
|
||||||
|
frontend aucun port publié
|
||||||
|
garage 127.0.0.1:3900->3900, 127.0.0.1:3903->3903
|
||||||
|
grafana 127.0.0.1:3001->3000
|
||||||
|
mailpit 127.0.0.1:8025->8025
|
||||||
|
node-exporter aucun port publié
|
||||||
|
postgres-exporter aucun port publié
|
||||||
|
prometheus 127.0.0.1:9090->9090
|
||||||
|
proxy 0.0.0.0:80->80, 0.0.0.0:443->443, 127.0.0.1:<aléatoire>->4443
|
||||||
|
|
||||||
|
## Ports réellement posés sur la machine par scripts/provision-host.sh (l. 212-214, colonnes https, http, front)
|
||||||
|
prod 127.0.0.1:10443 127.0.0.1:10080 127.0.0.1:10444
|
||||||
|
rec 127.0.0.1:8443 127.0.0.1:8081 127.0.0.1:8444
|
||||||
|
dev 127.0.0.1:9443 127.0.0.1:8083 127.0.0.1:9444
|
||||||
|
Base, Mailpit, Airflow, Garage et supervision décalés par environnement, tous sur 127.0.0.1.
|
||||||
|
|
||||||
|
Lecture : les défauts du dépôt publient le proxy en 80 et 443 pour un poste de développement. Sur la machine, provision-host.sh ramène tous les ports des trois stacks sur la boucle locale : le seul composant joignable depuis le réseau est le frontal SNI, en 80 et 443. Backend, frontend, scheduler, dag-processor et exporteurs ne publient rien. Le port SMTP 1025 de Mailpit, publié en développement, disparaît (!override). L'état effectif de la machine est relevé dans 12-constats-machine.txt.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# State Terraform local de la racine vm-eni · relevé 2026-09-24 14:50 CEST · commit 9f343e9
|
||||||
|
# Commande : terraform -chdir=infra/terraform/environments/vm-eni state list (seuls les noms de ressources, aucun attribut recopié)
|
||||||
|
|
||||||
|
null_resource.docker_engine
|
||||||
|
null_resource.environnements
|
||||||
|
null_resource.runner_github
|
||||||
|
|
||||||
|
Dernière écriture du state : 2026-09-23 12:12:09
|
||||||
|
serial 12 · terraform 1.16.3 · 3 ressources appliquées sur 4 déclarées dans vm-eni/main.tf
|
||||||
|
|
||||||
|
Lecture : depuis le relevé du 23/09 9h05 (serial 3, 2 ressources), les applies du 22/09 au 23/09 ont installé Docker, préparé les trois environnements (clones, .env générés sur la machine, certificats) et enregistré le runner GitHub (null_resource.runner_github). La quatrième ressource, null_resource.coffre (volumes Docker dans un coffre LUKS), est désactivée tant que la variable coffre_taille est vide (count = 0), et elle l'est restée : la machine est un conteneur LXC, où LUKS est impossible (ADR 0020), elle n'a donc jamais été appliquée. Clés des triggers relevées (valeurs non recopiées) : docker_engine [hote, user], environnements [depot, domaine, racine, script], runner_github [dossier, labels, nom, version]. L'adresse de la machine et le compte SSH y figurent, mais ni le jeton d'enregistrement du runner ni la clé SSH : le jeton est une variable sensitive hors des triggers (ADR 0010).
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Constats sur la machine du groupe, en lecture seule · 24/09/2026 15h20 à 15h30 CEST · commit déployé en production 9f343e9
|
||||||
|
# Commande : ssh root@<IP-VM-G3> 'bash -s' < constats-machine-enervision.sh, puis constats-complement-enervision.sh, lancés par l'auteur
|
||||||
|
# Anonymisation : adresse et nom de la machine masqués, identifiants de cluster Vault et adresses internes des réseaux Docker retirés.
|
||||||
|
|
||||||
|
## Virtualisation (systemd-detect-virt)
|
||||||
|
lxc
|
||||||
|
|
||||||
|
## Ports TCP en écoute (ss -tlnpH, adresse locale et processus)
|
||||||
|
Sur toutes les interfaces :
|
||||||
|
0.0.0.0:22 et [::]:22 sshd
|
||||||
|
0.0.0.0:80, 0.0.0.0:443 frontal SNI (enervision-front)
|
||||||
|
*:6443 k3s-server
|
||||||
|
0.0.0.0:8200, *:8201 vault
|
||||||
|
0.0.0.0:8202, *:8203 vault
|
||||||
|
0.0.0.0:8204, *:8205 vault
|
||||||
|
Sur la boucle locale seulement :
|
||||||
|
proxys des stacks 127.0.0.1:10080, 10443, 10444 (prod) · 8081, 8443, 8444 (rec) · 8083, 9443, 9444 (dev)
|
||||||
|
bases des stacks 127.0.0.1:5433 (prod), 5434 (rec), 5435 (dev)
|
||||||
|
Mailpit 127.0.0.1:8025, 8026, 8027
|
||||||
|
API Airflow 127.0.0.1:8080, 8082, 8084
|
||||||
|
Garage (S3, admin) 127.0.0.1:3900, 3903 (prod) · 3910, 3913 (rec) · aucun pour dev
|
||||||
|
Grafana, Prometheus, Alertmanager (prod) 127.0.0.1:3001, 9090, 9093
|
||||||
|
PostgreSQL 18 de l'hôte 127.0.0.1:5432, [::1]:5432
|
||||||
|
Postfix de l'hôte 127.0.0.1:25, [::1]:25
|
||||||
|
k3s (interne) 127.0.0.1:6444
|
||||||
|
containerd 127.0.0.1:34303
|
||||||
|
résolveur systemd 127.0.0.53:53, 127.0.0.54:53
|
||||||
|
|
||||||
|
## Pare-feu (nft list ruleset, iptables -S)
|
||||||
|
table inet filter : chaînes input, forward et output vides, politique accept.
|
||||||
|
table ip raw : 24 règles posées par Docker, qui rejettent tout paquet destiné à un port publié sur 127.0.0.1 (ou à un conteneur) s'il n'arrive pas par lo (ou par le pont Docker du projet).
|
||||||
|
iptables : -P INPUT ACCEPT, -P FORWARD DROP, -P OUTPUT ACCEPT, et les chaînes DOCKER, DOCKER-USER, DOCKER-FORWARD de Docker.
|
||||||
|
|
||||||
|
## SSH effectif (sshd -T)
|
||||||
|
permitrootlogin yes
|
||||||
|
pubkeyauthentication yes
|
||||||
|
passwordauthentication yes
|
||||||
|
kbdinteractiveauthentication no
|
||||||
|
|
||||||
|
## Stacks (docker ps par projet Compose)
|
||||||
|
### enervision-prod : 15 services démarrés, airflow-init terminé (0)
|
||||||
|
airflow-apiserver (healthy), airflow-dag-processor, airflow-scheduler, alertmanager, backend (healthy), cadvisor (healthy), db (healthy), frontend, garage (healthy), grafana, mailpit (healthy), node-exporter, postgres-exporter, prometheus, proxy
|
||||||
|
### enervision-rec : 9 services démarrés, airflow-init terminé (0)
|
||||||
|
airflow-apiserver (healthy), airflow-dag-processor, airflow-scheduler, backend (healthy), db (healthy), frontend, garage (healthy), mailpit (healthy), proxy
|
||||||
|
### enervision-dev : 8 services démarrés, airflow-init terminé (0), pas de Garage
|
||||||
|
airflow-apiserver (healthy), airflow-dag-processor, airflow-scheduler, backend (healthy), db (healthy), frontend, mailpit (healthy), proxy
|
||||||
|
### enervision-front
|
||||||
|
front, démarré depuis 26 heures
|
||||||
|
### Hors projets EnerVision
|
||||||
|
19 conteneurs arrêtés entre le 16 et le 21/09 ou jamais démarrés, dont 3 hello-world ; aucun en fonctionnement.
|
||||||
|
|
||||||
|
## Sondes de santé par le nom public, certificat vérifié (curl sans -k)
|
||||||
|
prod http=200 verification_tls=0
|
||||||
|
rec http=200 verification_tls=0
|
||||||
|
dev http=200 verification_tls=0
|
||||||
|
|
||||||
|
## Certificats servis par le frontal (openssl s_client -servername)
|
||||||
|
prod : issuer=C = US, O = Let's Encrypt, CN = YE1 · notAfter=Dec 22 09:40:38 2026 GMT
|
||||||
|
rec : issuer=C = US, O = Let's Encrypt, CN = YE1 · notAfter=Dec 22 09:11:04 2026 GMT
|
||||||
|
dev : issuer=C = US, O = Let's Encrypt, CN = YE1 · notAfter=Dec 22 09:17:54 2026 GMT
|
||||||
|
|
||||||
|
## En-têtes servis en production sur / (curl -sI)
|
||||||
|
server: nginx
|
||||||
|
strict-transport-security: max-age=31536000; includeSubDomains
|
||||||
|
content-security-policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
|
||||||
|
|
||||||
|
## Droits des secrets sur disque (stat)
|
||||||
|
600 enervision /srv/enervision/dev/.env
|
||||||
|
600 enervision /srv/enervision/prod/.env
|
||||||
|
600 enervision /srv/enervision/rec/.env
|
||||||
|
600 enervision /srv/enervision/dns.token
|
||||||
|
|
||||||
|
## Runner GitHub Actions (systemctl)
|
||||||
|
actions.runner.<depot>.<machine-G3>.service loaded active running
|
||||||
|
|
||||||
|
## Journal d'audit de la production (select action, count(*) from audit_log group by action)
|
||||||
|
user.created|5
|
||||||
|
user.password_changed|3
|
||||||
|
|
||||||
|
## Supervision de la production (API Prometheus, 127.0.0.1:9090)
|
||||||
|
cibles : alertmanager, backend, cadvisor, garage, node, postgres, prometheus · toutes health=up
|
||||||
|
règles chargées : ApiErreursServeur, ApiIndisponible, ApiLatenceElevee, BaseConnexionsSaturees, BaseIndisponible, CibleInjoignable, HoteCpuSature, HoteDisquePlein, HoteMemoireSaturee (groupes api, base, hote, supervision)
|
||||||
|
|
||||||
|
## Services hors plateforme
|
||||||
|
k3s : binaire /usr/local/bin/k3s du 17/09/2026 14:07 UTC · systemctl show k3s : ActiveState=activating, SubState=start, NRestarts=51678 · k3s kubectl get pods -A : No resources found, API refusée pendant un redémarrage
|
||||||
|
vault.service : active (running), « HashiCorp Vault »
|
||||||
|
Vault, seal-status sans authentification (champs utiles) :
|
||||||
|
8200 : version 2.1.1, shamir t=2 n=5, initialized=true, sealed=false, storage_type=file, démarré le 22/09 12:28 UTC
|
||||||
|
8202 : version 2.1.1, shamir t=3 n=5, initialized=true, sealed=false, storage_type=file, démarré le 22/09 07:27 UTC
|
||||||
|
8204 : version 2.1.1, shamir t=4 n=5, initialized=true, sealed=false, storage_type=file, démarré le 22/09 07:28 UTC
|
||||||
|
postgresql@18-main : active (running), boucle locale seulement
|
||||||
|
postfix@- : active (running), boucle locale seulement
|
||||||
|
|
||||||
|
Lecture :
|
||||||
|
- La plateforme livrée tient sa promesse : de ses composants, seul le frontal SNI écoute hors de la boucle locale, en 80 et 443. Les trois noms publics répondent 200 avec un certificat Let's Encrypt vérifié, HSTS et CSP sont servis, les .env sont en 600, le runner tourne, les sept cibles Prometheus sont up.
|
||||||
|
- La machine, elle, expose davantage : SSH en root avec mot de passe accepté, l'API k3s sur 6443 et trois serveurs Vault descellés sur 8200 à 8205, tous sur toutes les interfaces, sans pare-feu hôte (table nftables vide, politique accept).
|
||||||
|
- k3s et Vault ne viennent pas du code livré : git grep vault ne trouve rien sur le commit gelé. Vault est visé par le Terraform d'une branche de travail non fusionnée (provider vault, adresse de la machine en 8200). k3s redémarre en boucle depuis son installation du 17/09 (51 678 redémarrages) et ne porte aucun pod : il consomme sans servir.
|
||||||
|
- L'environnement dev tourne encore le commit du 23/09 : pas de Garage. Il suit la dernière branche lancée à la main (ADR 0017), et aucune ne l'a été depuis.
|
||||||
|
- Le journal d'audit ne compte que les événements d'administration des comptes (5 créations, 3 changements de mot de passe) : les connexions vont à login_attempt.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Réglages GitHub du dépôt, lus par l'API (gh api, lecture seule) · 2026-09-24 14:54 CEST · commit 9f343e9
|
||||||
|
# Heures converties en heure locale (CEST). URL des environnements non reproduites (anonymisation).
|
||||||
|
|
||||||
|
## Dépôt
|
||||||
|
visibilité : public · branche par défaut : main · licence : aucune · fork autorisé : oui
|
||||||
|
|
||||||
|
## Protection des branches (gh api repos/<depot>/branches/<b> --jq .protected ; gh api repos/<depot>/rulesets)
|
||||||
|
main : protected=false
|
||||||
|
dev : protected=false
|
||||||
|
rulesets : 0
|
||||||
|
|
||||||
|
## Environnements de déploiement (gh api repos/<depot>/environments/<env>)
|
||||||
|
prod : 1 règle, branch_policy, restreint à la branche main ; relecteurs requis : 0 ; délai : aucun
|
||||||
|
rec : aucune règle
|
||||||
|
dev : aucune règle
|
||||||
|
|
||||||
|
## Approbation des workflows des contributeurs externes (gh api repos/<depot>/actions/permissions/workflow)
|
||||||
|
refusé avec les droits d'un membre (HTTP 403) : réglage non vérifiable, seule l'administratrice le lit
|
||||||
|
|
||||||
|
## Secrets d'Actions (gh secret list, noms seulement)
|
||||||
|
SONAR_TOKEN
|
||||||
|
|
||||||
|
## Derniers runs de ci.yml (gh run list --workflow ci.yml)
|
||||||
|
24/09 11:51 dev 9f343e9 push success
|
||||||
|
24/09 11:08 main 9f343e9 push success
|
||||||
|
24/09 11:08 dev 5a7e2a9 pull_request success
|
||||||
|
24/09 10:59 dev 5a7e2a9 push success
|
||||||
|
|
||||||
|
## Déploiements de l'environnement prod (gh api repos/<depot>/deployments?environment=prod)
|
||||||
|
24/09 11:12 9f343e9
|
||||||
|
23/09 15:56 5afe2fc
|
||||||
|
23/09 14:56 32f1bef
|
||||||
|
23/09 12:49 7f4364d
|
||||||
|
23/09 12:02 84969d3
|
||||||
|
23/09 12:02 93d5cad
|
||||||
|
23/09 11:37 b786f27
|
||||||
|
|
||||||
|
Lecture :
|
||||||
|
- La production n'attend aucune approbation humaine : la seule garde est la restriction à main. L'ADR 0009 annonce une production après approbation, l'ADR 0014 laisse ce réglage à poser par l'administratrice : il ne l'est pas.
|
||||||
|
- Aucune branche protégée : un membre en écriture peut pousser directement sur dev ou main, ce qui est arrivé trois fois sur dev (c2f360c, cbbfaf4, 6c09bee), relus ensuite par les PR de remontée #161 et #163.
|
||||||
|
- Un seul secret côté GitHub, pour SonarCloud : le déploiement n'en consomme aucun, le runner auto-hébergé travaille dans le dossier qui porte déjà le .env.
|
||||||
|
- Chaque push sur main depuis le 23/09 11h37 a produit un déploiement ; le dernier porte le commit gelé 9f343e9.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# DAST OWASP ZAP, dernier passage publié en artefact zap-report (run 35840107213, dast.yml) · 23/09/2026 10:59 CEST · commit fe0d422 (PR de remontée dev vers main)
|
||||||
|
# Récupération : gh run download 35840107213 -n zap-report, puis extraction des alertes du JSON et du bilan de zap-stdout.log
|
||||||
|
# Cible : l'API en fonctionnement dans le job (backend seul, sans TLS ni proxy), authentifiée par un compte lecteur jetable
|
||||||
|
|
||||||
|
## Bilan des règles (zap-stdout.log)
|
||||||
|
FAIL-NEW: 0 FAIL-INPROG: 0 WARN-NEW: 0 WARN-INPROG: 0 INFO: 0 IGNORE: 0 PASS: 118
|
||||||
|
|
||||||
|
## Alertes du rapport (ZAP 2.17.0)
|
||||||
|
- Informational (High) · A Client Error response code was returned by the server [100000] · 65 occurrence(s)
|
||||||
|
- Informational (Medium) · Non-Storable Content [10049] · 5 occurrence(s)
|
||||||
|
- Informational (High) · Authentication Request Identified [10111] · 1 occurrence(s)
|
||||||
|
exemple : POST /api/v1/auth/login · paramètre email
|
||||||
|
- Informational (Medium) · Information Disclosure - Sensitive Information in URL [10024] · 1 occurrence(s)
|
||||||
|
exemple : GET /api/v1/auth/reset-password/validate?token=token · paramètre token
|
||||||
|
|
||||||
|
Lecture :
|
||||||
|
- 0 échec, 0 avertissement, 118 règles passées. Les 4 alertes restantes sont informatives.
|
||||||
|
- « Client Error response code » (65) : 42 × 404, 16 × 403, 3 × 405, 2 × 422, 2 × 401, les refus attendus d'un scanner qui sonde des routes inexistantes, gardées par rôle ou appelées avec une charge invalide. Aucun 5xx.
|
||||||
|
- « Non-Storable Content » (5) : login, logout et refresh marqués Cache-Control: no-store par l'API, voulu ; les deux sondes de santé, que ZAP juge non mémorisables parce qu'appelées avec un en-tête Authorization.
|
||||||
|
- « Authentication Request Identified » : repérage de la route de connexion, sans défaut associé.
|
||||||
|
- « Sensitive Information in URL » : le jeton de réinitialisation voyage en paramètre de GET /api/v1/auth/reset-password/validate. Mitigations en place : jeton stocké haché (token_hash), valable 15 minutes (password_reset_ttl_seconds = 900), à usage unique (consumed_at), caviardé des journaux de l'API (app/core/logging.py, motif token=), Referrer-Policy: no-referrer. Résidu réel : le journal d'accès du proxy (log_format enervision, champ "$request", infra/proxy/nginx.conf:18) enregistre l'URL complète, donc le jeton en clair pendant sa durée de validité, lisible par qui administre la machine.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,70 @@
|
|||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 17mm 16mm 19mm 16mm;
|
||||||
|
@bottom-left {
|
||||||
|
content: string(doctitle);
|
||||||
|
font: 8pt Lato, "Liberation Sans", Arial, sans-serif;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
@bottom-right {
|
||||||
|
content: counter(page) " / " counter(pages);
|
||||||
|
font: 8pt Lato, "Liberation Sans", Arial, sans-serif;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--ink: #1f2937;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--label: #374151;
|
||||||
|
--accent: #16a34a;
|
||||||
|
--accent-dark: #15803d;
|
||||||
|
--accent-light: #dcfce7;
|
||||||
|
--rule: #e5e7eb;
|
||||||
|
--border: #d1d5db;
|
||||||
|
--soft: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
html { font: 10pt/1.48 Lato, "Liberation Sans", Arial, sans-serif; color: var(--ink); background: #fff; }
|
||||||
|
body { margin: 0; }
|
||||||
|
|
||||||
|
.brand { display: flex; align-items: center; justify-content: space-between; padding-bottom: 3mm; margin-bottom: 5mm; border-bottom: 0.3mm solid var(--rule); }
|
||||||
|
.brand .lockup { display: flex; align-items: center; gap: 3mm; }
|
||||||
|
.brand img { height: 9mm; width: 9mm; }
|
||||||
|
.brand .name { font-size: 14pt; font-weight: 700; color: var(--ink); }
|
||||||
|
.brand .meta { font-size: 8pt; font-weight: 700; letter-spacing: 0.12em; color: var(--accent-dark); text-transform: uppercase; }
|
||||||
|
|
||||||
|
h1 { string-set: doctitle content(); font-size: 20pt; line-height: 1.2; color: var(--ink); margin: 0 0 5mm; }
|
||||||
|
h1::after { content: ""; display: block; width: 14mm; height: 1mm; background: var(--accent); margin-top: 3mm; }
|
||||||
|
h2 { font-size: 13.5pt; color: var(--ink); margin: 7mm 0 3mm; padding-bottom: 1.2mm; border-bottom: 0.3mm solid var(--rule); break-after: avoid; }
|
||||||
|
h3 { font-size: 11pt; color: var(--label); margin: 5mm 0 2mm; break-after: avoid; }
|
||||||
|
h4 { font-size: 10.2pt; margin: 4mm 0 1.5mm; break-after: avoid; }
|
||||||
|
p, li { orphans: 3; widows: 3; }
|
||||||
|
a { color: var(--accent-dark); text-decoration: none; }
|
||||||
|
strong { font-weight: 700; }
|
||||||
|
|
||||||
|
code { font: 8.4pt "Noto Sans Mono", "DejaVu Sans Mono", monospace; background: var(--soft); padding: 0 1mm; border-radius: 1mm; }
|
||||||
|
pre { background: var(--soft); padding: 3mm; border-radius: 0 2mm 2mm 0; border-left: 1mm solid var(--accent); overflow-wrap: anywhere; white-space: pre-wrap; break-inside: avoid; }
|
||||||
|
pre code { background: none; padding: 0; }
|
||||||
|
|
||||||
|
table { border-collapse: collapse; width: 100%; margin: 3mm 0 4mm; font-size: 8.6pt; break-inside: auto; }
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { break-inside: avoid; }
|
||||||
|
th, td { padding: 1.5mm 2.2mm; text-align: left; vertical-align: top; border-bottom: 0.25mm solid var(--rule); }
|
||||||
|
th { font-size: 7.4pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); border-bottom: 0.4mm solid var(--border); }
|
||||||
|
|
||||||
|
thead:has(th:empty + th:empty) { display: none; }
|
||||||
|
table:has(thead th:empty + th:empty) { border-collapse: separate; border-spacing: 0 0.6mm; margin-top: 0; }
|
||||||
|
table:has(thead th:empty + th:empty) td { background: var(--soft); border: none; padding: 1.8mm 2.5mm; }
|
||||||
|
table:has(thead th:empty + th:empty) td:first-child { border-radius: 2mm 0 0 2mm; width: 24mm; font-size: 7.4pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); padding-top: 2.2mm; }
|
||||||
|
table:has(thead th:empty + th:empty) td:first-child strong { color: var(--muted); }
|
||||||
|
table:has(thead th:empty + th:empty) td:last-child { border-radius: 0 2mm 2mm 0; }
|
||||||
|
|
||||||
|
blockquote { margin: 3mm 0; padding: 2mm 4mm; border-left: 1mm solid var(--accent); background: var(--soft); border-radius: 0 2mm 2mm 0; }
|
||||||
|
hr { border: none; border-top: 0.3mm solid var(--rule); margin: 6mm 0; }
|
||||||
|
img { max-width: 100%; }
|
||||||
|
|
||||||
|
.badge { display: inline-block; font-size: 6.8pt; font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase; border-radius: 99mm; padding: 0.3mm 1.8mm; line-height: 1.5; vertical-align: 0.3mm; white-space: nowrap; }
|
||||||
|
.badge.ok { background: var(--accent); color: #fff; }
|
||||||
|
.badge.warn { background: #fef9e7; color: #92400e; border: 0.25mm solid #f9a825; }
|
||||||
|
.badge.no { background: #fef2f2; color: #b91c1c; border: 0.25mm solid #dc2626; }
|
||||||
Executable
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Réf : consigne formateur, version figée d'un livrable = Markdown -> HTML -> CSS de pagination -> PDF.
|
||||||
|
# Usage : md2pdf.sh "fichier.md" [sortie.pdf]. Sans sortie, le PDF est écrit à côté du .md.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
src="$1"
|
||||||
|
out="${2:-${src%.md}.pdf}"
|
||||||
|
here="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
|
title="$(grep -m1 '^# ' "$src" | sed 's/^# //; s/&/\&/g; s/</\</g')"
|
||||||
|
css_title="$(grep -m1 '^# ' "$src" | sed 's/^# //; s/\\/\\\\/g; s/"/\\"/g')"
|
||||||
|
logo="$(base64 -w0 "$here/logo-icon.png")"
|
||||||
|
|
||||||
|
npx --yes marked@18 --gfm -i "$src" -o "$work/body.html"
|
||||||
|
sed -i 's/ \([:;?!»]\)/\xc2\xa0\1/g; s/« /«\xc2\xa0/g' "$work/body.html"
|
||||||
|
# Les marqueurs de preuve du rapport EC04 deviennent des pastilles, comme les badges d'état de l'application.
|
||||||
|
sed -i -E 's#\[Prouvé\]#<span class="badge ok">Prouvé</span>#g; s#\[Constaté\]#<span class="badge warn">Constaté</span>#g; s#\[Absent\]#<span class="badge no">Absent</span>#g; s#<strong>((<span class="badge[^"]*">[^<]*</span> ?)+)</strong>#\1#g' "$work/body.html"
|
||||||
|
|
||||||
|
{
|
||||||
|
printf '<!doctype html>\n<html lang="fr"><head><meta charset="utf-8"><title>%s</title><style>\n' "$title"
|
||||||
|
cat "$here/md2pdf.css"
|
||||||
|
# Piège : Chromium ignore `string-set`, le titre du pied de page est donc écrit en dur ici.
|
||||||
|
printf '\n@page { @bottom-left { content: "%s"; } }\n' "$css_title"
|
||||||
|
printf '\n</style></head><body>\n'
|
||||||
|
printf '<div class="brand"><div class="lockup"><img src="data:image/png;base64,%s" alt=""><span class="name">EnerVision</span></div><div class="meta">EADL 2026 · Groupe 3</div></div>\n' "$logo"
|
||||||
|
cat "$work/body.html"
|
||||||
|
printf '\n</body></html>\n'
|
||||||
|
} > "$work/doc.html"
|
||||||
|
|
||||||
|
chromium --headless --disable-gpu --no-pdf-header-footer \
|
||||||
|
--print-to-pdf="$out" "file://$work/doc.html" 2>/dev/null
|
||||||
|
|
||||||
|
echo "PDF écrit : $out"
|
||||||
+78
-62
@@ -1,4 +1,4 @@
|
|||||||
# Pipeline ETL — EnerVision
|
# Pipeline ETL · EnerVision
|
||||||
|
|
||||||
## Objectif
|
## Objectif
|
||||||
|
|
||||||
@@ -236,11 +236,11 @@ apps/backend/
|
|||||||
|
|
||||||
exécuter :
|
exécuter :
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
uv run python -m app.etl.historical_import `
|
uv run python -m app.etl.historical_import \
|
||||||
--csv ..\..\data\raw\all_sites_combined.csv `
|
--csv ../../data/raw/all_sites_combined.csv \
|
||||||
--metadata ..\..\data\raw\dataset_metadata.json `
|
--metadata ../../data/raw/dataset_metadata.json \
|
||||||
--source-timezone UTC `
|
--source-timezone UTC \
|
||||||
--dry-run
|
--dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -250,10 +250,10 @@ Aucune donnée n'est écrite dans la base pendant cette exécution.
|
|||||||
|
|
||||||
Depuis `apps/backend/` :
|
Depuis `apps/backend/` :
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
uv run python -m app.etl.historical_import `
|
uv run python -m app.etl.historical_import \
|
||||||
--csv ..\..\data\raw\all_sites_combined.csv `
|
--csv ../../data/raw/all_sites_combined.csv \
|
||||||
--metadata ..\..\data\raw\dataset_metadata.json `
|
--metadata ../../data/raw/dataset_metadata.json \
|
||||||
--source-timezone UTC
|
--source-timezone UTC
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -303,7 +303,7 @@ Une nouvelle exécution du même import ne crée donc pas de mesures supplément
|
|||||||
|
|
||||||
Depuis la racine du projet, vérifier le nombre d'enregistrements avec :
|
Depuis la racine du projet, vérifier le nombre d'enregistrements avec :
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
docker compose exec db psql -U enervision -d enervision -c "SELECT COUNT(*) AS datasets FROM dataset; SELECT COUNT(*) AS sites FROM site; SELECT COUNT(*) AS readings FROM reading;"
|
docker compose exec db psql -U enervision -d enervision -c "SELECT COUNT(*) AS datasets FROM dataset; SELECT COUNT(*) AS sites FROM site; SELECT COUNT(*) AS readings FROM reading;"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -317,7 +317,7 @@ readings = 122647
|
|||||||
|
|
||||||
Vérifier la source des mesures avec :
|
Vérifier la source des mesures avec :
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
docker compose exec db psql -U enervision -d enervision -c "SELECT source, COUNT(*) FROM reading GROUP BY source ORDER BY source;"
|
docker compose exec db psql -U enervision -d enervision -c "SELECT source, COUNT(*) FROM reading GROUP BY source ORDER BY source;"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -384,7 +384,9 @@ end_time
|
|||||||
limit
|
limit
|
||||||
```
|
```
|
||||||
|
|
||||||
Le paramètre `limit` doit être compris entre 1 et 1000.
|
Le paramètre `limit` doit être compris entre 1 et 1000. Il n'est plus fourni par l'appelant :
|
||||||
|
`limit_for_window()` le dérive de la fenêtre demandée, pour obtenir une lecture par heure,
|
||||||
|
alignée sur l'heure pile.
|
||||||
|
|
||||||
### Configuration de l'API Mock
|
### Configuration de l'API Mock
|
||||||
|
|
||||||
@@ -485,8 +487,13 @@ data_quality = "degraded"
|
|||||||
L'import ne s'interrompt pas pour autant : le mock émet des anomalies par construction, et
|
L'import ne s'interrompt pas pour autant : le mock émet des anomalies par construction, et
|
||||||
`raw_data` conserve la réponse d'origine.
|
`raw_data` conserve la réponse d'origine.
|
||||||
|
|
||||||
La taille des réponses est plafonnée : au plus `MAX_SITES` sites, et au plus `--limit` mesures
|
La taille des réponses est plafonnée : au plus `MAX_SITES` sites, et au plus `limit` mesures par
|
||||||
par site. Au-delà, l'import échoue au lieu de charger.
|
site, une par heure de la fenêtre (`limit_for_window()`). Au-delà, l'import échoue au lieu de
|
||||||
|
charger.
|
||||||
|
|
||||||
|
L'import refuse aussi une fenêtre qui recouvre le dataset historique
|
||||||
|
(`refuse_if_overlaps_historical_dataset()`) : le CSV couvre 2023 et 2024, une fenêtre de l'API Mock
|
||||||
|
doit donc commencer après le 31/12/2024, et démarrer pile sur une heure.
|
||||||
|
|
||||||
Enfin, seuls les champs attendus sont recopiés vers la base. Une clé supplémentaire renvoyée par
|
Enfin, seuls les champs attendus sont recopiés vers la base. Une clé supplémentaire renvoyée par
|
||||||
l'API n'atteint jamais une colonne.
|
l'API n'atteint jamais une colonne.
|
||||||
@@ -495,30 +502,36 @@ l'API n'atteint jamais une colonne.
|
|||||||
|
|
||||||
Le mode `--dry-run` permet de tester la connexion, la récupération des sites et la récupération des mesures sans écrire dans PostgreSQL.
|
Le mode `--dry-run` permet de tester la connexion, la récupération des sites et la récupération des mesures sans écrire dans PostgreSQL.
|
||||||
|
|
||||||
Depuis `apps/backend/` :
|
Depuis `apps/backend/`, sur une fenêtre de deux heures postérieure au dataset historique (une
|
||||||
|
lecture par heure et par site) :
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
uv run python -m app.etl.mock_api_import `
|
uv run python -m app.etl.mock_api_import \
|
||||||
--start-time "2024-06-15T12:00:00" `
|
--start-time "2026-09-24T08:00:00" \
|
||||||
--end-time "2024-06-15T13:00:00" `
|
--end-time "2026-09-24T10:00:00" \
|
||||||
--limit 60 `
|
|
||||||
--dry-run
|
--dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Les heures sans fuseau sont lues en UTC. Seuls `--start-time`, `--end-time` et `--dry-run`
|
||||||
|
existent.
|
||||||
|
|
||||||
### Chargement réel depuis l'API Mock
|
### Chargement réel depuis l'API Mock
|
||||||
|
|
||||||
Depuis `apps/backend/` :
|
Depuis `apps/backend/` :
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
uv run python -m app.etl.mock_api_import `
|
uv run python -m app.etl.mock_api_import \
|
||||||
--start-time "2024-06-15T12:00:00" `
|
--start-time "2026-09-24T08:00:00" \
|
||||||
--end-time "2024-06-15T13:00:00" `
|
--end-time "2026-09-24T10:00:00"
|
||||||
--limit 60
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Résultat validé pour l'API Mock
|
En fonctionnement normal, cet import n'est pas lancé à la main : le DAG `mock_api_import` le
|
||||||
|
joue chaque heure (voir la fin de ce document).
|
||||||
|
|
||||||
Le scénario de validation utilisé couvre la période :
|
### Validation initiale du 18/09, antérieure à la réconciliation
|
||||||
|
|
||||||
|
Le premier scénario de validation, joué le 18/09 avant la clôture de la réconciliation (#15),
|
||||||
|
couvrait la période :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
15/06/2024 12:00 UTC
|
15/06/2024 12:00 UTC
|
||||||
@@ -526,9 +539,11 @@ Le scénario de validation utilisé couvre la période :
|
|||||||
15/06/2024 13:00 UTC
|
15/06/2024 13:00 UTC
|
||||||
```
|
```
|
||||||
|
|
||||||
avec une limite de 60 lectures par site.
|
avec une limite de 60 lectures par site. Il ne se rejoue plus tel quel : depuis le 23/09, cette
|
||||||
|
fenêtre est refusée parce qu'elle recouvre le dataset historique, et l'option `--limit` a disparu
|
||||||
|
au profit d'une lecture par heure.
|
||||||
|
|
||||||
Résultat obtenu :
|
Résultat obtenu à l'époque :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
sites récupérés : 7
|
sites récupérés : 7
|
||||||
@@ -581,7 +596,8 @@ Les tests de l'import API Mock couvrent notamment :
|
|||||||
|
|
||||||
- la récupération des sites ;
|
- la récupération des sites ;
|
||||||
- l'appel à `/api/v1/readings` ;
|
- l'appel à `/api/v1/readings` ;
|
||||||
- les paramètres `site_id`, `start_time`, `end_time` et `limit` ;
|
- les paramètres `site_id`, `start_time`, `end_time` et `limit`, dérivé de la fenêtre ;
|
||||||
|
- le refus d'une fenêtre qui recouvre le dataset historique ;
|
||||||
- la gestion des erreurs HTTP ;
|
- la gestion des erreurs HTTP ;
|
||||||
- la validation du format de la réponse ;
|
- la validation du format de la réponse ;
|
||||||
- la transformation des mesures ;
|
- la transformation des mesures ;
|
||||||
@@ -594,48 +610,43 @@ Les tests de l'import API Mock couvrent notamment :
|
|||||||
|
|
||||||
Exécuter les tests ETL :
|
Exécuter les tests ETL :
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
uv run pytest tests\etl -v
|
uv run pytest tests/etl -v
|
||||||
```
|
```
|
||||||
|
|
||||||
Exécuter les tests unitaires de l'import API Mock :
|
Exécuter les tests unitaires de l'import API Mock :
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
uv run pytest tests\etl\test_mock_api_import.py -v
|
uv run pytest tests/etl/test_mock_api_import.py -v
|
||||||
```
|
```
|
||||||
|
|
||||||
Exécuter le test d'intégration de l'import API Mock :
|
Exécuter le test d'intégration de l'import API Mock :
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
uv run pytest tests\etl\test_mock_api_import.py -m integration -v
|
uv run pytest tests/etl/test_mock_api_import.py -m integration -v
|
||||||
```
|
```
|
||||||
|
|
||||||
Contrôler la qualité du code :
|
Contrôler la qualité du code :
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
uv run ruff check app\etl tests\etl
|
uv run ruff check app/etl tests/etl
|
||||||
```
|
```
|
||||||
|
|
||||||
Contrôler le typage :
|
Contrôler le typage :
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
uv run mypy app
|
uv run mypy app
|
||||||
```
|
```
|
||||||
|
|
||||||
Exécuter la suite complète avec le seuil de couverture :
|
Exécuter la suite complète avec le seuil de couverture :
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
uv run pytest --cov-fail-under=85
|
uv run pytest --cov-fail-under=85
|
||||||
```
|
```
|
||||||
|
|
||||||
Lors de la validation de l'import API Mock :
|
Lors de la première validation, le 18/09, le fichier comptait 8 tests unitaires et 1 test
|
||||||
|
d'intégration. Il en compte aujourd'hui 42, dont 2 d'intégration (`pytest --collect-only`), après
|
||||||
```text
|
l'ajout des bornes physiques, de la réconciliation et de l'alignement horaire.
|
||||||
8 tests unitaires passés
|
|
||||||
1 test d'intégration passé
|
|
||||||
```
|
|
||||||
|
|
||||||
La suite backend complète a également été validée avec une couverture supérieure au seuil de 85 %.
|
|
||||||
|
|
||||||
## Suite du pipeline Data
|
## Suite du pipeline Data
|
||||||
|
|
||||||
@@ -663,22 +674,27 @@ mock_api_import.py
|
|||||||
|
|
||||||
La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP.
|
La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP.
|
||||||
|
|
||||||
Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`) et orchestre cinq DAGs :
|
Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`) et orchestre sept DAGs :
|
||||||
le pipeline ML (`ml_train` et `ml_score`, issue #115), la détection d'alertes et la génération
|
le pipeline ML (`ml_train` et `ml_score`, issue #115), la détection d'alertes et la génération
|
||||||
des recommandations (`alertes`, issue #116), l'import historique (`historical_import`,
|
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), l'import périodique de l'API Mock (`mock_api_import`, issue #15), la surveillance
|
||||||
|
de dérive du modèle (`derive`, ADR 0013) et la rétention des relevés, exportés vers Garage puis
|
||||||
|
supprimés (`retention`, issue #36, ADR 0019).
|
||||||
|
|
||||||
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é
|
||||||
|
DAG, et refuse une fenêtre qui ne démarre pas pile sur l'heure. Une fenêtre calée sur l'intervalle
|
||||||
|
Airflow tel quel (`[:45, :45)`) placerait cette lecture à :45, hors de la grille horaire du reste
|
||||||
|
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`.
|
||||||
|
|
||||||
Le DAG `mock_api_import` exécute `app.etl.mock_api_import` toutes les heures. Chaque exécution
|
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`,
|
||||||
traite l'intervalle Airflow précédent. Les deux pipelines normalisent leurs données vers les
|
`mock_api_import.py`, `derive.py` et `retention.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui).
|
||||||
tables communes `site` et `reading`, tout en conservant leur source (`csv` ou `api_history`).
|
|
||||||
|
|
||||||
Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py`, `alertes.py`, `historical_import.py` et
|
|
||||||
`mock_api_import.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui).
|
|
||||||
|
|
||||||
Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning.
|
Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning.
|
||||||
|
|||||||
@@ -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
|
||||||
+16
-12
@@ -1,7 +1,9 @@
|
|||||||
# Reverse proxy
|
# Reverse proxy
|
||||||
|
|
||||||
Terminaison TLS et routage de la stack déployée. Seul composant publié sur le réseau : il
|
Terminaison TLS et routage de la stack déployée. Sur un poste, seul composant publié sur le
|
||||||
écoute en 80 et 443, et rien d'autre ne sort du réseau Compose.
|
réseau : il écoute en 80 et 443, et rien d'autre ne sort du réseau Compose. Sur la VM, il
|
||||||
|
n'écoute plus que sur `127.0.0.1`, derrière le frontal SNI `infra/front`, seul composant exposé
|
||||||
|
([ADR 0018](../../docs/adr/0018-noms-publics-certificats-dns01-et-frontal-sni.md)).
|
||||||
|
|
||||||
- `nginx.conf` : bloc `http`, journalisation, compression, zones de limitation de débit.
|
- `nginx.conf` : bloc `http`, journalisation, compression, zones de limitation de débit.
|
||||||
- `conf.d/enervision.conf` : redirection 80 vers 443, terminaison TLS, en-têtes de sécurité,
|
- `conf.d/enervision.conf` : redirection 80 vers 443, terminaison TLS, en-têtes de sécurité,
|
||||||
@@ -15,11 +17,13 @@ configuration est montée en volume par `docker-compose.prod.yml`.
|
|||||||
L'overlay emploie les marqueurs `!override` et `!reset`, qui demandent **Docker Compose 2.24.4
|
L'overlay emploie les marqueurs `!override` et `!reset`, qui demandent **Docker Compose 2.24.4
|
||||||
ou plus récent**. Sur une version antérieure, la fusion échoue au lieu de dépublier les ports.
|
ou plus récent**. Sur une version antérieure, la fusion échoue au lieu de dépublier les ports.
|
||||||
|
|
||||||
Les ports publiés sont `PROXY_HTTP_PORT` et `PROXY_HTTPS_PORT`, 80 et 443 par défaut. Quand deux
|
Les ports publiés sont `PROXY_HTTP_PORT`, `PROXY_HTTPS_PORT` et `PROXY_FRONT_PORT`, 80, 443 et un
|
||||||
environnements partagent la machine ([ADR 0009](../../docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md)),
|
port aléatoire de la boucle locale par défaut. Sur la VM, trois environnements partagent la
|
||||||
la recette publie `8443` et ramène son port 80 sur `127.0.0.1:8081` : la redirection ci-dessous
|
machine ([ADR 0009](../../docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md),
|
||||||
renvoie vers `https://$host` sans port, donc vers la production. `PUBLIC_ORIGIN` porte alors
|
[ADR 0017](../../docs/adr/0017-environnement-dev-a-la-demande.md)) : `scripts/provision-host.sh`
|
||||||
l'origine avec son port pour le CORS et le lien de réinitialisation.
|
place les ports de chaque proxy sur `127.0.0.1` (HTTPS en 10443, 8443 et 9443 pour la production,
|
||||||
|
la recette et le dev), et le frontal aiguille chaque nom vers le sien. Les URL publiques n'ont
|
||||||
|
donc plus de port, et `PUBLIC_ORIGIN` vaut `https://` suivi du nom de l'environnement.
|
||||||
|
|
||||||
## Routage
|
## Routage
|
||||||
|
|
||||||
@@ -55,15 +59,15 @@ make tls-selfsigned PUBLIC_HOST=enervision.local
|
|||||||
make stack-up
|
make stack-up
|
||||||
```
|
```
|
||||||
|
|
||||||
Le navigateur avertira d'un émetteur inconnu : c'est attendu, et c'est le seul mode exploitable
|
Le navigateur avertira d'un émetteur inconnu : c'est attendu. C'est le mode du poste de
|
||||||
tant que la machine cible n'a pas de nom de domaine public.
|
développement et des tests e2e ; la VM utilise Let's Encrypt par DNS-01 (plus bas).
|
||||||
|
|
||||||
### Let's Encrypt
|
### Let's Encrypt
|
||||||
|
|
||||||
Le défi HTTP-01 exige un nom de domaine **résolvable publiquement** et le port 80 joignable
|
Le défi HTTP-01 exige un nom de domaine **résolvable publiquement** et le port 80 joignable
|
||||||
depuis Internet. La cible documentée aujourd'hui (`ssh_host = "10.0.0.10"`, serveur de l'école)
|
depuis Internet. La VM de l'école ne remplit ni l'une ni l'autre condition : ce chemin reste
|
||||||
ne remplit ni l'une ni l'autre condition : le chemin ci-dessous est livré et documenté, il n'a
|
livré pour une machine publique, et n'a pas été exercé. La VM passe par DNS-01 (section
|
||||||
pas été exercé.
|
suivante).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make stack-up # nginx doit tourner pour servir le défi
|
make stack-up # nginx doit tourner pour servir le défi
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
ssh_host = "10.101.200.37"
|
ssh_host = "<IP-VM-G3>"
|
||||||
ssh_port = 22
|
ssh_port = 22
|
||||||
ssh_user = "root"
|
ssh_user = "root"
|
||||||
ssh_private_key_path = "~/.ssh/id_ed25519"
|
ssh_private_key_path = "~/.ssh/id_ed25519"
|
||||||
@@ -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,12 +12,13 @@ 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 :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh -L 3001:127.0.0.1:3001 -L 9090:127.0.0.1:9090 enervision@10.101.200.37
|
ssh -L 3001:127.0.0.1:3001 -L 9090:127.0.0.1:9090 enervision@<IP-VM-G3>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Démarrer
|
## Démarrer
|
||||||
@@ -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,45 @@ 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.
|
||||||
|
Le script refuse de démarrer dans un conteneur LXC (`systemd-detect-virt`) ou sans device-mapper :
|
||||||
|
c'est le cas de la machine ENI, où le chiffrement du disque relève de l'hôte Proxmox.
|
||||||
|
|
||||||
|
## provision-host.sh
|
||||||
|
|
||||||
|
Prépare la machine et ses trois environnements, `prod` sur `main`, `rec` et `dev` sur `dev`
|
||||||
|
([ADR 0009](../docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md),
|
||||||
|
[ADR 0017](../docs/adr/0017-environnement-dev-a-la-demande.md)), sans démarrer aucune stack. Pour
|
||||||
|
chacun, sous `RACINE` (`/srv/enervision` par défaut) : un clone du dépôt, un `.env` en `600` dont
|
||||||
|
les secrets sont générés sur place et jamais réécrits s'ils existent, l'adressage réaligné, un
|
||||||
|
certificat Let's Encrypt par DNS-01 si le jeton dynv6 est dans `RACINE/dns.token` (auto-signé
|
||||||
|
sinon). Lancé en root avec `PROPRIETAIRE`, il pose aussi la tâche cron de renouvellement et donne
|
||||||
|
les dossiers au compte du runner. Joué par Terraform (`infra/terraform/environments/vm-eni`) ou à
|
||||||
|
la main : `PROPRIETAIRE=<utilisateur du runner> bash scripts/provision-host.sh`.
|
||||||
|
Rejouable. Variables : `REPO_URL`, `RACINE`, `DOMAINE`, `PUBLIC_IP`, `PROPRIETAIRE`.
|
||||||
|
|
||||||
|
## tls-selfsigned.sh
|
||||||
|
|
||||||
|
Écrit un certificat auto-signé dans `infra/proxy/tls/` (`fullchain.pem`, `privkey.pem`), là où
|
||||||
|
nginx lit toujours ses certificats, quel que soit le mode d'obtention. Sert au poste de
|
||||||
|
développement et aux tests e2e ; sur la machine, il ne reste en place que si Let's Encrypt échoue.
|
||||||
|
`make tls-selfsigned PUBLIC_HOST=enervision.local`, `FORCE=1` pour écraser. Variables :
|
||||||
|
`PUBLIC_HOST`, `PUBLIC_IP` (ajoutée au certificat), `TLS_DAYS` (365 par défaut).
|
||||||
|
|
||||||
|
## comptes-test.sh
|
||||||
|
|
||||||
|
Réservé à une base **jetable** (CI, e2e, charge sur le poste) : crée un administrateur par la CLI
|
||||||
|
du backend, puis un lecteur et un opérateur, leur fait passer le changement de mot de passe
|
||||||
|
obligatoire, et écrit leurs identifiants en JSON dans `COMPTES_FICHIER`. Appelé par `e2e.yml` et
|
||||||
|
par `make e2e-prepare`. Variables : `BASE_URL` (`http://localhost:8000` par défaut),
|
||||||
|
`COMPTES_FICHIER` (requis), `APP_CLI`, `ADMIN_SUPPLEMENTAIRE=1` pour la base du poste, qui a déjà
|
||||||
|
un administrateur. Nécessite `curl`, `jq` et `openssl`.
|
||||||
|
|||||||
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