Files
tools/minio/README.md
T
arcodangeandClaude Opus 5 287e3dcf1e
Helm Charts / Detect changed charts (push) Successful in 14s
Helm Charts / Detect changed charts (pull_request) Successful in 14s
Helm Charts / Application charts pgcat (push) Has been cancelled
Helm Charts / Library charts tool (push) Has been cancelled
MinIO / Tofu - minio IAC (push) Has been cancelled
MinIO / Auth with gitea for vault (push) Has been cancelled
MinIO / Auth with gitea for vault (pull_request) Failing after 8m31s
MinIO / Tofu - minio IAC (pull_request) Has been skipped
Hashicorp Vault / Auth with gitea for vault (pull_request) Failing after 8m32s
Hashicorp Vault / Tofu - Vault IAC (pull_request) Has been skipped
Helm Charts / Library charts tool (pull_request) Has been skipped
Helm Charts / Application charts pgcat (pull_request) Has been skipped
refactor(minio) — le BUCKET est la seule déclaration : plus de liste à tenir
« Je ne vois pas le mal à donner la permission de lire sur un chemin qui n'existe
pas. Je préfère ne pas m'embêter avec consumers ou autre. » (fondateur, 26/07)

`var.consumers` disparaît. Le plan LIT `values.yaml` du chart — le même fichier
qu'Helm consomme — et provisionne un compte de service par bucket. Créer un
bucket EST la déclaration : il devient impossible d'avoir un bucket sans son
compte, ou un compte sans son bucket. Une liste de plus aurait été une liste à
tenir synchronisée, donc une liste à oublier.

La convention qui rend ça possible : UN BUCKET PAR APP, NOMMÉ COMME ELLE. Le
bucket passe donc de `kadans-videos` à `kadans`. Il est VIDE aujourd'hui — le
renommer maintenant ne coûte rien ; dans un mois ce serait une migration.

Tout en découle sans être écrit ailleurs : le compte `<app>-app` borné à ce seul
bucket, le secret `kvv2/minio/<app>`, et la lecture que `app_policy` accorde
déjà à toute app sur `kvv2/data/minio/<son nom>`.

