Author SHA1 Message Date
arcodange 3158f2d013 Merge pull request 'feat(minio) — un téléversement abandonné laisse des parts que RIEN ne montre, et le plafond du tunnel est enfin mesuré' (#30) from arcodange/minio-multipart into main
Helm Charts / Detect changed charts (push) Successful in 11s
Helm Charts / Library charts tool (push) Skipped
Helm Charts / Application charts pgcat (push) Skipped
Reviewed-on: #30
2026-08-20 20:07:42 +02:00
arcodange edc3a73351 Merge pull request 'fix(vault) — PDB pour les évictions volontaires, OnRootMismatch pour les remounts' (#29) from arcodange/vault-pdb-remount into main
Helm Charts / Library charts tool (push) Canceled after 0s
Helm Charts / Application charts pgcat (push) Canceled after 0s
Helm Charts / Detect changed charts (push) Canceled after 1m4s
Reviewed-on: #29
2026-08-20 20:07:30 +02:00
arcodangeandClaude Opus 5 6d09adab0c feat(minio) — un téléversement abandonné laisse des parts que RIEN ne montre, et le plafond du tunnel est enfin mesuré
Helm Charts / Detect changed charts (pull_request) Successful in 26s
Helm Charts / Library charts tool (pull_request) Skipped
Helm Charts / Application charts pgcat (pull_request) Skipped
## Le plafond, mesuré — la section du README qui disait « à vérifier » ne le dit plus

`minio/README.md` portait : « 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 ».

Sonde par PUT NON SIGNÉ vers le bucket : rien ne s'écrit, et les deux réponses se
distinguent proprement — un 403 vient de MinIO (le corps a donc traversé le
tunnel), un 413 vient du tunnel.

    100 Mio → 403   corps passé
    101 Mio → 413   refusé par le tunnel

⚠ Le plafond est EXACTEMENT 100 Mio, alors que kadans-api annonçait 200 Mio.

⚠ Et la parade retenue n'est PAS celle que le README recommandait. Ramener le
gabarit sous le plafond aurait aussi fermé les cours longs (~29 min au palier
« travail »). C'est le téléversement en PARTS qui a été livré (kadans-api #188).

## Ce que ça crée comme déchet, et à qui il appartient

Un téléversement en parts jamais refermé laisse ses parts dans le bucket :
`mc ls` n'en dit rien, la console non plus, aucun objet ne les montre. Une fuite
qui ne se voit qu'à la facture — ou à la saturation d'un volume de 50 Gi.

L'app abandonne ce qu'elle ouvre quand elle échoue en route. Elle ne peut PAS
rattraper le navigateur qui ferme l'onglet.

⚠ LE README DU MODULE DISAIT « il ne pose ni quota, ni règle de cycle de vie » —
et cette règle-ci ne le contredit pas, elle en précise la frontière. La question
qui tranche est « à QUOI cette connaissance appartient-elle ? » :

  - une EXPIRATION DE CONTENU (« ces vidéos se purgent à 90 jours ») demande de
    connaître le produit. Elle est chez l'app ;
  - un téléversement incomplet n'est le contenu de PERSONNE. Aucune app ne veut
    le garder, aucune ne peut le voir depuis son code. C'est un déchet de
    PROTOCOLE, produit par le mécanisme même du bucket : il est chez celui qui
    crée les buckets.

Test pratique écrit au README : si répondre à « combien de temps ? » exige de
connaître le produit, c'est chez l'app. Ici la réponse n'exige que de connaître
S3 — passé l'expiration des URL signées (2 h côté kadans-api), un téléversement
ne peut plus RIEN recevoir. D'où 1 jour, douze fois la marge, et pas 7.

⚠ Non paramétrable (YAGNI) : un seul cas. Le déclencheur pour en faire une
variable est écrit — une app qui signerait des parts au-delà de 24 h.

## ⚠ Ce réglage-ci fonctionne, contrairement au CORS par bucket

MinIO communautaire stubbe `PutBucketCors` en 501 (`cmd/dummy-handlers.go`), ce
qui avait déjà coûté une tentative d'IaC — c'est écrit dans le `iac/main.tf` du
dépôt front. Les handlers de CYCLE DE VIE, eux, n'y figurent PAS : vérifié à la
source avant d'écrire une ligne, pour ne pas répéter exactement cette erreur.

## Le plancher de version, et pourquoi il est là

`abort_incomplete_multipart_upload` est apparu en **3.10.0** — mesuré en
interrogeant le schéma du provider version par version (3.9.0 ne l'a pas,
3.10.0 l'a). Le module déclare donc `>= 3.10.0` LUI-MÊME.

⚠ Sans ce plancher, un appelant resté sur 3.3.0 échouerait au plan sur un
« unsupported block type » qui ne dit pas qu'il faut monter de version. Avec, il
lit dès `tofu init` : « no available releases match the given constraints 3.3.0,
>= 3.10.0 ».

## ⚠ ORDRE DE FUSION

Cette PR fait passer tout appelant du module sous le plancher 3.10.0. Le dépôt
`kadans` épingle encore 3.3.0 : **son bump doit atterrir AVANT celle-ci**, sinon
son apply casse entre les deux fusions.

## Preuve

`tofu validate` contre le schéma RÉEL du provider 3.10.0, module instancié depuis
un bac à sable (aucun backend, aucun appel à MinIO ni Vault) : « Success! The
configuration is valid. »

Et le plancher a été éprouvé plutôt que relu : épinglé à 3.3.0, `tofu init` rend
bien le refus cité ci-dessus.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 20:06:02 +02:00
4 changed files with 132 additions and 16 deletions
+35 -12
View File
@@ -78,20 +78,43 @@ l'URL signée et de sa durée de vie courte (15 min pour déposer, 1 h pour lire
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
### La taille maximale d'une requête — **mesurée le 2026-08-20**
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.
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.
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.
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
+47 -3
View File
@@ -38,6 +38,8 @@ 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 ;
- les **téléversements abandonnés** sont ramassés au bout d'un jour (voir plus
bas : c'est de l'hygiène de protocole, pas un choix de l'app) ;
- le compte de service ne peut **rien** toucher d'autre que ces buckets-là ;
- ses clés vont dans `kvv2/minio/<app>`, que le module Vault central autorise
déjà l'app à lire (règle **inconditionnelle** : le chemin porte le nom de
@@ -51,6 +53,48 @@ 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.
Il ne pose ni quota, ni **expiration de contenu**, ni versioning : ces choix
appartiennent à l'app et varient d'un bucket à l'autre. « Ces vidéos se purgent
à 90 jours » est une décision de produit, elle se prend dans le dépôt du
produit.
### ⚠ L'exception, et la ligne qu'elle trace
Ce module pose **une seule** règle de cycle de vie :
`AbortIncompleteMultipartUpload` à 1 jour, sur chaque bucket qu'il crée.
Elle a l'air de contredire le paragraphe ci-dessus. Elle ne le contredit pas —
elle en précise la frontière, et c'est la question « à QUOI cette connaissance
appartient-elle ? » qui tranche :
- une **expiration de contenu** porte sur des objets que l'app a voulus, qu'elle
montre, et dont elle seule sait combien de temps ils valent. Elle varie d'une
app à l'autre : elle est chez l'app ;
- un **téléversement en plusieurs parts jamais refermé** n'est le contenu de
personne. Ce sont des morceaux qu'aucune API ne montre — ni `mc ls`, ni la
console — laissés par un navigateur qui a fermé l'onglet. **Aucune app ne veut
les garder**, et aucune ne peut les voir depuis son propre code. C'est un
déchet de PROTOCOLE, produit par le mécanisme même du bucket : il est chez
celui qui crée les buckets.
Le test pratique : si la réponse à « combien de temps ? » demande de connaître le
produit, c'est chez l'app. Ici, la réponse ne demande que de connaître S3 — passé
l'expiration des URL signées, un téléversement en cours ne peut plus rien
recevoir, il occupe seulement.
⚠ Le délai n'est **pas** paramétrable, et c'est voulu (YAGNI) : un seul cas
existe. Le jour où une app signe des parts pour plus de 24 h, ce sera le
déclencheur pour en faire une variable — pas avant.
### Plancher de version
⚠ Ce module exige **`aminueza/minio >= 3.10.0`** : `abort_incomplete_multipart_upload`
y est apparu (mesuré en interrogeant le schéma du provider version par version —
3.9.0 ne l'a pas). Un appelant resté plus bas se verra dire, dès `tofu init` :
```
no available releases match the given constraints 3.3.0, >= 3.10.0
```
… ce qui NOMME la version à atteindre, au lieu d'un « unsupported block type »
au plan, qui ne l'aurait pas dite.
+42
View File
@@ -8,6 +8,48 @@ resource "minio_s3_bucket" "app" {
force_destroy = false # détruire un bucket doit être un geste explicite
}
# Les téléversements ABANDONNÉS ne s'accumulent pas
#
# Un téléversement S3 en plusieurs parts qui n'est jamais refermé laisse ses
# parts dans le bucket : elles occupent de l'espace et AUCUN objet ne les
# montre. `mc ls` n'en dit rien, la console non plus. C'est donc une fuite qui
# ne se voit qu'à la facture ou à la saturation du volume, qui est ici de
# 50 Gi et déjà dimensionné à 250 h de cours.
#
# L'application abandonne ce qu'elle ouvre quand elle échoue en route. Ce
# qu'elle ne peut PAS rattraper : un navigateur qui ferme l'onglet, perd le
# réseau, ou expire. Ce cas- appartient au BUCKET, pas au code applicatif
# c'est la seule place d' l'on voit un téléversement que plus personne ne
# suit.
#
# POURQUOI UN JOUR, ET PAS SEPT. Les URL de parts sont signées pour quelques
# heures (2 h côté kadans-api). Passé ce délai, un téléversement en cours ne
# peut PLUS rien recevoir : il est mort, il occupe seulement. Un jour laisse
# douze fois la marge nécessaire au plus long téléversement possible, sans
# garder des déchets une semaine.
#
# CE N'EST PAS PARAMÉTRABLE, ET C'EST VOULU (YAGNI) : un seul cas existe. Le
# jour une app signe des parts pour plus de 24 h, ce sera le déclencheur pour
# en faire une variable pas avant.
#
# CE RÉGLAGE-CI FONCTIONNE VRAIMENT, contrairement au CORS par bucket. MinIO
# édition communautaire stubbe `PutBucketCors` (501, cf. `cmd/dummy-handlers.go`
# du serveur), ce qui a déjà coûté une tentative d'IaC ; les handlers de CYCLE DE
# VIE, eux, n'y figurent PAS vérifié à la source le 2026-08-20.
resource "minio_ilm_policy" "app" {
for_each = minio_s3_bucket.app
bucket = each.value.bucket
rule {
id = "abandon-televersements-incomplets"
status = "Enabled"
abort_incomplete_multipart_upload {
days_after_initiation = "1d"
}
}
}
resource "minio_iam_policy" "app" {
name = "${var.app}-app"
policy = jsonencode({
+8 -1
View File
@@ -1,7 +1,14 @@
terraform {
required_providers {
minio = {
source = "aminueza/minio"
source = "aminueza/minio"
# PLANCHER, ET IL N'EST PAS DÉCORATIF : `abort_incomplete_multipart_upload`
# est apparu en 3.10.0 (mesuré en interrogeant le schéma du provider,
# version par version : 3.9.0 ne l'a pas, 3.10.0 l'a). Sans cette
# contrainte, un appelant resté sur 3.3.0 échouerait au plan avec un
# « unsupported block type » qui ne dit pas qu'il faut monter de version.
# Le plancher fait dire à tofu la vraie phrase, au bon moment.
version = ">= 3.10.0"
configuration_aliases = [minio]
}
vault = {