`ListObjectParts` et `AbortMultipartUpload` rendaient 403 AccessDenied avec les identifiants de kadans-api — mesuré le 2026-09-07 sur le homelab, en rejouant les appels de contrôle exacts de l'API depuis un poste. Ouvrir et déposer les parts sont des PutObject et passaient ; l'assemblage n'a jamais pu avoir lieu, et toute vidéo de plus d'une part échouait à la confirmation derrière « stockage injoignable ». Co-Authored-By: Claude Fable 5.1 <[email protected]>
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 « MinIO local d'abord » — bascule vers Cloudflare R2 prévue aux seuils : 100+ utilisateurs actifs, > 10 To/mois, ou dispersion géographique.
- ADR-013 le gratuit est local-first (la vidéo ne quitte pas l'appareil) ; MinIO sert les paliers payants.
- ADR-018 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 | 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
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 :
- Workflow
Hashicorp Vault— MinIO doit d'abord figurer danshashicorp-vault/iac/terraform.tfvars(c'est fait) : c'est là que naît le rôle CIgitea_cicd_minio, et non dansminio/iac. Sans cette étape, le workflow MinIO échoue surrole "gitea_cicd_minio" could not be found— il essaie de s'authentifier avec un rôle que personne n'a encore créé. - Workflow
MinIO— appliqueminio/iac: rôle Kubernetes pour le Vault Secrets Operator, et génération du mot de passe root danskvv2/minio/config. - ArgoCD synchronise l'application (déclarée dans
chart/values.yaml). - Vérifier :
kubectl -n tools get vaultstaticsecret minio(secret matérialisé) puiskubectl -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 :
- Contenu mixte — une page servie en
httpsne peut pas émettre une requête vershttp://. L'ingress.labest en entrypointwebsans TLS. .labn'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.
La taille maximale d'une requête — mesurée le 2026-08-20
Le trafic public passe par un tunnel Cloudflare, qui plafonne la taille du corps d'une requête proxifiée. Ce plafond était marqué ici « à vérifier avant de s'y fier ». Il l'est.
Sonde : un PUT non signé vers le bucket. Rien ne s'écrit, et les deux réponses se distinguent proprement — un 403 vient de MinIO (donc le corps a traversé le tunnel), un 413 vient du tunnel lui-même.
| Corps | Réponse | Lecture |
|---|---|---|
| 50 Mio | 403 |
corps passé, refus de signature |
| 100 Mio | 403 |
corps passé |
| 101 Mio | 413 |
refusé par le tunnel |
| 200 Mio | 413 |
refusé par le tunnel |
⚠ Le plafond est exactement 100 Mio. Or kadans-api annonçait un gabarit de
200 Mio — le double. Une vidéo entre les deux était acceptée, signée,
téléversée pendant ~100 Mo… puis coupée.
⚠ La parade retenue n'est PAS celle que cette section recommandait. Ramener le gabarit sous le plafond du tunnel aurait aussi fermé les cours longs (au palier « travail » de l'ADR-018, 360p ≈ 3,4 Mo/min, 100 Mio ≈ 29 min). C'est le téléversement en plusieurs parts qui a été livré (kadans-api PR #188) : des parts de 8 Mio passent chacune très en dessous du plafond, sans rétrécir la promesse.
Le corpus, lui, reste largement sous la limite : sur les 707 vidéos du fondateur (~2 ans), la durée moyenne est de 53 secondes et deux seulement dépassent 5 minutes. C'est ce qui explique que le défaut n'ait jamais été rencontré — et pourquoi il attendait le premier cours entier.
⚠ Conséquence pour le bucket, et elle est ici : un téléversement en parts
jamais refermé laisse des parts que rien ne montre. Le module minio_app pose
donc sur chaque bucket une règle de cycle de vie
AbortIncompleteMultipartUpload à 1 jour — voir son main.tf.
Donner à une app l'accès au stockage
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 :
# 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 }
}
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.
Ce que tools fournit, et pourquoi
| 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 |
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
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.