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:
Johan LEROY
2026-09-24 10:30:04 +02:00
parent 0a7a802b38
commit e53c7e441c
19 changed files with 372 additions and 105 deletions
+25
View File
@@ -52,6 +52,31 @@ AIRFLOW_ADMIN_EMAIL=admin@enervision.fr
# python -c "import secrets; print(secrets.token_urlsafe(48))" # python -c "import secrets; print(secrets.token_urlsafe(48))"
AIRFLOW_APP_SECRET_KEY=change_me AIRFLOW_APP_SECRET_KEY=change_me
# Garage, stockage objet S3 par environnement (ADR 0019) : un conteneur par projet Compose, publié
# sur 127.0.0.1 seulement. Les six secrets ci-dessous sont exigés par `make services-up` et
# `make stack-up` ; scripts/provision-host.sh les génère sur la VM.
# 32 octets en hexadécimal, rien d'autre n'est accepté : openssl rand -hex 32
GARAGE_RPC_SECRET=change_me
# Jetons de l'API d'administration et de /metrics (port 3903). Même générateur qu'APP_SECRET_KEY.
GARAGE_ADMIN_TOKEN=change_me
GARAGE_METRICS_TOKEN=change_me
# Clé S3 créée au premier démarrage (`--default-bucket`). Identifiant : echo "GK$(openssl rand -hex 12)"
# Secret : openssl rand -hex 32. Ne plus le changer ensuite, Garage refuserait de démarrer.
GARAGE_ACCESS_KEY=change_me
GARAGE_SECRET_KEY=change_me
GARAGE_BUCKET=enervision-archives
# Ports S3 et admin sur 127.0.0.1. Recette : 3910 et 3913, dev : 3920 et 3923.
GARAGE_S3_PORT=3900
GARAGE_ADMIN_PORT=3903
# Rétention des mesures (ADR 0019, 0020) : le DAG `retention` exporte chaque nuit vers Garage les
# chunks de `reading` plus vieux que cette borne, puis les supprime. L'historique de démonstration
# s'arrête fin 2024 : sous 21 mois, la démo disparaîtrait.
READING_RETENTION_DAYS=1095
# Clé SSE-C des archives, 32 octets en base64 : openssl rand -base64 32. La perdre rend les
# archives illisibles ; la sauvegarder hors de la VM.
GARAGE_SSE_KEY=change_me
# Stack complète derrière le reverse proxy (docker-compose.prod.yml). # Stack complète derrière le reverse proxy (docker-compose.prod.yml).
# PUBLIC_HOST alimente l'origine CORS, le lien de réinitialisation et le certificat. # PUBLIC_HOST alimente l'origine CORS, le lien de réinitialisation et le certificat.
PUBLIC_HOST=enervision.local PUBLIC_HOST=enervision.local
+2
View File
@@ -82,6 +82,8 @@ jobs:
- "docker-compose*.yml" - "docker-compose*.yml"
- ".env.example" - ".env.example"
- "infra/front/**" - "infra/front/**"
- "infra/garage/**"
- "tests/garage/**"
- "monitoring/**" - "monitoring/**"
- ".github/workflows/infra.yml" - ".github/workflows/infra.yml"
workflows: workflows:
+33
View File
@@ -67,6 +67,16 @@ jobs:
- name: Prépare un .env d'exemple - name: Prépare un .env d'exemple
run: cp .env.example .env run: cp .env.example .env
# Garage refuse un rpc_secret qui n'est pas 32 octets hexadécimaux : `change_me` ne suffit pas.
- name: Génère les secrets Garage du .env
run: |
sed -i -e "s|^GARAGE_RPC_SECRET=.*|GARAGE_RPC_SECRET=$(openssl rand -hex 32)|" \
-e "s|^GARAGE_ADMIN_TOKEN=.*|GARAGE_ADMIN_TOKEN=$(openssl rand -hex 32)|" \
-e "s|^GARAGE_METRICS_TOKEN=.*|GARAGE_METRICS_TOKEN=$(openssl rand -hex 32)|" \
-e "s|^GARAGE_ACCESS_KEY=.*|GARAGE_ACCESS_KEY=GK$(openssl rand -hex 12)|" \
-e "s|^GARAGE_SECRET_KEY=.*|GARAGE_SECRET_KEY=$(openssl rand -hex 32)|" \
-e "s|^GARAGE_SSE_KEY=.*|GARAGE_SSE_KEY=$(openssl rand -base64 32)|" .env
- name: Valide la stack de développement - name: Valide la stack de développement
run: docker compose config --quiet run: docker compose config --quiet
@@ -91,6 +101,29 @@ jobs:
- name: Valide les tableaux de bord Grafana - name: Valide les tableaux de bord Grafana
run: for tableau in monitoring/grafana/dashboards/*.json; do jq empty "$tableau"; done run: for tableau in monitoring/grafana/dashboards/*.json; do jq empty "$tableau"; done
# Action tierce, épinglée sur le commit du tag (règle Sonar githubactions:S7637).
- name: Installe uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: false
# Même image et même healthcheck qu'en prod : `--wait` ne rend la main qu'une fois le S3 prêt.
- name: Démarre Garage
run: docker compose up -d --wait --wait-timeout 120 garage
- name: Fumée S3 sur Garage, SSE-C compris
run: |
set -a; . ./.env; set +a
uvx --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
-3
View File
@@ -83,6 +83,3 @@ infra/proxy/acme/
*.swp *.swp
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Docker garage
garage/garage.toml
+13 -3
View File
@@ -41,11 +41,19 @@ SUPERVISION := $(findstring monitoring,$(COMPOSE_PROFILES) $(call env-val,COMPOS
SERVICES_SUPERVISION := prometheus alertmanager grafana postgres-exporter node-exporter cadvisor SERVICES_SUPERVISION := prometheus alertmanager grafana postgres-exporter node-exporter cadvisor
GRAFANA_PORT := $(or $(strip $(call env-val,GRAFANA_PORT)),3001) GRAFANA_PORT := $(or $(strip $(call env-val,GRAFANA_PORT)),3001)
PROMETHEUS_PORT := $(or $(strip $(call env-val,PROMETHEUS_PORT)),9090) PROMETHEUS_PORT := $(or $(strip $(call env-val,PROMETHEUS_PORT)),9090)
supervision-garde = for cle in APP_METRICS_TOKEN GRAFANA_ADMIN_PASSWORD SUPERVISION_DB_PASSWORD; do \ supervision-garde = for cle in APP_METRICS_TOKEN GRAFANA_ADMIN_PASSWORD SUPERVISION_DB_PASSWORD GARAGE_METRICS_TOKEN; do \
sed -n "s/^$$cle=//p" .env 2>/dev/null | tail -1 | grep -q . \ sed -n "s/^$$cle=//p" .env 2>/dev/null | tail -1 | grep -q . \
|| { echo "$$cle manquant dans .env, requis par la supervision (cf. .env.example)"; exit 1; }; \ || { echo "$$cle manquant dans .env, requis par la supervision (cf. .env.example)"; exit 1; }; \
done done
MONITORING := docker compose --profile monitoring MONITORING := docker compose --profile monitoring
# Piege : l'image Garage n'a pas de shell, elle ne peut pas porter sa garde comme grafana ou
# airflow-init. Un secret vide ou laisse a change_me la ferait redemarrer en boucle (ADR 0019).
CLES_GARAGE := GARAGE_RPC_SECRET GARAGE_ADMIN_TOKEN GARAGE_METRICS_TOKEN GARAGE_ACCESS_KEY GARAGE_SECRET_KEY GARAGE_SSE_KEY
garage-garde = for cle in $(CLES_GARAGE); do \
sed -n "s/^$$cle=//p" .env 2>/dev/null | tail -1 | grep -qv '^change_me$$' \
|| { echo "$$cle manquant ou laisse a change_me dans .env, requis par Garage (cf. .env.example)"; exit 1; }; \
done
PROMTOOL := $(MONITORING) run --rm --no-deps --entrypoint promtool prometheus PROMTOOL := $(MONITORING) run --rm --no-deps --entrypoint promtool prometheus
# Piege : `e2e-prepare` ajoute trois sites `demo-*` et des comptes `test-*` a la base visee. Elle # Piege : `e2e-prepare` ajoute trois sites `demo-*` et des comptes `test-*` a la base visee. Elle
@@ -101,8 +109,9 @@ dev: services-up migrate demo-data ## Lance toute la stack : base, Mailpit, Airf
$(MAKE) --no-print-directory dev-frontend & \ $(MAKE) --no-print-directory dev-frontend & \
wait wait
services-up: ## Démarre les services conteneurisés dont `make dev` dépend (base, Mailpit, Airflow) services-up: ## Démarre les services conteneurisés dont `make dev` dépend (base, Mailpit, Garage, Airflow)
docker compose up -d db mailpit @$(garage-garde)
docker compose up -d db mailpit garage
@$(MAKE) --no-print-directory db-wait @$(MAKE) --no-print-directory db-wait
@$(MAKE) --no-print-directory db-ensure-airflow @$(MAKE) --no-print-directory db-ensure-airflow
docker compose up -d airflow-init airflow-apiserver airflow-scheduler airflow-dag-processor docker compose up -d airflow-init airflow-apiserver airflow-scheduler airflow-dag-processor
@@ -212,6 +221,7 @@ stack-up: ## Démarre la stack derrière le reverse proxy, puis migre la base. P
@openssl x509 -in infra/proxy/tls/fullchain.pem -noout -checkhost "$(PUBLIC_HOST)" >/dev/null \ @openssl x509 -in infra/proxy/tls/fullchain.pem -noout -checkhost "$(PUBLIC_HOST)" >/dev/null \
|| { echo "Le certificat ne couvre pas $(PUBLIC_HOST). Relancer make tls-selfsigned PUBLIC_HOST=$(PUBLIC_HOST) FORCE=1"; exit 1; } || { echo "Le certificat ne couvre pas $(PUBLIC_HOST). Relancer make tls-selfsigned PUBLIC_HOST=$(PUBLIC_HOST) FORCE=1"; exit 1; }
@$(if $(SUPERVISION),$(supervision-garde),true) @$(if $(SUPERVISION),$(supervision-garde),true)
@$(garage-garde)
$(COMPOSE_PROD) up -d --build $(COMPOSE_PROD) up -d --build
$(COMPOSE_PROD) exec -T backend alembic upgrade head $(COMPOSE_PROD) exec -T backend alembic upgrade head
@$(if $(SUPERVISION),$(MAKE) --no-print-directory db-ensure-supervision,true) @$(if $(SUPERVISION),$(MAKE) --no-print-directory db-ensure-supervision,true)
+6 -1
View File
@@ -28,6 +28,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place | | Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
| CI/CD | GitHub Actions | `.github/workflows` | En place | | CI/CD | GitHub Actions | `.github/workflows` | En place |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | En place, profil Compose | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | En place, profil Compose |
| Stockage objet | Garage (S3), un par environnement | `infra/garage` | En place, archives de `reading` |
| Tests e2e et de charge | Playwright, k6 | `tests` | En place | | Tests e2e et de charge | Playwright, k6 | `tests` | En place |
| ML | LightGBM, MLflow | `ml` | En place | | ML | LightGBM, MLflow | `ml` | En place |
@@ -57,6 +58,7 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
│ ├── include/ Requetes SQL et ressources des DAGs │ ├── include/ Requetes SQL et ressources des DAGs
│ └── tests/ Tests d'integrite des DAGs │ └── tests/ Tests d'integrite des DAGs
├── infra/ ├── infra/
│ ├── garage/ Stockage objet S3 : configuration sans secret
│ ├── proxy/ Reverse proxy Nginx : terminaison TLS et routage │ ├── proxy/ Reverse proxy Nginx : terminaison TLS et routage
│ └── terraform/ │ └── terraform/
│ ├── modules/ Modules reutilisables │ ├── modules/ Modules reutilisables
@@ -68,6 +70,7 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
│ └── alertmanager/ Routage des alertes │ └── alertmanager/ Routage des alertes
├── tests/ ├── tests/
│ ├── e2e/ Parcours Playwright contre la stack │ ├── e2e/ Parcours Playwright contre la stack
│ ├── garage/ Tests de fumée S3 joués par la CI contre Garage
│ └── load/ Scenarios de charge k6 │ └── load/ Scenarios de charge k6
├── docs/ ADR et vues d'architecture ├── docs/ ADR et vues d'architecture
└── scripts/ Outillage local └── scripts/ Outillage local
@@ -101,7 +104,9 @@ et frontend en rechargement a chaud sur le poste.
Le `.env` doit porter les cles Airflow avant le premier `make dev` : `AIRFLOW_FERNET_KEY`, Le `.env` doit porter les cles Airflow avant le premier `make dev` : `AIRFLOW_FERNET_KEY`,
`AIRFLOW_API_SECRET_KEY`, `AIRFLOW_JWT_SECRET`, `AIRFLOW_APP_SECRET_KEY` et `AIRFLOW_API_SECRET_KEY`, `AIRFLOW_JWT_SECRET`, `AIRFLOW_APP_SECRET_KEY` et
`AIRFLOW_ADMIN_PASSWORD`. Sans elles `airflow-init` refuse de demarrer, et `airflow-apiserver`, `AIRFLOW_ADMIN_PASSWORD`. Sans elles `airflow-init` refuse de demarrer, et `airflow-apiserver`,
`airflow-scheduler` et `airflow-dag-processor` avec lui. `airflow-scheduler` et `airflow-dag-processor` avec lui. Il doit aussi porter les six clés
`GARAGE_*` (rpc, jetons, clé S3, clé SSE-C) : `make services-up` refuse sinon de démarrer Garage,
où le DAG `retention` archive les mesures anciennes ([ADR 0019](docs/adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)).
Les cibles d'origine restent disponibles pour ne demarrer qu'une partie : `make db-up`, Les cibles d'origine restent disponibles pour ne demarrer qu'une partie : `make db-up`,
`make airflow-up`, `make dev-backend`, `make dev-frontend`. `make airflow-up`, `make dev-backend`, `make dev-frontend`.
+45
View File
@@ -82,6 +82,37 @@ services:
- "${MAILPIT_UI_PORT:-8025}:8025" - "${MAILPIT_UI_PORT:-8025}:8025"
restart: unless-stopped restart: unless-stopped
# Piège : image `FROM scratch`, sans shell : healthcheck en forme exec, et aucune garde shell sur
# les secrets. Un GARAGE_RPC_SECRET vide ou non hexadécimal fait échouer Garage lui-même, message
# explicite dans ses journaux ; `make services-up` et `make stack-up` vérifient le .env avant.
# Piège : GARAGE_SECRET_KEY ne se change pas sur un volume `garage_meta` déjà peuplé, Garage
# refuse alors de démarrer. Rotation par `garage key` ou par recréation du volume (ADR 0019).
garage:
image: dxflrs/garage:v2.4.1
command: ["/garage", "server", "--single-node", "--default-bucket"]
environment:
GARAGE_RPC_SECRET: ${GARAGE_RPC_SECRET:-}
GARAGE_ADMIN_TOKEN: ${GARAGE_ADMIN_TOKEN:-}
GARAGE_METRICS_TOKEN: ${GARAGE_METRICS_TOKEN:-}
GARAGE_DEFAULT_ACCESS_KEY: ${GARAGE_ACCESS_KEY:-}
GARAGE_DEFAULT_SECRET_KEY: ${GARAGE_SECRET_KEY:-}
GARAGE_DEFAULT_BUCKET: ${GARAGE_BUCKET:-enervision-archives}
volumes:
- ./infra/garage/garage.toml:/etc/garage.toml:ro
- garage_meta:/var/lib/garage/meta
- garage_data:/var/lib/garage/data
ports:
- "127.0.0.1:${GARAGE_S3_PORT:-3900}:3900"
- "127.0.0.1:${GARAGE_ADMIN_PORT:-3903}:3903"
healthcheck:
test: ["CMD", "/garage", "health", "-q"]
interval: 15s
timeout: 5s
retries: 6
start_period: 20s
mem_limit: 256m
restart: unless-stopped
backend: backend:
build: ./apps/backend build: ./apps/backend
depends_on: depends_on:
@@ -178,6 +209,15 @@ services:
APP_MOCK_API_USERNAME: ${APP_MOCK_API_USERNAME:-} APP_MOCK_API_USERNAME: ${APP_MOCK_API_USERNAME:-}
APP_MOCK_API_PASSWORD: ${APP_MOCK_API_PASSWORD:-} APP_MOCK_API_PASSWORD: ${APP_MOCK_API_PASSWORD:-}
APP_MOCK_API_TIMEOUT_SECONDS: ${APP_MOCK_API_TIMEOUT_SECONDS:-10} APP_MOCK_API_TIMEOUT_SECONDS: ${APP_MOCK_API_TIMEOUT_SECONDS:-10}
# Le DAG `retention` archive les chunks de `reading` sur le Garage du projet (ADR 0019),
# chiffrés par la clé SSE-C du .env (ADR 0020). Vides, `app.etl.reading_retention` refuse seul.
APP_S3_ENDPOINT_URL: http://garage:3900
APP_S3_REGION: garage
APP_S3_ACCESS_KEY: ${GARAGE_ACCESS_KEY:-}
APP_S3_SECRET_KEY: ${GARAGE_SECRET_KEY:-}
APP_S3_BUCKET: ${GARAGE_BUCKET:-enervision-archives}
APP_S3_SSE_KEY: ${GARAGE_SSE_KEY:-}
APP_READING_RETENTION_DAYS: ${READING_RETENTION_DAYS:-1095}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -209,6 +249,7 @@ services:
- prometheus_data:/prometheus - prometheus_data:/prometheus
secrets: secrets:
- metrics_token - metrics_token
- garage_metrics_token
ports: ports:
- "127.0.0.1:${PROMETHEUS_PORT:-9090}:9090" - "127.0.0.1:${PROMETHEUS_PORT:-9090}:9090"
mem_limit: 512m mem_limit: 512m
@@ -318,6 +359,8 @@ services:
volumes: volumes:
pgdata: pgdata:
garage_meta:
garage_data:
airflow_logs: airflow_logs:
airflow_ml_state: airflow_ml_state:
prometheus_data: prometheus_data:
@@ -328,3 +371,5 @@ volumes:
secrets: secrets:
metrics_token: metrics_token:
environment: APP_METRICS_TOKEN environment: APP_METRICS_TOKEN
garage_metrics_token:
environment: GARAGE_METRICS_TOKEN
@@ -0,0 +1,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.
+7
View File
@@ -97,6 +97,7 @@ ailleurs ([ADR 0016](../adr/0016-supervision-en-profil-compose.md),
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) | | Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive livrée côté backend (`app.monitoring.drift`, table `drift_report`, `GET /monitoring/drift`, DAG `derive`), voir [ADR 0013](../adr/0013-surveillance-de-derive-dans-le-backend.md) | | ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive livrée côté backend (`app.monitoring.drift`, table `drift_report`, `GET /monitoring/drift`, DAG `derive`), voir [ADR 0013](../adr/0013-surveillance-de-derive-dans-le-backend.md) |
| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Provisionnement de la VM par Terraform, qui installe Docker, prépare les deux environnements et enregistre le runner, jamais appliqué ([ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée | | Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Provisionnement de la VM par Terraform, qui installe Docker, prépare les deux environnements et enregistre le runner, jamais appliqué ([ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée |
| Stockage objet | Garage, S3 | `infra/garage`, `docker-compose.yml` | `Fait` | Un Garage par environnement, `--single-node --default-bucket`, secrets par l'environnement, ports sur `127.0.0.1`, fumée S3 et SSE-C en CI. Reçoit les archives CSV gzip du DAG `retention`, chiffrées SSE-C, avant `drop_chunks` ([ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md), [ADR 0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md)) |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Fait` | Profil Compose `monitoring`, actif en prod : Prometheus et trois exporteurs (PostgreSQL, hôte, conteneurs), neuf règles d'alerte testées par `promtool`, Alertmanager vers Mailpit, trois tableaux de bord Grafana provisionnés. Voir [60-observabilite.md](60-observabilite.md) | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Fait` | Profil Compose `monitoring`, actif en prod : Prometheus et trois exporteurs (PostgreSQL, hôte, conteneurs), neuf règles d'alerte testées par `promtool`, Alertmanager vers Mailpit, trois tableaux de bord Grafana provisionnés. Voir [60-observabilite.md](60-observabilite.md) |
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver et scheduler avec LocalExecutor via Docker Compose, sur une base PostgreSQL dédiée. Six DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes`, `historical_import`, `mock_api_import` et `derive` (quotidien, surveillance de dérive). L'import historique reste manuel et l'import API Mock s'exécute chaque heure. Réconciliation entre les deux sources (issue #15) : trou temporel accepté, recouvrement refusé à l'ingestion et dédupliqué en défense côté ML, voir [40-data.md](40-data.md). | | ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver et scheduler avec LocalExecutor via Docker Compose, sur une base PostgreSQL dédiée. Six DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes`, `historical_import`, `mock_api_import` et `derive` (quotidien, surveillance de dérive). L'import historique reste manuel et l'import API Mock s'exécute chaque heure. Réconciliation entre les deux sources (issue #15) : trou temporel accepté, recouvrement refusé à l'ingestion et dédupliqué en défense côté ML, voir [40-data.md](40-data.md). |
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | Un orchestrateur `ci.yml` qui n'appelle que les composants modifiés ([ADR 0014](../adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md)) : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow, Terraform, Compose et supervision, parcours Playwright et tirs k6 contre la stack de prod ([ADR 0015](../adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md)). Déploiement vers la VM ENI par `deploy.yml`, appelé une fois « CI ok » vert, `dev` en recette et `main` en production après approbation ([ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)), mais jamais exécuté : le runner n'est pas enregistré sur la machine. Détail dans [50-cicd.md](50-cicd.md) | | CI/CD | GitHub Actions | `.github/workflows` | `En cours` | Un orchestrateur `ci.yml` qui n'appelle que les composants modifiés ([ADR 0014](../adr/0014-pipeline-ci-unique-et-deploiement-conditionne.md)) : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow, Terraform, Compose et supervision, parcours Playwright et tirs k6 contre la stack de prod ([ADR 0015](../adr/0015-tests-e2e-et-de-charge-contre-la-stack-compose.md)). Déploiement vers la VM ENI par `deploy.yml`, appelé une fois « CI ok » vert, `dev` en recette et `main` en production après approbation ([ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)), mais jamais exécuté : le runner n'est pas enregistré sur la machine. Détail dans [50-cicd.md](50-cicd.md) |
@@ -140,6 +141,10 @@ consolidée.
Argon2id, RBAC à trois rôles. Détail dans [20-backend.md](20-backend.md), décisions dans les Argon2id, RBAC à trois rôles. Détail dans [20-backend.md](20-backend.md), décisions dans les
[ADR 0002](../adr/0002-authentification-jwt-et-refresh-opaque.md) et [ADR 0002](../adr/0002-authentification-jwt-et-refresh-opaque.md) et
[0003](../adr/0003-autorisation-rbac-a-trois-roles.md). [0003](../adr/0003-autorisation-rbac-a-trois-roles.md).
- **Chiffrement au repos.** 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 - **Interdire par défaut.** Toute route exige un jeton, sauf quatre exceptions listées dans un
fichier de test qui interroge réellement chaque route sans identifiant. fichier de test qui interroge réellement chaque route sans identifiant.
- **Révocation immédiate.** Le compte est relu en base à chaque requête : une désactivation ou un - **Révocation immédiate.** Le compte est relu en base à chaque requête : une désactivation ou un
@@ -202,3 +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 | | [0008](../adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement |
| [0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md) | Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé | | [0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md) | Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé |
| [0010](../adr/0010-terraform-provisionne-github-actions-deploie.md) | Terraform provisionne la machine, GitHub Actions déploie l'application | | [0010](../adr/0010-terraform-provisionne-github-actions-deploie.md) | Terraform provisionne la machine, GitHub Actions déploie l'application |
| [0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md) | Stockage objet Garage par environnement ; les chunks anciens de `reading` sont exportés en CSV gzip puis supprimés |
| [0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md) | Chiffrement au repos : coffre LUKS des volumes Docker de la VM, SSE-C des archives |
+4
View File
@@ -21,6 +21,8 @@ flowchart LR
db[("db<br/>TimescaleDB")] db[("db<br/>TimescaleDB")]
mail["mailpit"] mail["mailpit"]
garage["garage<br/>:3903/metrics"]
subgraph sup["Profil monitoring"] subgraph sup["Profil monitoring"]
prom["prometheus<br/>15 s, 15 jours"] prom["prometheus<br/>15 s, 15 jours"]
am["alertmanager"] am["alertmanager"]
@@ -35,6 +37,7 @@ flowchart LR
prom -->|"Bearer APP_METRICS_TOKEN"| api prom -->|"Bearer APP_METRICS_TOKEN"| api
prom --> pge & node & cad prom --> pge & node & cad
prom -->|"Bearer GARAGE_METRICS_TOKEN"| garage
pge -->|"rôle supervision"| db pge -->|"rôle supervision"| db
node -.->|"/proc, /sys"| hote node -.->|"/proc, /sys"| hote
cad -.->|"cgroups"| hote cad -.->|"cgroups"| hote
@@ -56,6 +59,7 @@ conteneur couvre donc aussi la recette, qu'on distingue au préfixe `enervision-
| node-exporter | Processeur, mémoire disponible, espace disque de `/` | Tableau « Infrastructure » | | node-exporter | Processeur, mémoire disponible, espace disque de `/` | Tableau « Infrastructure » |
| cAdvisor | Mémoire (`working_set`) et processeur par conteneur | Tableau « Infrastructure » | | cAdvisor | Mémoire (`working_set`) et processeur par conteneur | Tableau « Infrastructure » |
| TimescaleDB, en SQL | Fraîcheur des relevés par site, relevés ingérés par heure, alertes par sévérité, `drift_report` | Tableau « Données et modèle » | | TimescaleDB, en SQL | Fraîcheur des relevés par site, relevés ingérés par heure, alertes par sévérité, `drift_report` | Tableau « Données et modèle » |
| Garage (`/metrics` du port admin, jeton `GARAGE_METRICS_TOKEN`) | `api_s3_request_counter`, `block_bytes_written`, `garage_local_disk_avail`, `cluster_healthy` | Prometheus seulement, aucun tableau dédié ; `CibleInjoignable` couvre son indisponibilité ([ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)) |
Deux choix de l'instrumentation se lisent dans ces courbes : Deux choix de l'instrumentation se lisent dans ces courbes :
-17
View File
@@ -1,17 +0,0 @@
services:
garage:
image: dxflrs/garage:v1.0.1
container_name: enervision-garage
ports:
- "127.0.0.1:3900:3900"
- "127.0.0.1:3901:3901"
- "127.0.0.1:3903:3903"
volumes:
- ./garage.toml:/etc/garage.toml:ro
- garage-meta:/var/lib/garage/meta
- garage-data:/var/lib/garage/data
restart: unless-stopped
volumes:
garage-meta:
garage-data:
-20
View File
@@ -1,20 +0,0 @@
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "127.0.0.1:3901"
# Générer la vôtre : python -c "import secrets; print(secrets.token_hex(32))"
rpc_secret = "change_me"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage.localhost"
[admin]
api_bind_addr = "[::]:3903"
# Générer la vôtre avec la même commande que rpc_secret
admin_token = "change_me"
-55
View File
@@ -1,55 +0,0 @@
"""
Tests de fumée pour Garage — vérifie que le cluster local accepte
vraiment des écritures et les rend intactes, pas juste qu'il démarre.
"""
import os
import uuid
import boto3
import pytest
ENDPOINT_URL = "http://127.0.0.1:3900"
ACCESS_KEY = os.environ["GARAGE_ACCESS_KEY"]
SECRET_KEY = os.environ["GARAGE_SECRET_KEY"]
BUCKET = "enervision-raw-test"
@pytest.fixture
def client():
return boto3.client(
"s3",
endpoint_url=ENDPOINT_URL,
aws_access_key_id=ACCESS_KEY,
aws_secret_access_key=SECRET_KEY,
region_name="garage",
)
def test_bucket_exists(client):
response = client.list_buckets()
noms = [b["Name"] for b in response["Buckets"]]
assert BUCKET in noms
def test_upload_and_download_roundtrip(client):
cle = f"test-{uuid.uuid4()}.txt"
contenu = b"contenu de test EnerVision"
client.put_object(Bucket=BUCKET, Key=cle, Body=contenu)
reponse = client.get_object(Bucket=BUCKET, Key=cle)
recupere = reponse["Body"].read()
assert recupere == contenu
client.delete_object(Bucket=BUCKET, Key=cle)
def test_deleted_object_is_really_gone(client):
cle = f"test-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)
+25
View File
@@ -0,0 +1,25 @@
# Garage
Stockage objet S3 de la stack, un conteneur par projet Compose (ADR 0019). Il ne sert qu'au DAG
`retention`, qui y archive les chunks de `reading` avant de les supprimer.
- `garage.toml` : configuration versionnée, sans secret, montée en lecture seule. Les secrets
arrivent par l'environnement : `GARAGE_RPC_SECRET`, `GARAGE_ADMIN_TOKEN`, `GARAGE_METRICS_TOKEN`.
- Démarrage `--single-node --default-bucket` : le premier démarrage crée le layout, la clé
`GARAGE_ACCESS_KEY` et le bucket `GARAGE_BUCKET`. Rejouable tant que les volumes `garage_meta`
et `garage_data` sont conservés. Changer `GARAGE_SECRET_KEY` ensuite fait refuser le démarrage.
- Ports, sur `127.0.0.1` seulement : `GARAGE_S3_PORT` (3900) pour l'API S3, `GARAGE_ADMIN_PORT`
(3903) pour `/health` (sans jeton) et `/metrics` (jeton `GARAGE_METRICS_TOKEN`, scruté par
Prometheus). Le RPC 3901 n'est pas publié.
- Nœud unique, `replication_factor = 1`, moteur sqlite : aucune redondance, le coffre LUKS de la
VM (ADR 0020) et l'instantané des métadonnées toutes les six heures sont les seules protections.
```bash
docker compose exec garage /garage status
docker compose exec garage /garage bucket info enervision-archives
docker compose exec garage /garage key info --show-secret "$GARAGE_ACCESS_KEY"
```
Tests de fumée : `tests/garage/test_smoke.py`, joués par la CI contre le vrai conteneur (job
« Validation des fichiers Compose et de la supervision »). En local, stack démarrée et `.env` chargé :
`uvx --with boto3 pytest tests/garage`.
+22
View File
@@ -0,0 +1,22 @@
# Contrainte : aucun secret ici, ce fichier est versionné et monté en lecture seule. Garage lit
# GARAGE_RPC_SECRET, GARAGE_ADMIN_TOKEN et GARAGE_METRICS_TOKEN dans son environnement, posés par
# docker-compose.yml depuis le .env (ADR 0019). `--single-node` exige replication_factor = 1.
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
metadata_auto_snapshot_interval = "6h"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "127.0.0.1:3901"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage.localhost"
[admin]
api_bind_addr = "[::]:3903"
metrics_require_token = true
+3 -1
View File
@@ -12,6 +12,7 @@ Issue #26, décisions dans l'ADR 0016, vue d'architecture dans
| `postgres-exporter` | `prometheuscommunity/postgres-exporter` | Connexions, transactions, taille des bases | réseau interne | | `postgres-exporter` | `prometheuscommunity/postgres-exporter` | Connexions, transactions, taille des bases | réseau interne |
| `node-exporter` | `prom/node-exporter` | Processeur, mémoire et disque de l'hôte | réseau interne | | `node-exporter` | `prom/node-exporter` | Processeur, mémoire et disque de l'hôte | réseau interne |
| `cadvisor` | `gcr.io/cadvisor/cadvisor` | Mémoire et processeur par conteneur | réseau interne | | `cadvisor` | `gcr.io/cadvisor/cadvisor` | Mémoire et processeur par conteneur | réseau interne |
| `garage` (cible) | service de la stack | Requêtes S3, octets lus et écrits, disque local, santé du nœud, sur `garage:3903/metrics` avec `GARAGE_METRICS_TOKEN` | réseau interne |
Les interfaces n'écoutent que sur `127.0.0.1`. Depuis un poste, on passe par un tunnel SSH, Les interfaces n'écoutent que sur `127.0.0.1`. Depuis un poste, on passe par un tunnel SSH,
comme pour Airflow : comme pour Airflow :
@@ -27,7 +28,7 @@ ssh -L 3001:127.0.0.1:3001 -L 9090:127.0.0.1:9090 enervision@10.101.200.37
- **Recette et poste.** À la demande, sur une stack déjà démarrée : `make monitoring-up`. Les - **Recette et poste.** À la demande, sur une stack déjà démarrée : `make monitoring-up`. Les
services partent en `--no-deps`, sans toucher aux autres. services partent en `--no-deps`, sans toucher aux autres.
Trois secrets sont requis, et `make stack-up` comme `make monitoring-up` refusent de démarrer Quatre secrets sont requis, et `make stack-up` comme `make monitoring-up` refusent de démarrer
s'il en manque un. `scripts/provision-host.sh` les génère pour un nouvel environnement. s'il en manque un. `scripts/provision-host.sh` les génère pour un nouvel environnement.
| Variable | Rôle | | Variable | Rôle |
@@ -35,6 +36,7 @@ s'il en manque un. `scripts/provision-host.sh` les génère pour un nouvel envir
| `APP_METRICS_TOKEN` | Jeton que Prometheus présente sur `/metrics`, et que l'API exige dès qu'il est posé | | `APP_METRICS_TOKEN` | Jeton que Prometheus présente sur `/metrics`, et que l'API exige dès qu'il est posé |
| `GRAFANA_ADMIN_PASSWORD` | Compte `admin` de Grafana. Sans lui, le conteneur refuse de démarrer | | `GRAFANA_ADMIN_PASSWORD` | Compte `admin` de Grafana. Sans lui, le conteneur refuse de démarrer |
| `SUPERVISION_DB_PASSWORD` | Rôle PostgreSQL `supervision`, en lecture seule (`db/roles/supervision.sql`) | | `SUPERVISION_DB_PASSWORD` | Rôle PostgreSQL `supervision`, en lecture seule (`db/roles/supervision.sql`) |
| `GARAGE_METRICS_TOKEN` | Jeton que Prometheus présente sur `/metrics` de Garage (ADR 0019), passé en secret Compose |
L'API doit tourner en conteneur (`make stack-up`, ou `docker compose up -d backend`) : L'API doit tourner en conteneur (`make stack-up`, ou `docker compose up -d backend`) :
Prometheus la joint en `backend:8000`, sur le réseau du projet. Une API lancée par `make dev` Prometheus la joint en `backend:8000`, sur le réseau du projet. Une API lancée par `make dev`
+7
View File
@@ -41,3 +41,10 @@ scrape_configs:
- job_name: cadvisor - job_name: cadvisor
static_configs: static_configs:
- targets: ["cadvisor:8080"] - targets: ["cadvisor:8080"]
- job_name: garage
authorization:
type: Bearer
credentials_file: /run/secrets/garage_metrics_token
static_configs:
- targets: ["garage:3903"]
+13 -5
View File
@@ -26,12 +26,19 @@ secret() { openssl rand -base64 48 | tr -d '/+=\n' | cut -c1-48; }
court() { secret | cut -c1-20; } court() { secret | cut -c1-20; }
# Clé Fernet : 32 octets en base64 urlsafe, padding compris. # Clé Fernet : 32 octets en base64 urlsafe, padding compris.
fernet() { openssl rand -base64 32 | tr '+/' '-_'; } fernet() { openssl rand -base64 32 | tr '+/' '-_'; }
# Garage : rpc_secret et secret de clé S3 en 32 octets hexadécimaux, identifiant de clé en GK + hex,
# clé SSE-C des archives en 32 octets base64 (ADR 0019, 0020).
hex32() { openssl rand -hex 32; }
cle_acces() { echo "GK$(openssl rand -hex 12)"; }
cle_sse() { openssl rand -base64 32; }
declare -A GENERATEURS=( declare -A GENERATEURS=(
[POSTGRES_PASSWORD]=secret [APP_SECRET_KEY]=secret [AIRFLOW_FERNET_KEY]=fernet [POSTGRES_PASSWORD]=secret [APP_SECRET_KEY]=secret [AIRFLOW_FERNET_KEY]=fernet
[AIRFLOW_API_SECRET_KEY]=secret [AIRFLOW_JWT_SECRET]=secret [AIRFLOW_ADMIN_PASSWORD]=court [AIRFLOW_API_SECRET_KEY]=secret [AIRFLOW_JWT_SECRET]=secret [AIRFLOW_ADMIN_PASSWORD]=court
[AIRFLOW_APP_SECRET_KEY]=secret [APP_METRICS_TOKEN]=secret [GRAFANA_ADMIN_PASSWORD]=court [AIRFLOW_APP_SECRET_KEY]=secret [APP_METRICS_TOKEN]=secret [GRAFANA_ADMIN_PASSWORD]=court
[SUPERVISION_DB_PASSWORD]=secret [SUPERVISION_DB_PASSWORD]=secret
[GARAGE_RPC_SECRET]=hex32 [GARAGE_ADMIN_TOKEN]=secret [GARAGE_METRICS_TOKEN]=secret
[GARAGE_ACCESS_KEY]=cle_acces [GARAGE_SECRET_KEY]=hex32 [GARAGE_SSE_KEY]=cle_sse
) )
verifier_outils() { verifier_outils() {
@@ -67,7 +74,7 @@ preparer() {
local env="$1" branche="$2" hote="$3" local env="$1" branche="$2" hote="$3"
local port_https="$4" port_http="$5" port_front="$6" port_pg="$7" port_mailpit="$8" local port_https="$4" port_http="$5" port_front="$6" port_pg="$7" port_mailpit="$8"
local port_airflow="$9" profils="${10}" port_grafana="${11}" port_prometheus="${12}" local port_airflow="$9" profils="${10}" port_grafana="${11}" port_prometheus="${12}"
local port_alertmanager="${13}" local port_alertmanager="${13}" port_garage_s3="${14}" port_garage_admin="${15}"
local dossier="$RACINE/$env" local dossier="$RACINE/$env"
local fichier="$dossier/.env" brouillon="$dossier/.env.brouillon" cle oubliees ajoutees="" local fichier="$dossier/.env" brouillon="$dossier/.env.brouillon" cle oubliees ajoutees=""
@@ -103,6 +110,7 @@ preparer() {
[POSTGRES_PORT]="$port_pg" [MAILPIT_UI_PORT]="$port_mailpit" [AIRFLOW_PORT]="$port_airflow" [POSTGRES_PORT]="$port_pg" [MAILPIT_UI_PORT]="$port_mailpit" [AIRFLOW_PORT]="$port_airflow"
[COMPOSE_PROFILES]="$profils" [GRAFANA_PORT]="$port_grafana" [COMPOSE_PROFILES]="$profils" [GRAFANA_PORT]="$port_grafana"
[PROMETHEUS_PORT]="$port_prometheus" [ALERTMANAGER_PORT]="$port_alertmanager" [PROMETHEUS_PORT]="$port_prometheus" [ALERTMANAGER_PORT]="$port_alertmanager"
[GARAGE_S3_PORT]="$port_garage_s3" [GARAGE_ADMIN_PORT]="$port_garage_admin"
) )
for cle in "${!adressage[@]}"; do for cle in "${!adressage[@]}"; do
poser "$brouillon" "$cle" "${adressage[$cle]}" poser "$brouillon" "$cle" "${adressage[$cle]}"
@@ -200,10 +208,10 @@ publier_dns
# Supervision active en prod seulement (ADR 0016). La prod vit sur `prod.` et non à la racine : # Supervision active en prod seulement (ADR 0016). La prod vit sur `prod.` et non à la racine :
# dynv6 ne sert pas de façon fiable un TXT `_acme-challenge` à la racine de la zone (ADR 0018). # dynv6 ne sert pas de façon fiable un TXT `_acme-challenge` à la racine de la zone (ADR 0018).
# env branche hôte https http front pg mailpit airflow profils grafana prometheus alertmanager # env branche hôte https http front pg mailpit airflow profils grafana prometheus alertmanager garage-s3 garage-admin
preparer prod main "prod.$DOMAINE" 127.0.0.1:10443 127.0.0.1:10080 127.0.0.1:10444 5433 8025 8080 monitoring 3001 9090 9093 preparer prod main "prod.$DOMAINE" 127.0.0.1:10443 127.0.0.1:10080 127.0.0.1:10444 5433 8025 8080 monitoring 3001 9090 9093 3900 3903
preparer rec dev "rec.$DOMAINE" 127.0.0.1:8443 127.0.0.1:8081 127.0.0.1:8444 5434 8026 8082 "" 3002 9091 9094 preparer rec dev "rec.$DOMAINE" 127.0.0.1:8443 127.0.0.1:8081 127.0.0.1:8444 5434 8026 8082 "" 3002 9091 9094 3910 3913
preparer dev dev "dev.$DOMAINE" 127.0.0.1:9443 127.0.0.1:8083 127.0.0.1:9444 5435 8027 8084 "" 3003 9092 9095 preparer dev dev "dev.$DOMAINE" 127.0.0.1:9443 127.0.0.1:8083 127.0.0.1:9444 5435 8027 8084 "" 3003 9092 9095 3920 3923
planifier_renouvellement planifier_renouvellement
if [[ -n "$PROPRIETAIRE" && "$(id -u)" -eq 0 ]]; then if [[ -n "$PROPRIETAIRE" && "$(id -u)" -eq 0 ]]; then
+70
View File
@@ -0,0 +1,70 @@
# Pourquoi : la CI (job compose d'infra.yml) démarre le vrai conteneur Garage et joue ces tests avec
# boto3, sans venv projet. Ils prouvent que le S3 accepte des écritures, les rend intactes, supprime
# vraiment, et que SSE-C refuse une lecture sans clé (ADR 0019, 0020) - test_smoke.py
import base64
import os
import uuid
import boto3
import pytest
from botocore.exceptions import ClientError
BUCKET = os.environ.get("GARAGE_BUCKET", "enervision-archives")
ENDPOINT_URL = os.environ.get(
"GARAGE_ENDPOINT_URL", f"http://127.0.0.1:{os.environ.get('GARAGE_S3_PORT', '3900')}"
)
SSE_KEY = base64.b64decode(os.environ["GARAGE_SSE_KEY"])
SSE = {"SSECustomerAlgorithm": "AES256", "SSECustomerKey": SSE_KEY}
@pytest.fixture
def client():
return boto3.client(
"s3",
endpoint_url=ENDPOINT_URL,
aws_access_key_id=os.environ["GARAGE_ACCESS_KEY"],
aws_secret_access_key=os.environ["GARAGE_SECRET_KEY"],
region_name="garage",
)
def test_default_bucket_exists(client):
noms = [bucket["Name"] for bucket in client.list_buckets()["Buckets"]]
assert BUCKET in noms
def test_upload_and_download_roundtrip(client):
cle = f"fumee/{uuid.uuid4()}.txt"
contenu = b"contenu de test EnerVision"
client.put_object(Bucket=BUCKET, Key=cle, Body=contenu)
recupere = client.get_object(Bucket=BUCKET, Key=cle)["Body"].read()
client.delete_object(Bucket=BUCKET, Key=cle)
assert recupere == contenu
def test_deleted_object_is_really_gone(client):
cle = f"fumee/suppression-{uuid.uuid4()}.txt"
client.put_object(Bucket=BUCKET, Key=cle, Body=b"a supprimer")
client.delete_object(Bucket=BUCKET, Key=cle)
with pytest.raises(client.exceptions.NoSuchKey):
client.get_object(Bucket=BUCKET, Key=cle)
def test_sse_c_object_is_unreadable_without_the_key(client):
cle = f"fumee/chiffre-{uuid.uuid4()}.txt"
contenu = b"archive chiffree"
client.put_object(Bucket=BUCKET, Key=cle, Body=contenu, **SSE)
with pytest.raises(ClientError) as erreur:
client.get_object(Bucket=BUCKET, Key=cle)
dechiffre = client.get_object(Bucket=BUCKET, Key=cle, **SSE)["Body"].read()
client.delete_object(Bucket=BUCKET, Key=cle)
assert erreur.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
assert dechiffre == contenu