Vérifié plutôt que supposé : `yamldecode` lit bien ce values.yaml, ancres YAML
comprises (testé en isolation avant d'écrire le plan). tofu fmt propre, tofu
validate réussi.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01CoafGWmRVESaWX819USUUA
2026-07-26 09:38:24 +02:00

138 lines
7.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MinIO — stockage objet S3 du homelab
Brique **partagée** du namespace `tools`, au même titre que pgbouncer ou
clickhouse. Le serveur vit ici ; les buckets, quotas et identifiants d'une
application vivent avec cette application.
## Premier consommateur : Kadans
- [ADR-012](https://gitea.arcodange.lab/arcodange/kadans/src/branch/main/docs/adr/012-video-storage-minio-first.md)
« MinIO local d'abord » — bascule vers Cloudflare R2 prévue aux seuils :
100+ utilisateurs actifs, > 10 To/mois, ou dispersion géographique.
- [ADR-013](https://gitea.arcodange.lab/arcodange/kadans/src/branch/main/docs/adr/013-video-storage-opfs-local-first.md)
le gratuit est **local-first** (la vidéo ne quitte pas l'appareil) ; MinIO sert
les **paliers payants**.
- [ADR-018](https://gitea.arcodange.lab/arcodange/kadans/src/branch/main/docs/adr/018-qualite-video-au-transfert.md)
ce qui transite est **dérivé** (aperçu 240p ~50 Ko, travail 360p ~3,4 Mo/min) —
le master reste chez l'utilisateur. D'où le dimensionnement ci-dessous.
## Ce que ce chart pose
| | |
|---|---|
| Mode | **standalone** (1 réplique) — la donnée est dérivée et Longhorn réplique déjà le volume ; l'erasure coding distribué coûterait de la RAM que des Pi 5 n'ont pas à dépenser pour ça |
| Volume | **50 Gi** sur `longhorn`**250 h de cours** au palier « travail ». ⚠ Longhorn réplique : compter **×3** sur la capacité du cluster avant d'augmenter |
| 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 | **un par app, nommé comme elle** (`kadans`…), tous **privés** — 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 |
| 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
chart amont) parce que le module Vault `app_roles` borne l'authentification au
SA portant le nom de l'app : un seul SA, rien à réconcilier.
## Première mise en service
L'ordre compte, et il compte **deux fois** :
1. **Workflow `Hashicorp Vault`** — MinIO doit d'abord figurer dans
`hashicorp-vault/iac/terraform.tfvars` (c'est fait) : c'est **là** que naît
le rôle CI `gitea_cicd_minio`, et non dans `minio/iac`. Sans cette étape,
le workflow MinIO échoue sur
`role "gitea_cicd_minio" could not be found` — il essaie de s'authentifier
avec un rôle que personne n'a encore créé.
2. **Workflow `MinIO`** — applique `minio/iac` : rôle Kubernetes pour le Vault
Secrets Operator, et **génération** du mot de passe root dans
`kvv2/minio/config`.
3. **ArgoCD** synchronise l'application (déclarée dans `chart/values.yaml`).
4. Vérifier : `kubectl -n tools get vaultstaticsecret minio` (secret
matérialisé) puis `kubectl -n tools get pods -l app=minio`.
> [!NOTE]
> Sans le secret `minio-config`, le pod ne démarre pas. C'est voulu — mieux
> vaut un pod en attente qu'un MinIO ouvert avec des identifiants par défaut.
## Pourquoi une exposition publique (`s3.arcodange.fr`)
La PWA Kadans est servie en `https://kadans.arcodange.fr` et téléverse ses vidéos
**directement** vers MinIO, avec des URL présignées émises par kadans-api
(kadans-api#23) : les octets ne passent jamais par l'API.
Deux raisons rendent le `.lab` inutilisable pour ça, et ce sont des faits du
navigateur, pas des préférences :
1. **Contenu mixte** — une page servie en `https` ne peut pas émettre une requête
vers `http://`. L'ingress `.lab` est en entrypoint `web` sans TLS.
2. **`.lab` n'est pas résolvable hors du LAN** — la synchronisation ne marcherait
qu'à la maison, ce qui vide de son sens « retrouver mes vidéos sur mon autre
appareil ».
**Pas de basic-auth** sur cet ingress, contrairement à `kadans-public` : une
requête S3 porte sa propre signature (SigV4). Un défi HTTP Basic casserait le PUT
présigné, auquel le navigateur ne peut pas répondre. L'autorisation vient de
l'URL signée et de sa durée de vie courte (15 min pour déposer, 1 h pour lire).
**CORS** (`MINIO_API_CORS_ALLOW_ORIGIN`) liste les origines EXACTES de la PWA —
jamais `*` : une URL présignée qui fuiterait serait sinon rejouable depuis
n'importe quel site.
### ⚠ À vérifier avant de s'y fier : la taille maximale d'une requête
Le trafic public passe par un **tunnel Cloudflare**. Les offres gratuites de
Cloudflare plafonnent la taille du corps d'une requête proxifiée (de l'ordre de
**100 Mo**) — ce plafond n'a **pas** été mesuré ici, il doit l'être avec un vrai
téléversement avant d'annoncer une limite aux utilisateurs.
Ce qu'on sait, en revanche, et qui rend le sujet peu urgent : sur le corpus réel
du fondateur (707 vidéos, ~2 ans), **la durée moyenne est de 53 secondes** et
**deux vidéos seulement dépassent 5 minutes**. Au palier « travail » de l'ADR-018
(360p ≈ 3,4 Mo/min), 100 Mo représentent ~29 minutes de cours : le corpus entier
passe très largement. Si la limite se confirme, le plafond de 200 Mio annoncé
côté API mérite d'être ramené sous celle du tunnel — mieux vaut refuser tôt, avec
une phrase claire, qu'échouer au milieu d'un téléversement.
## Donner à une app l'accès au stockage
**Une seule chose à faire** : ajouter son bucket dans `values.yaml`, **nommé
comme l'app**.
```yaml
buckets:
- name: mon-app
policy: none # privé : l'accès passe par des URL signées
```
Tout le reste en découle, sans autre déclaration nulle part :
- `iac/consumers.tf` **lit ce même fichier** et crée un compte de service
`mon-app-app`, borné à ce seul bucket, dont les 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/<son nom>` — 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.
Côté app, il reste à écrire une `VaultStaticSecret` sur `kvv2/minio/<app>` et à
injecter les variables dans son Deployment (voir `kadans-api` pour l'exemple).
> **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à.
### Rotation
Détruire `random_password.app["<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.