feat(infra): déploie Garage par environnement, secrets par le .env, fumée S3 en CI
Reprend l'amorce de la PR 164 et l'intègre à la stack : service `garage` (dxflrs/garage v2.4.1, `--single-node --default-bucket`) dans docker-compose.yml, garage.toml versionné sans secret, ports sur 127.0.0.1 décalés par environnement dans provision-host.sh, garde des six clés GARAGE_* dans le Makefile, cible Prometheus avec jeton, tests de fumée déplacés dans tests/garage et joués par le job compose d'infra.yml contre le vrai conteneur, SSE-C compris. Variables APP_S3_* et APP_READING_RETENTION_DAYS posées sur airflow-scheduler pour le DAG `retention`. ADR 0019. Closes #24
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
# 0019 - Stockage objet Garage par environnement, et cycle de vie des mesures : export puis suppression
|
||||
|
||||
- Statut : accepté
|
||||
- Date : 2026-09-24
|
||||
|
||||
## Contexte
|
||||
|
||||
Les issues #24 « Déployer MinIO » et #36 « Politique de rétention + export vers MinIO » datent du
|
||||
cadrage du 14/09. Au 24/09, la hypertable `reading` grossit d'une lecture par site et par heure
|
||||
sans qu'aucune politique ne la borne, et `docs/architecture/40-data.md` classe rétention et
|
||||
compression parmi les cibles non faites. Aucun stockage objet ne tourne.
|
||||
|
||||
La PR 164 a posé une amorce : un projet Compose à part dans `garage/`, l'image `dxflrs/garage:v1.0.1`,
|
||||
des secrets dans un `garage.toml` gitignoré et des tests de fumée boto3 que rien ne jouait. Rien
|
||||
n'était branché sur les trois environnements de la VM ([ADR 0009](0009-deux-environnements-compose-sur-la-vm-eni.md),
|
||||
[ADR 0017](0017-environnement-dev-a-la-demande.md)), ni sur la CI, ni sur la supervision.
|
||||
|
||||
Contrainte propre au projet : le jeu historique s'arrête au 31/12/2024 et `make demo-data` s'y
|
||||
ancre. Une rétention sous vingt-et-un mois effacerait la démonstration.
|
||||
|
||||
## Décision
|
||||
|
||||
**Garage plutôt que MinIO**, en `v2.4.1`. Un binaire statique de quelques dizaines de Mo, une
|
||||
API S3 suffisante pour boto3, des métriques Prometheus natives, et depuis la `v2.3.0` un mode
|
||||
`--single-node --default-bucket` qui crée layout, clé et bucket au premier démarrage à partir de
|
||||
trois variables d'environnement : aucun conteneur d'initialisation, aucune séquence CLI à rejouer.
|
||||
|
||||
**Un Garage par projet Compose.** Le service `garage` vit dans `docker-compose.yml`, comme `db`
|
||||
et `mailpit`. Chaque environnement a le sien, ses volumes `garage_meta` et `garage_data`, ses
|
||||
secrets et ses ports sur `127.0.0.1` : S3 `3900`, `3910`, `3920` et admin `3903`, `3913`, `3923`
|
||||
pour prod, recette et dev. Le RPC n'est pas publié. Rien ne passe par le proxy.
|
||||
|
||||
**`infra/garage/garage.toml` est versionné sans secret.** `GARAGE_RPC_SECRET` (32 octets
|
||||
hexadécimaux), `GARAGE_ADMIN_TOKEN` et `GARAGE_METRICS_TOKEN` arrivent par l'environnement, comme
|
||||
les autres secrets du `.env`, générés par `scripts/provision-host.sh`. L'image est `FROM scratch`,
|
||||
sans shell : la garde sur les secrets vit dans le `Makefile` (`garage-garde`, appelée par
|
||||
`services-up` et `stack-up`), et le healthcheck est `garage health -q`.
|
||||
|
||||
**Nœud unique assumé.** `replication_factor = 1` et moteur `sqlite`, avec un instantané des
|
||||
métadonnées toutes les six heures. La documentation de Garage réserve ce facteur aux
|
||||
déploiements de test : ici la machine est unique, la redondance n'existe pour aucun autre service,
|
||||
et le coffre LUKS de l'[ADR 0020](0020-chiffrement-au-repos-coffre-luks-et-sse-c.md) porte les
|
||||
volumes. LMDB, le moteur par défaut, se corrompt à l'arrêt brutal et rien ne le reconstruirait.
|
||||
|
||||
**La rétention de `reading` est un traitement du backend, ordonnancé par Airflow.** Le DAG
|
||||
`retention` lance chaque nuit `app.etl.reading_retention` ([ADR 0008](0008-airflow-execute-le-code-du-backend.md)),
|
||||
qui, pour chaque chunk entièrement plus vieux que `READING_RETENTION_DAYS` (1095 jours par défaut) :
|
||||
|
||||
1. lit ses lignes par la hypertable (`WHERE timestamp >= range_start AND timestamp < range_end`) ;
|
||||
2. les sérialise en CSV gzip reproductible, les colonnes `jsonb` et `text[]` en JSON ;
|
||||
3. les dépose sur Garage sous `reading/<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.
|
||||
@@ -97,6 +97,7 @@ ailleurs ([ADR 0016](../adr/0016-supervision-en-profil-compose.md),
|
||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
|
||||
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive livrée côté backend (`app.monitoring.drift`, table `drift_report`, `GET /monitoring/drift`, DAG `derive`), voir [ADR 0013](../adr/0013-surveillance-de-derive-dans-le-backend.md) |
|
||||
| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Provisionnement de la VM par Terraform, qui installe Docker, prépare les deux environnements et enregistre le runner, jamais appliqué ([ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée |
|
||||
| Stockage objet | Garage, S3 | `infra/garage`, `docker-compose.yml` | `Fait` | Un Garage par environnement, `--single-node --default-bucket`, secrets par l'environnement, ports sur `127.0.0.1`, fumée S3 et SSE-C en CI. Reçoit les archives CSV gzip du DAG `retention`, chiffrées SSE-C, avant `drop_chunks` ([ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md), [ADR 0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md)) |
|
||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Fait` | Profil Compose `monitoring`, actif en prod : Prometheus et trois exporteurs (PostgreSQL, hôte, conteneurs), neuf règles d'alerte testées par `promtool`, Alertmanager vers Mailpit, trois tableaux de bord Grafana provisionnés. Voir [60-observabilite.md](60-observabilite.md) |
|
||||
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver et scheduler avec LocalExecutor via Docker Compose, sur une base PostgreSQL dédiée. Six DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes`, `historical_import`, `mock_api_import` et `derive` (quotidien, surveillance de dérive). L'import historique reste manuel et l'import API Mock s'exécute chaque heure. Réconciliation entre les deux sources (issue #15) : trou temporel accepté, recouvrement refusé à l'ingestion et dédupliqué en défense côté ML, voir [40-data.md](40-data.md). |
|
||||
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | Un orchestrateur `ci.yml` qui n'appelle que les composants modifiés ([ADR 0014](../adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md)) : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow, Terraform, Compose et supervision, parcours Playwright et tirs k6 contre la stack de prod ([ADR 0015](../adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md)). Déploiement vers la VM ENI par `deploy.yml`, appelé une fois « CI ok » vert, `dev` en recette et `main` en production après approbation ([ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)), mais jamais exécuté : le runner n'est pas enregistré sur la machine. Détail dans [50-cicd.md](50-cicd.md) |
|
||||
@@ -140,6 +141,10 @@ consolidée.
|
||||
Argon2id, RBAC à trois rôles. Détail dans [20-backend.md](20-backend.md), décisions dans les
|
||||
[ADR 0002](../adr/0002-authentification-jwt-et-refresh-opaque.md) et
|
||||
[0003](../adr/0003-autorisation-rbac-a-trois-roles.md).
|
||||
- **Chiffrement au repos.** Sur la VM, tous les volumes Docker (base, Garage, Airflow,
|
||||
supervision) vivent dans un coffre LUKS2 dont Docker exige le montage pour démarrer ; les
|
||||
archives de mesures déposées sur Garage sont en plus chiffrées par clé client (SSE-C). Ce que
|
||||
cela protège et ne protège pas : [ADR 0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md).
|
||||
- **Interdire par défaut.** Toute route exige un jeton, sauf quatre exceptions listées dans un
|
||||
fichier de test qui interroge réellement chaque route sans identifiant.
|
||||
- **Révocation immédiate.** Le compte est relu en base à chaque requête : une désactivation ou un
|
||||
@@ -202,3 +207,5 @@ Elles vivent dans `../adr/`, pas ici.
|
||||
| [0008](../adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement |
|
||||
| [0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md) | Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé |
|
||||
| [0010](../adr/0010-terraform-provisionne-github-actions-deploie.md) | Terraform provisionne la machine, GitHub Actions déploie l'application |
|
||||
| [0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md) | Stockage objet Garage par environnement ; les chunks anciens de `reading` sont exportés en CSV gzip puis supprimés |
|
||||
| [0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md) | Chiffrement au repos : coffre LUKS des volumes Docker de la VM, SSE-C des archives |
|
||||
|
||||
@@ -21,6 +21,8 @@ flowchart LR
|
||||
db[("db<br/>TimescaleDB")]
|
||||
mail["mailpit"]
|
||||
|
||||
garage["garage<br/>:3903/metrics"]
|
||||
|
||||
subgraph sup["Profil monitoring"]
|
||||
prom["prometheus<br/>15 s, 15 jours"]
|
||||
am["alertmanager"]
|
||||
@@ -35,6 +37,7 @@ flowchart LR
|
||||
|
||||
prom -->|"Bearer APP_METRICS_TOKEN"| api
|
||||
prom --> pge & node & cad
|
||||
prom -->|"Bearer GARAGE_METRICS_TOKEN"| garage
|
||||
pge -->|"rôle supervision"| db
|
||||
node -.->|"/proc, /sys"| hote
|
||||
cad -.->|"cgroups"| hote
|
||||
@@ -56,6 +59,7 @@ conteneur couvre donc aussi la recette, qu'on distingue au préfixe `enervision-
|
||||
| node-exporter | Processeur, mémoire disponible, espace disque de `/` | Tableau « Infrastructure » |
|
||||
| cAdvisor | Mémoire (`working_set`) et processeur par conteneur | Tableau « Infrastructure » |
|
||||
| TimescaleDB, en SQL | Fraîcheur des relevés par site, relevés ingérés par heure, alertes par sévérité, `drift_report` | Tableau « Données et modèle » |
|
||||
| Garage (`/metrics` du port admin, jeton `GARAGE_METRICS_TOKEN`) | `api_s3_request_counter`, `block_bytes_written`, `garage_local_disk_avail`, `cluster_healthy` | Prometheus seulement, aucun tableau dédié ; `CibleInjoignable` couvre son indisponibilité ([ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)) |
|
||||
|
||||
Deux choix de l'instrumentation se lisent dans ces courbes :
|
||||
|
||||
|
||||
Reference in New Issue
Block a user