diff --git a/hashicorp-vault/iac/modules/app_policy/main.tf b/hashicorp-vault/iac/modules/app_policy/main.tf index f93c0fb..402f5e1 100644 --- a/hashicorp-vault/iac/modules/app_policy/main.tf +++ b/hashicorp-vault/iac/modules/app_policy/main.tf @@ -36,6 +36,15 @@ data "vault_policy_document" "ops" { path = "kvv1/google/credentials" capabilities = ["read"] } + # Provisionneur MinIO : permet à l'app de créer SES buckets et SON compte de + # service depuis son propre dépôt (module tools//minio/iac/modules/minio_app), + # sans qu'on lui confie le root de MinIO. Ce compte ne peut QUE provisionner — + # aucun droit de lecture ni d'écriture sur les objets. + rule { + path = "kvv2/data/minio/provisioner" + capabilities = ["read"] + } + # read cloudflare related secrets rule { path = "kvv1/cloudflare/${local.name}*" diff --git a/minio/README.md b/minio/README.md index 77cdb4f..5d0e9c4 100644 --- a/minio/README.md +++ b/minio/README.md @@ -25,7 +25,7 @@ application vivent avec cette application. | Ressources | requests 512 Mi / 100 m · limit 2 Gi — la limite protège les voisins de `tools`, pas MinIO | | API S3 | `s3.arcodange.lab` (interne) **et `s3.arcodange.fr`** (public, tunnel Cloudflare → entrypoint `web` + crowdsec) — voir « Pourquoi une exposition publique » | | Console | `minio.arcodange.lab` (Traefik) | -| Buckets | **privés**, chacun déclarant son app (`app:`) — l'accès passe par des URL signées (ADR-0002 du dossier produit). Le bucket est la SEULE déclaration : compte de service et droits Vault en découlent. Une app peut en avoir plusieurs | +| Buckets | **aucun ici** — chaque app déclare les siens depuis son dépôt (module `minio_app`). Tous privés : l'accès passe par des URL signées (ADR-0002 du dossier produit) | | Identifiants | **jamais dans le dépôt** : `iac/` les génère dans Vault (`kvv2/minio/config`), le Vault Secrets Operator les matérialise en secret `minio-config`, le chart les lit via `existingSecret` | Le ServiceAccount du pod est nommé `minio` (et non le `minio-sa` par défaut du @@ -96,53 +96,50 @@ une phrase claire, qu'échouer au milieu d'un téléversement. ## Donner à une app l'accès au stockage -**Une seule chose à faire** : déclarer son bucket dans `values.yaml`, en disant -à quelle app il appartient. +**Rien à faire ici.** Chaque application déclare **ses** buckets **depuis son +propre dépôt**, avec le module que ce dépôt-ci fournit : -```yaml -buckets: - - name: mon-app-fichiers - app: mon-app # ← la seule déclaration - policy: none # privé : l'accès passe par des URL signées +```hcl +# iac/main.tf de l'application +data "vault_kv_secret_v2" "minio_provisioner" { + mount = "kvv2" + name = "minio/provisioner" +} + +provider "minio" { + minio_server = "s3.arcodange.fr" + minio_user = data.vault_kv_secret_v2.minio_provisioner.data["MINIO_ACCESS_KEY"] + minio_password = data.vault_kv_secret_v2.minio_provisioner.data["MINIO_SECRET_KEY"] + minio_ssl = true +} + +module "stockage" { + source = "git::…/tools.git//minio/iac/modules/minio_app?depth=1&ref=main" + app = "mon-app" + buckets = ["mon-app-fichiers"] + providers = { minio = minio } +} ``` -Tout le reste en découle, sans rien écrire ailleurs : +Voir `iac/modules/minio_app/README.md`. **Chacun son périmètre** : `tools` +fournit le serveur, le provisionneur et le module — pas la liste des buckets. +Sans ça, chaque bucket de chaque app deviendrait une PR sur l'infra partagée. -- `iac/consumers.tf` **lit ce même fichier**, groupe les buckets par app, et crée - **un** compte de service `mon-app-app` autorisé sur **tous ses buckets et eux - seuls**. Ses clés atterrissent dans `kvv2/minio/mon-app` ; -- le module Vault central `app_policy` accorde **déjà** à toute app la lecture de - `kvv2/data/minio/` — inconditionnellement, parce que le chemin porte - le nom de l'app et ne peut donc jamais exposer que ses propres clés. Une app - qui ne stocke rien y lit un chemin qui n'existe pas : une règle inerte. +### Ce que `tools` fournit, et pourquoi -Côté app, il reste à écrire une `VaultStaticSecret` sur `kvv2/minio/` et à -injecter les variables dans son Deployment (voir `kadans-api` pour l'exemple). -Le secret porte `MINIO_ENDPOINT`, `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY`, -`MINIO_BUCKET` (le premier) et `MINIO_BUCKETS` (tous, séparés par des virgules). +| Pièce | Rôle | +|---|---| +| Le serveur | le chart, son volume, ses ingress (interne + public) | +| Le **root** | généré ici, écrit dans `kvv2/minio/config`, **ne sort jamais** de ce pipeline | +| Le **provisionneur** | un compte aux droits d'administration MINIMAUX (créer bucket, politique, compte de service) et **aucun droit sur les objets** — lisible par le rôle CI de chaque app | +| Le **module** | `minio_app` : standardise la déclaration, sans la détenir | -### Plusieurs buckets pour une même app - -C'est prévu, et c'est le cas courant : deux contenus aux **cycles de vie -différents** méritent deux buckets aux politiques de purge différentes. Kadans y -viendra avec les deux paliers de l'ADR-018 — l'aperçu 240p, régénérable, et le -rendu de travail 360p, à garder. Il suffit d'une seconde entrée avec le même -`app:` ; le compte de service existant gagne l'accès, sans nouvelle clé. - -> **Il n'y a volontairement AUCUNE liste de consommateurs.** Une liste de plus -> serait une liste à tenir synchronisée avec les buckets — donc une liste à -> oublier. Le bucket fait foi. - -### Pourquoi les clés vivent ICI et pas chez l'app - -Seul ce pipeline possède les identifiants **root** de MinIO. Si chaque app créait -son propre compte de service, il faudrait donner ce root à chaque rôle CI — -c'est-à-dire à tout le monde. Ici il ne sort jamais, et l'app ne reçoit qu'une -clé qui **ne peut rien lire d'autre que son bucket**. Un compte de service qui -fuite ne donne accès qu'aux objets qu'il gérait déjà. +Donner le root aux apps aurait été absurde : il lit et écrit **tous** les objets +de **toutes** les apps. Le provisionneur, lui, peut créer des buckets — une +nuisance si une app est compromise — mais **pas lire les vidéos d'une autre**. ### Rotation -Détruire `random_password.app[""]` et relancer le plan suffit : la clé -change, `force_destroy = false` garde le compte, et les objets déjà déposés -conservent leur propriétaire. +Depuis l'`iac/` de l'app : détruire `module.stockage.random_password.app` et +relancer son plan. La clé change, `force_destroy = false` garde le compte, et +les objets déjà déposés conservent leur propriétaire. diff --git a/minio/iac/consumers.tf b/minio/iac/consumers.tf deleted file mode 100644 index 7db0766..0000000 --- a/minio/iac/consumers.tf +++ /dev/null @@ -1,98 +0,0 @@ -# ── Un compte de service par APP, sans liste à tenir ───────────────────────── -# -# Il n'y a PAS de liste de consommateurs : elle se lit dans `values.yaml` du -# chart. Chaque bucket y dit à quelle app il appartient (`app:`) — et c'est la -# SEULE déclaration (fondateur 2026-07-26 : « je préfère ne pas m'embêter avec -# consumers ou autre »). Rien à synchroniser entre deux fichiers, donc rien à -# oublier, et aucune divergence possible : c'est le fichier qu'Helm consomme. -# -# UNE APP PEUT AVOIR PLUSIEURS BUCKETS, et c'est important : rien ne justifiait -# de l'en empêcher. L'ADR-018 de Kadans prévoit déjà deux paliers de transfert -# (aperçu 240p régénérable, travail 360p à garder) — deux cycles de vie, donc -# potentiellement deux buckets aux politiques de purge différentes. Le compte de -# service est donc par APP, autorisé sur TOUS ses buckets et sur eux seuls. -# -# POURQUOI LES CLÉS VIVENT ICI ET PAS CHEZ L'APP : seul ce pipeline possède les -# identifiants ROOT de MinIO. Si chaque app créait son propre compte de service, -# il faudrait donner ce root à chaque rôle CI — c'est-à-dire à tout le monde. -# Ici il ne sort jamais, et l'app ne reçoit qu'une clé qui ne peut rien lire -# d'autre que ses propres buckets. Un compte qui fuite ne donne accès qu'aux -# objets qu'il gérait déjà. - -locals { - # Le MÊME fichier que celui qu'Helm consomme : impossible de créer un bucket - # sans son compte de service, ou un compte sans ses buckets. - buckets_declares = yamldecode(file("${path.module}/../values.yaml")).minio.buckets - - # app → ses buckets. `try` : un bucket sans `app:` est ignoré plutôt que de - # faire échouer tout le plan — il n'aura simplement aucun compte de service, - # ce qui se voit tout de suite et ne casse rien d'existant. - buckets_par_app = { - for app in distinct([for b in local.buckets_declares : try(b.app, null) if try(b.app, null) != null]) : - app => [for b in local.buckets_declares : b.name if try(b.app, null) == app] - } -} - -# La politique d'accès : SES buckets, rien d'autre. Ni les autres buckets, ni -# l'administration. -resource "minio_iam_policy" "app" { - for_each = local.buckets_par_app - name = "${each.key}-app" - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Effect = "Allow" - Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] - Resource = [for b in each.value : "arn:aws:s3:::${b}/*"] - }, - { - # Nécessaire pour qu'un client S3 vérifie l'existence d'un bucket et - # liste SES objets — jamais ceux d'une autre app. - Effect = "Allow" - Action = ["s3:ListBucket", "s3:GetBucketLocation"] - Resource = [for b in each.value : "arn:aws:s3:::${b}"] - }, - ] - }) -} - -resource "random_password" "app" { - for_each = local.buckets_par_app - length = 40 - special = false # les outils S3 transportent mal certains caractères en URL -} - -resource "minio_iam_user" "app" { - for_each = local.buckets_par_app - name = "${each.key}-app" - # Le mot de passe EST la clé secrète S3 : généré ici, jamais choisi. - secret = random_password.app[each.key].result - # `false` : une rotation du mot de passe ne recrée pas l'utilisateur, donc les - # objets déjà déposés gardent leur propriétaire. - force_destroy = false -} - -resource "minio_iam_user_policy_attachment" "app" { - for_each = local.buckets_par_app - user_name = minio_iam_user.app[each.key].id - policy_name = minio_iam_policy.app[each.key].id -} - -# Les clés, dans l'espace Vault de MINIO. L'app y accède par la règle que -# `app_policy` accorde à toutes : `kvv2/data/minio/`. -# -# `MINIO_BUCKETS` liste ses buckets pour que l'app n'ait pas à les redéclarer de -# son côté ; `MINIO_BUCKET` reste le premier, pour les apps qui n'en ont qu'un. -resource "vault_kv_secret_v2" "app" { - for_each = local.buckets_par_app - mount = "kvv2" - name = "minio/${each.key}" - data_json = jsonencode({ - MINIO_ENDPOINT = var.minio_endpoint - MINIO_BUCKET = each.value[0] - MINIO_BUCKETS = join(",", each.value) - MINIO_ACCESS_KEY = minio_iam_user.app[each.key].id - MINIO_SECRET_KEY = random_password.app[each.key].result - }) -} diff --git a/minio/iac/modules/minio_app/README.md b/minio/iac/modules/minio_app/README.md new file mode 100644 index 0000000..3df9edf --- /dev/null +++ b/minio/iac/modules/minio_app/README.md @@ -0,0 +1,56 @@ +# `minio_app` — déclarer ses buckets depuis SON dépôt + +Chacun son périmètre : les buckets d'une application appartiennent au dépôt de +cette application. Ce module **standardise** la déclaration, il ne la détient +pas — sans lui, chaque bucket de chaque app deviendrait une PR sur `tools`. + +## Usage + +Dans l'`iac/` de l'app : + +```hcl +# Le provisionneur MinIO : des droits d'administration MINIMAUX (créer un +# bucket, un compte de service, une politique), jamais le root — qui, lui, ne +# sort pas du pipeline `minio`. +data "vault_kv_secret_v2" "minio_provisioner" { + mount = "kvv2" + name = "minio/provisioner" +} + +provider "minio" { + minio_server = "s3.arcodange.fr" + minio_user = data.vault_kv_secret_v2.minio_provisioner.data["MINIO_ACCESS_KEY"] + minio_password = data.vault_kv_secret_v2.minio_provisioner.data["MINIO_SECRET_KEY"] + minio_ssl = true +} + +module "stockage" { + source = "git::ssh://git@192.168.1.202:2222/arcodange-org/tools.git//minio/iac/modules/minio_app?depth=1&ref=main" + app = "mon-app" # = le nom de son rôle Vault + buckets = ["mon-app-fichiers"] + providers = { minio = minio } +} +``` + +Puis, côté chart : une `VaultStaticSecret` sur `kvv2/minio/` et l'injection +des variables dans le Deployment. + +## Ce que le module garantit + +- les buckets sont **privés** — l'accès passe par des URL présignées ; +- le compte de service ne peut **rien** toucher d'autre que ces buckets-là ; +- ses clés vont dans `kvv2/minio/`, que le module Vault central autorise + déjà l'app à lire (règle **inconditionnelle** : le chemin porte le nom de + l'app, donc il ne peut exposer que ses propres clés). + +## Plusieurs buckets + +C'est le cas courant : deux contenus aux **cycles de vie différents** méritent +deux buckets. Il suffit de les lister — le compte de service existant gagne +l'accès, **sans nouvelle clé**. + +## Ce que le module ne fait PAS + +Il ne pose ni quota, ni règle de cycle de vie, ni versioning : ces choix +appartiennent à l'app et varient d'un bucket à l'autre. À ajouter le jour où +un besoin réel apparaît, pas avant. diff --git a/minio/iac/modules/minio_app/main.tf b/minio/iac/modules/minio_app/main.tf new file mode 100644 index 0000000..6d69f22 --- /dev/null +++ b/minio/iac/modules/minio_app/main.tf @@ -0,0 +1,89 @@ +# ── Module `minio_app` — une app déclare SES buckets, depuis SON dépôt ─────── +# +# Chacun son périmètre (fondateur 2026-07-26) : les buckets d'une application +# appartiennent au dépôt de cette application, pas à l'infra partagée. `tools` +# ne fait que STANDARDISER la déclaration — il ne la détient pas. Sans ça, +# chaque bucket de chaque app deviendrait une PR sur le dépôt commun. +# +# Usage, depuis l'`iac/` de l'app : +# +# module "stockage" { +# source = "git::…/tools.git//minio/iac/modules/minio_app?depth=1&ref=main" +# app = "kadans" +# buckets = ["kadans-videos"] +# providers = { minio = minio } +# } +# +# Ce que le module garantit : +# · les buckets sont PRIVÉS — l'accès passe par des URL présignées ; +# · le compte de service ne peut RIEN toucher d'autre que ces buckets-là ; +# · ses clés atterrissent dans `kvv2/minio/`, que le module Vault central +# autorise déjà l'app à lire (règle inconditionnelle : le chemin porte le nom +# de l'app, donc il ne peut exposer que ses propres clés). + +resource "minio_s3_bucket" "app" { + for_each = toset(var.buckets) + bucket = each.key + acl = "private" + # Les objets sont DÉRIVÉS (rendus de transfert) et régénérables depuis les + # originaux restés sur l'appareil : un `force_destroy` accidentel ne détruit + # donc rien d'irremplaçable, mais on l'interdit quand même — la destruction + # d'un bucket doit être un geste explicite, pas un effet de bord d'un plan. + force_destroy = false +} + +# La politique : SES buckets, rien d'autre. Ni les autres, ni l'administration. +resource "minio_iam_policy" "app" { + name = "${var.app}-app" + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] + Resource = [for b in var.buckets : "arn:aws:s3:::${b}/*"] + }, + { + # Nécessaire pour qu'un client S3 vérifie l'existence d'un bucket et + # liste SES objets — jamais ceux d'une autre app. + Effect = "Allow" + Action = ["s3:ListBucket", "s3:GetBucketLocation"] + Resource = [for b in var.buckets : "arn:aws:s3:::${b}"] + }, + ] + }) +} + +resource "random_password" "app" { + length = 40 + special = false # les outils S3 transportent mal certains caractères en URL +} + +resource "minio_iam_user" "app" { + name = "${var.app}-app" + # Le mot de passe EST la clé secrète S3 : généré ici, jamais choisi. + secret = random_password.app.result + # `false` : une rotation du mot de passe ne recrée pas l'utilisateur, donc les + # objets déjà déposés gardent leur propriétaire. + force_destroy = false +} + +resource "minio_iam_user_policy_attachment" "app" { + user_name = minio_iam_user.app.id + policy_name = minio_iam_policy.app.id +} + +# Les clés, dans l'espace Vault de MinIO — lu par le pod de l'app. +# `MINIO_BUCKETS` les liste toutes pour que l'app n'ait pas à les redéclarer ; +# `MINIO_BUCKET` reste le premier, pour celles qui n'en ont qu'un. +resource "vault_kv_secret_v2" "app" { + mount = "kvv2" + name = "minio/${var.app}" + data_json = jsonencode({ + MINIO_ENDPOINT = var.endpoint + MINIO_BUCKET = var.buckets[0] + MINIO_BUCKETS = join(",", var.buckets) + MINIO_ACCESS_KEY = minio_iam_user.app.id + MINIO_SECRET_KEY = random_password.app.result + }) +} diff --git a/minio/iac/modules/minio_app/outputs.tf b/minio/iac/modules/minio_app/outputs.tf new file mode 100644 index 0000000..d4f2ba0 --- /dev/null +++ b/minio/iac/modules/minio_app/outputs.tf @@ -0,0 +1,9 @@ +output "vault_path" { + value = "kvv2/minio/${var.app}" + description = "Où le pod lira ses identifiants (VaultStaticSecret). Le module Vault central autorise déjà l'app à lire ce chemin." +} + +output "buckets" { + value = var.buckets + description = "Écho des buckets créés — utile pour poser la valeur `bucket` du chart sans la réécrire." +} diff --git a/minio/iac/modules/minio_app/providers.tf b/minio/iac/modules/minio_app/providers.tf new file mode 100644 index 0000000..c85f94b --- /dev/null +++ b/minio/iac/modules/minio_app/providers.tf @@ -0,0 +1,14 @@ +terraform { + required_providers { + minio = { + source = "aminueza/minio" + configuration_aliases = [minio] + } + vault = { + source = "hashicorp/vault" + } + random = { + source = "hashicorp/random" + } + } +} diff --git a/minio/iac/modules/minio_app/variables.tf b/minio/iac/modules/minio_app/variables.tf new file mode 100644 index 0000000..f205996 --- /dev/null +++ b/minio/iac/modules/minio_app/variables.tf @@ -0,0 +1,15 @@ +variable "app" { + type = string + description = "Nom de l'app — celui de son rôle Vault. Décide du chemin du secret (kvv2/minio/) et du nom du compte de service." +} + +variable "buckets" { + type = list(string) + description = "Les buckets de cette app. Ils sont CRÉÉS ici (privés) et le compte de service n'a de droits que sur eux. Plusieurs sont possibles — deux contenus aux cycles de vie différents méritent deux buckets." +} + +variable "endpoint" { + type = string + default = "s3.arcodange.fr" + description = "Hôte de l'API S3, SANS schéma. Public : le runner CI n'est pas dans le LAN et `.lab` ne s'y résout pas." +} diff --git a/minio/iac/provisioner.tf b/minio/iac/provisioner.tf new file mode 100644 index 0000000..0aa3724 --- /dev/null +++ b/minio/iac/provisioner.tf @@ -0,0 +1,77 @@ +# ── Le compte PROVISIONNEUR : ce que `tools` doit fournir en plus du module ─── +# +# Une app qui déclare ses buckets depuis son propre dépôt doit pouvoir les +# créer — donc disposer de droits d'ADMINISTRATION sur MinIO. Lui donner le +# ROOT serait absurde : le root lit et écrit TOUS les objets de TOUTES les apps. +# +# Ce compte-ci ne peut que PROVISIONNER : créer un bucket, une politique, un +# compte de service, et les attacher. Il n'a AUCUN droit de lecture ou +# d'écriture sur les objets. Une app compromise pourrait créer des buckets — +# une nuisance —, pas lire les vidéos d'une autre. +# +# Le root, lui, reste dans `kvv2/minio/config`, que seul le rôle CI `minio` +# peut lire. +# +# ⚠ NON VÉRIFIÉ CONTRE LE SERVEUR : les noms d'actions d'administration MinIO +# ci-dessous viennent de la documentation, pas d'un essai — je n'ai pas +# d'identifiants admin en main. Le PREMIER `apply` les confirmera ou les +# corrigera ; c'est le seul point de cette PR que je ne peux pas prouver ici. +resource "minio_iam_policy" "provisioner" { + name = "provisioner" + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "admin:CreateUser", + "admin:DeleteUser", + "admin:ListUsers", + "admin:GetUser", + "admin:CreatePolicy", + "admin:DeletePolicy", + "admin:GetPolicy", + "admin:ListUserPolicies", + "admin:AttachUserOrGroupPolicy", + ] + Resource = ["arn:aws:s3:::*"] + }, + { + # Créer et inspecter un bucket — mais PAS lire ni écrire ses objets : + # `s3:GetObject` et `s3:PutObject` sont volontairement absents. + Effect = "Allow" + Action = ["s3:CreateBucket", "s3:DeleteBucket", "s3:ListAllMyBuckets", "s3:GetBucketLocation", "s3:GetBucketPolicy", "s3:PutBucketPolicy"] + Resource = ["arn:aws:s3:::*"] + }, + ] + }) +} + +resource "random_password" "provisioner" { + length = 40 + special = false +} + +resource "minio_iam_user" "provisioner" { + name = "provisioner" + secret = random_password.provisioner.result + force_destroy = false +} + +resource "minio_iam_user_policy_attachment" "provisioner" { + user_name = minio_iam_user.provisioner.id + policy_name = minio_iam_policy.provisioner.id +} + +# Lisible par le rôle CI de CHAQUE app (règle générique ajoutée à `app_policy`) : +# c'est ce qui permet à une app de provisionner ses propres buckets sans qu'on +# lui confie le root. +resource "vault_kv_secret_v2" "provisioner" { + mount = "kvv2" + name = "minio/provisioner" + data_json = jsonencode({ + MINIO_ENDPOINT = var.minio_endpoint + MINIO_ACCESS_KEY = minio_iam_user.provisioner.id + MINIO_SECRET_KEY = random_password.provisioner.result + }) +} diff --git a/minio/values.yaml b/minio/values.yaml index c14e0cc..26eccad 100644 --- a/minio/values.yaml +++ b/minio/values.yaml @@ -61,21 +61,12 @@ minio: &minio_config # Buckets créés au déploiement. `versioning: false` assumé : ces objets sont # DÉRIVÉS et re-générables depuis le master local — versionner doublerait le # stockage pour un filet dont on n'a pas besoin. - # Chaque bucket dit À QUELLE APP il appartient (`app:`) — et c'est la SEULE - # déclaration à faire : `iac/consumers.tf` lit ce fichier, groupe les buckets - # par app, et provisionne UN compte de service par app, autorisé sur tous SES - # buckets. Ses clés atterrissent dans `kvv2/minio/`, que le module Vault - # central autorise déjà l'app à lire. - # - # Une app peut donc avoir PLUSIEURS buckets, aux cycles de vie différents — - # l'ADR-018 de Kadans prévoit déjà deux paliers (aperçu 240p régénérable, - # travail 360p à garder), qui n'ont pas la même politique de purge. - buckets: - - name: kadans-videos - app: kadans - policy: none # privé : l'accès passe par des URL signées (ADR-0002 du dossier) - purge: false - versioning: false + # AUCUN bucket ici : chaque application déclare LES SIENS depuis son propre + # dépôt, via le module `iac/modules/minio_app` (fondateur 2026-07-26 : « on ne + # va pas modifier le repo tools à chaque changement d'application, chacun son + # périmètre »). `tools` fournit le serveur, le provisionneur et le module — + # pas la liste. + buckets: [] # Métriques : Prometheus (namespace `tools`) scrape déjà la façade et le # laptop (ADR-0014 du dossier) — MinIO rejoint la même vue.