Author SHA1 Message Date
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
arcodange d2202414c0 Merge pull request 'fix(redis) — il mourait toutes les 90 s, et c'est le .fr qui tombait' (#28) from arcodange/redis-sonde into main
Helm Charts / Detect changed charts (push) Successful in 1m15s
Helm Charts / Library charts tool (push) Has been skipped
Helm Charts / Application charts pgcat (push) Has been skipped
Reviewed-on: #28
2026-07-29 20:23:45 +02:00
arcodange f9adc2ec23 Merge pull request 'fix(pgbouncer,vault) — ce qu'un client laisse sur une connexion, le suivant n'en hérite plus' (#25) from arcodange/fuite-set-role into main
Helm Charts / Detect changed charts (push) Successful in 1m12s
Helm Charts / Library charts tool (push) Has been skipped
Helm Charts / Application charts pgcat (push) Has been skipped
Reviewed-on: #25
2026-07-29 20:22:10 +02:00
arcodange 49763852d8 Merge pull request 'doc(réseau) — le confort LAN ne se règle pas dans Traefik : la mesure, les deux pièges, la voie retenue' (#24) from arcodange/confort-lan into main
Helm Charts / Detect changed charts (push) Successful in 1m8s
Helm Charts / Library charts tool (push) Has been skipped
Helm Charts / Application charts pgcat (push) Has been skipped
Reviewed-on: #24
2026-07-29 18:36:05 +02:00
arcodangeandClaude Opus 5 f53c3adb7d fix(redis) — il mourait toutes les 90 s, et c'est le .fr qui tombait
Helm Charts / Detect changed charts (pull_request) Successful in 1m14s
Helm Charts / Application charts pgcat (pull_request) Has been skipped
Helm Charts / Library charts tool (pull_request) Has been skipped
Symptôme observé : `kadans.arcodange.fr` et `gitea.arcodange.fr` en 403, corps
vide, depuis n'importe quel client extérieur — pendant que `arcodange.fr` et
`www` répondaient 200. Ça ressemble à un bannissement d'IP. Ça n'en était pas un.

La chaîne, remontée de bout en bout :

1. Le sous-chart fige ses sondes à `timeoutSeconds: 1` sur `redis-cli ping` et
   n'expose aucune valeur pour les surcharger.
2. Sans réservation de ressources, le conteneur est en QoS `BestEffort` : sur un
   Raspberry Pi chargé, lancer `redis-cli` dépasse la seconde.
3. « Liveness probe failed: command timed out after 1s » 836 fois en 23 jours,
   135 redémarrages, SIGTERM après ~90 s de vie à chaque tour.
4. Le plugin crowdsec de Traefik utilise ce Redis comme cache de décisions.
   Cache injoignable ⇒ `isCrowdsecStreamHealthy:false` ⇒ refus par défaut de tout
   client absent de `clientTrustedIPs` ⇒ le 403.

D'où le motif trompeur : seuls les hôtes portant le middleware crowdsec
tombaient, et depuis le LAN (IP de confiance) tout paraissait sain — l'origine
répondait 401, son défi d'authentification normal. Trois fausses pistes en sont
sorties : Cloudflare, un bannissement crowdsec, une règle de pare-feu. Aucune
n'était la bonne, et `cscli decisions list` disait `isBanned:false` du début à la
fin.

La réservation fait passer le conteneur en `Burstable` et lui garantit sa part.
Les limites restent larges : Redis n'est pas ce qui sature ce nœud.

⚠ Ce commit ne corrige PAS la sonde elle-même — elle reste à 1 s, et elle reste
inatteignable depuis les valeurs. Si le clignotement revient malgré la
réservation, il faudra un patch Kustomize au niveau de l'Application ArgoCD, ou
abandonner ce sous-chart. Une vérification en production a été appliquée à chaud
(`timeoutSeconds: 5`, `failureThreshold: 6`) pour rétablir l'accès immédiatement,
mais `selfHeal: true` la ré-écrasera : elle n'est pas la correction, ce commit
l'est.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GdUCA5Uz8QyMwa2P4Pg2hK
2026-07-29 01:56:58 +02:00
arcodangeandClaude Opus 5 7d13a8d764 doc(pgbouncer) — la version mesurée n'était pas la déployée, et DISCARD ALL a un prix
Helm Charts / Detect changed charts (pull_request) Successful in 28s
Helm Charts / Library charts tool (pull_request) Has been skipped
Helm Charts / Application charts pgcat (pull_request) Has been skipped
Trois corrections au raisonnement, toutes issues d'une contre-vérification qui
a refait les mesures au lieu de les relire.

1. La mesure invoquait pgbouncer 1.25.2 « la version en prod ». Le pod tourne
   1.23.1 (ghcr.io/icoretech/pgbouncer-docker:1.23.1-fixed, chart 2.3.1, up
   133 j). Tout a été rejoué sur 1.23.1 avec la ConfigMap extraite du cluster :
   les conclusions tiennent, mais la preuve invoquée portait sur autre chose.

2. La portée de la fuite était laissée en suspens, donc lue au pire. Les pools
   sont partitionnés par (base, utilisateur) — 210 pools, aucun mélange. Le
   seul héritier possible du SET ROLE de kadans-api est kadans-api. Défaut
   d'hygiène, pas brèche inter-applications. Le dire baisse la gravité, et
   c'est plus utile qu'une alarme vague.

3. ⚠ Le point manquant, et c'est l'inverse de ce qu'on croyait : DISCARD ALL
   rend la protection de db.go STRICTEMENT PLUS FRAGILE. Aujourd'hui
   (DEALLOCATE ALL) elle survivrait à un passage en transaction pooling ;
   après, non — le SET ROLE d'AfterConnect est effacé entre deux transactions,
   et poserRoleProprietaire ne peut pas le voir puisqu'il relit current_role à
   l'ouverture. Sans danger tant que pool_mode reste `session` (le défaut, non
   déclaré) — mais c'est précisément le genre de trappe qui se referme des mois
   plus tard sur quelqu'un qui optimise. Les quatre combinaisons sont mesurées
   et écrites au-dessus de la ligne.

La ceinture Vault couvre ce cas (un défaut de rôle survit à DISCARD ALL), mais
seulement au renouvellement des baux. D'où l'avertissement explicite.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GdUCA5Uz8QyMwa2P4Pg2hK
2026-07-28 23:23:18 +02:00
arcodangeandClaude Opus 5 1dd905dc5f fix(pgbouncer,vault) — ce qu'un client laisse sur une connexion, le suivant n'en hérite plus
Helm Charts / Detect changed charts (pull_request) Successful in 58s
Helm Charts / Library charts tool (pull_request) Has been skipped
Helm Charts / Application charts pgcat (pull_request) Has been skipped
Un pgbouncer partagé n'a qu'UNE barrière entre deux clients d'un même pool : le
`server_reset_query`. La nôtre ne nettoyait que les requêtes préparées. Tout le
reste de l'état de session — variables, LISTEN, tables TEMP, et le rôle
courant — traversait la déconnexion et tombait dans les mains du client suivant.

CE QUI A ÉTÉ MESURÉ, PAS SUPPOSÉ

pgbouncer 1.25.2 (la version qui tourne), monté avec NOTRE configuration :
`pool_mode = session`, `server_reset_query = DEALLOCATE ALL`,
`server_reset_query_always = 1`, `default_pool_size = 1`. Le client 1 pose son
état puis se déconnecte ; le client 2, qui n'a rien demandé, arrive.

  DEALLOCATE ALL (l'existant)   client 2 hérite : work_mem=17MB, 1 LISTEN,
                                la table TEMP du client 1, et son SET ROLE —
                                au point de créer des tables appartenant à un
                                rôle qui n'est pas le sien.
  DISCARD ALL (ce commit)       client 2 obtient work_mem=4MB, 0 LISTEN, pas
                                de table TEMP, et son PROPRE rôle.

POURQUOI `DISCARD ALL` NE PEUT RIEN CASSER

C'est le défaut de pgbouncer, et un sur-ensemble STRICT des deux valeurs qui
l'ont précédé ici : il contient `DEALLOCATE ALL` — donc le correctif crowdsec de
07e2c6d (« prepared statement already exists ») est conservé, et c'est vérifié :
deux clients successifs préparent le même nom sans erreur — et il contient
`SELECT pg_advisory_unlock_all()`, la valeur que pose le sous-chart.

Et il ne touche jamais un client vivant : en `pool_mode = session` la connexion
serveur n'est rendue qu'à la déconnexion, donc le reset ne court pas entre deux
requêtes d'une même session. Vérifié : table TEMP, `SET work_mem` et `LISTEN`
d'un client VIVANT survivent intacts.

PORTÉE DE LA FUITE — ce qu'elle est, et ce qu'elle n'est pas

Les pools de pgbouncer sont partitionnés par (base, utilisateur) : 210 pools
mesurés sur l'instance, aucun ne mélange deux bases ni deux comptes. Un rôle
posé par kadans ne peut donc PAS atterrir chez crowdsec ou plausible : le seul
héritier possible est un client de la même base avec le même identifiant, donc
l'application elle-même. Ce n'est pas une élévation de privilège entre
applications — c'est un défaut d'hygiène, et il est déjà là aujourd'hui pour
n'importe quel `SET` de n'importe quelle application.

LA CEINTURE, CÔTÉ VAULT

`ALTER ROLE "{{name}}" SET ROLE <app>_role` dans les creation_statements fait de
l'endossement un défaut de CONNEXION, immune au `pool_mode` comme au
`server_reset_query`, et valable pour les clients qui ne sont pas l'application
(le psql d'un job). Elle n'apporte aucun privilège : le `GRANT` de la ligne
précédente rend déjà le rôle éphémère membre du rôle stable — d'où le fait
qu'elle ne peut pas échouer là où le GRANT réussit.

Elle règle à la racine ce que le CronJob `pg-fix-table-ownership` rattrape tous
les jours à 03:00 : les objets naissent chez le rôle stable au lieu d'être
réattribués après coup. ⚠ Elle ne vaut que pour les identifiants créés APRÈS
l'apply — elle ne protège donc PAS ce soir ; c'est `DISCARD ALL` qui protège ce
soir.

Mesuré (PostgreSQL 16, compte CREATEROLE non-superutilisateur comme celui de
Vault) : l'instruction passe, le login donne bien `current_role` = rôle stable
avec `session_user` = rôle éphémère, un `ALTER ROLE … RESET role` la retire, et
elle SURVIT à `RESET ALL` / `DISCARD ALL` — les deux mesures composent au lieu
de s'annuler.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GdUCA5Uz8QyMwa2P4Pg2hK
2026-07-28 22:48:49 +02:00
arcodangeandClaude Opus 5 2cb19809c6 doc(reseau) — le confort LAN ne se règle pas dans Traefik : mesure, pièges, voie retenue
Helm Charts / Detect changed charts (pull_request) Successful in 1m6s
Helm Charts / Library charts tool (pull_request) Has been skipped
Helm Charts / Application charts pgcat (pull_request) Has been skipped
Demande : « ne pas avoir le basic auth Traefik quand on est sur le Wi-Fi partagé
avec le homelab ». La réponse intuitive — une règle `ClientIP(192.168.1.0/24)` —
ne peut pas marcher, et la variante « je regarde CF-Connecting-IP » est un
contournement d'authentification. Ce commit écrit la mesure pour qu'on ne
re-découvre ni l'une ni l'autre.

Mesuré (2026-07-28, homelab) :

- `kadans.arcodange.fr` résout vers Cloudflare MÊME depuis le LAN, et redescend
  par le tunnel cloudflared. L'adresse de socket vue par Traefik est donc
  toujours celle d'un pod, en 10.42.x.x.
- Traefik SAIT qui est le vrai client (journal d'accès : l'IPv6 de la maison,
  `2a01:cb04:dff:cf00::/64` — pas l'IPv4 qu'`ipify` injecte dans `localIp`), car
  l'entrypoint `web` fait confiance aux en-têtes venant de 10.42.0.0/16.
- Mais le matcher de routeur `ClientIP()`, lui, juge la SOCKET. Prouvé par trois
  routeurs temporaires vers un Service sans endpoint (503 = règle matchée,
  401 = repli sur le routeur normal) : témoin 503, `ClientIP(<IPv6 maison>)` 401,
  `ClientIP(10.42.0.0/16)` 503. Sondes retirées après mesure.

D'où deux pièges, écrits là où on les rencontrerait :

- `localIp@file` sur un routeur `.fr` laisserait entrer Internet entier, parce
  que `10.42.0.0/16` est dans son `sourceRange` et qu'`ipAllowList` juge lui
  aussi la socket. Vérifié : aucun routeur `.fr` ne le porte aujourd'hui — le
  piège est latent, et les cinq endroits d'où on pourrait le recopier
  (2 gabarits `.fr`, 3 values `.lab`) portent désormais l'avertissement.
- Un en-tête posé par le client ne peut pas piloter une exemption d'auth :
  Cloudflare ne retire pas les en-têtes inconnus, et Traefik reste joignable en
  direct sur 192.168.1.201.

Voie retenue, et pourquoi celle-là : la décision « suis-je à la maison ? » doit
être prise là où la vraie IP est native et non falsifiable, donc au bord, chez
Cloudflare. Une règle de transformation y pré-remplit l'en-tête `Authorization`
pour les IP du foyer. Traefik ne bouge pas, aucun certificat public à produire,
aucun changement DNS. Surface d'attaque ajoutée : AUCUNE — `kadans:kkadans` est
déjà en clair dans le chart de kadans, Cloudflare ne fait que le taper à notre
place. Et quand l'IP du foyer dérive, la règle cesse de matcher : le navigateur
redemande le mot de passe. Dégradation douce, pas de panne.

Le doc écrit l'action Cloudflare mot pour mot (expression, en-tête, valeur, et
l'équivalent Terraform pour `cms/cloudflare`) plutôt que de la supposer faite,
avec les deux commandes qui la vérifient — et nomme le repli si Cloudflare
refuse de modifier `Authorization` : Cloudflare Access avec une politique de
bypass, PAS un en-tête secret.

Enfin, un contre-exemple utile : le bouncer CrowdSec, lui, juge la VRAIE IP
(journal à l'appui). Son `clientTrustedIPs` contient `10.42.0.0/16` sans que ce
soit un trou — noté pour que personne ne « corrige » cette ligne en croyant y
reproduire le piège.

⚠ Aucun des réglages en cause ne vit dans ce dépôt : les valeurs Helm de
Traefik, `dynamic.yaml`/`localIp` et le Middleware crowdsec sont dans `factory`
(ansible), le tunnel et le DNS dans `cms`. Ce dépôt reçoit la mesure et les
garde-fous parce que c'est lui qui héberge les gabarits `.fr` et les
consommateurs de `localIp@file`.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GdUCA5Uz8QyMwa2P4Pg2hK
2026-07-28 14:29:35 +02:00
arcodange 9de9a9663b Merge pull request 'ci — arrêter les runs qui ne peuvent pas aboutir, et le doublon push+PR' (#23) from arcodange/moins-de-runs-inutiles into main
Helm Charts / Detect changed charts (push) Successful in 18s
Helm Charts / Library charts tool (push) Has been skipped
Helm Charts / Application charts pgcat (push) Has been skipped
2026-07-26 12:39:13 +02:00
arcodangeandClaude Opus 5 2bdc486ae6 ci — arrêter les runs qui ne peuvent pas aboutir, et le doublon push+PR
Helm Charts / Detect changed charts (pull_request) Successful in 17s
Helm Charts / Library charts tool (pull_request) Has been skipped
Helm Charts / Application charts pgcat (pull_request) Has been skipped
Quatre runs d'une branche déjà mergée ont bloqué, ce matin, l'apply qu'on
attendait. Deux causes, indépendantes :

1. Les workflows tofu (minio, vault, crowdsec, plausible) s'authentifient à
   Vault par un flux OIDC dont un HUMAIN doit ouvrir le lien. Déclenchés tout
   seuls, ils ne peuvent qu'occuper un runner jusqu'au timeout. Ils font en
   plus `apply` en `auto_approve` CONTRE LA PROD : partir sur le push d'une
   branche, c'est appliquer du code que personne n'a relu. → `workflow_dispatch`
   seul, ce qui écrit enfin ce qu'ils faisaient déjà.

   (crowdsec et plausible passaient de toute façon par une ancre YAML, donc
   leurs triggers étaient INERTES — issues 113 → 117 de kadans.)

2. `push` sur toutes les branches + `pull_request` = DEUX runs par commit dès
   qu'une branche a une PR. Vérifié : runs 258/259 et 260/261 portent le même
   SHA. helmcharts, qui travaille seul et mérite de rester automatique, prend
   la forme éprouvée de la CI de kadans : push sur `main`, PR pour la branche.

Chaque clé de trigger porte un corps explicite : un `pull_request:` nu n'est
pas une forme éprouvée ici, et son mode d'échec est le silencieux.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01CoafGWmRVESaWX819USUUA
2026-07-26 12:37:46 +02:00
arcodange 901aa9a9dc Merge pull request 'fix(minio) — le provisionneur ne pouvait pas créer de bucket : s3:ListBucket manquait' (#22) from arcodange/provisioner-listbucket into main
Helm Charts / Detect changed charts (push) Successful in 1m0s
Helm Charts / Library charts tool (push) Has been skipped
Helm Charts / Application charts pgcat (push) Has been skipped
MinIO / Auth with gitea for vault (push) Successful in 23s
MinIO / Tofu - minio IAC (push) Successful in 49s
2026-07-26 11:44:18 +02:00
arcodangeandClaude Opus 5 e8ab19962b fix(minio) — le provisionneur ne pouvait pas créer de bucket : s3:ListBucket manquait
Helm Charts / Detect changed charts (push) Successful in 15s
Helm Charts / Detect changed charts (pull_request) Successful in 16s
Helm Charts / Library charts tool (push) Has been skipped
MinIO / Auth with gitea for vault (push) Failing after 8m31s
MinIO / Tofu - minio IAC (push) Has been skipped
Helm Charts / Library charts tool (pull_request) Has been skipped
Helm Charts / Application charts pgcat (push) Has been skipped
MinIO / Auth with gitea for vault (pull_request) Failing after 8m32s
MinIO / Tofu - minio IAC (pull_request) Has been skipped
Helm Charts / Application charts pgcat (pull_request) Has been skipped
Le premier apply réel (kadans) a créé la politique, le compte de service,
l'attachement et le secret Vault — puis a échoué sur le bucket lui-même :

    Error: [FATAL] unable to check bucket (kadans-videos): Access Denied.

Le provider teste l'existence du bucket AVANT de le créer (HeadBucket), et
MinIO exige `s3:ListBucket` pour ça. C'est le seul point que l'ADR annonçait
comme non éprouvé ; il l'est maintenant.

`s3:ListBucket` donne la vue des CLÉS d'un bucket, pas leur contenu :
GetObject / PutObject restent absents, donc le provisionneur — partagé entre
les rôles CI — ne peut toujours pas lire les vidéos d'une autre application.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01CoafGWmRVESaWX819USUUA
2026-07-26 11:43:04 +02:00
arcodange 5c7dce96e1 Merge pull request 'feat(minio) — un compte de service par app consommatrice, borné à son bucket' (#21) from arcodange/minio-comptes-de-service into main
Helm Charts / Detect changed charts (push) Successful in 19s
Hashicorp Vault / Auth with gitea for vault (push) Successful in 2m58s
MinIO / Auth with gitea for vault (push) Successful in 5m52s
Helm Charts / Library charts tool (push) Has been skipped
Hashicorp Vault / Tofu - Vault IAC (push) Successful in 53s
MinIO / Tofu - minio IAC (push) Successful in 33s
Helm Charts / Application charts pgcat (push) Has been skipped
2026-07-26 10:46:39 +02:00
arcodangeandClaude Opus 5 7074a94d7e docs(minio) — le raisonnement part dans l'ADR, le code garde ce qui protège
Helm Charts / Detect changed charts (push) Successful in 22s
Helm Charts / Detect changed charts (pull_request) Successful in 16s
MinIO / Auth with gitea for vault (push) Failing after 8m33s
MinIO / Tofu - minio IAC (push) Has been skipped
MinIO / Auth with gitea for vault (pull_request) Failing after 8m32s
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 (push) Has been skipped
Helm Charts / Library charts tool (pull_request) Has been skipped
Hashicorp Vault / Auth with gitea for vault (push) Failing after 8m31s
Hashicorp Vault / Tofu - Vault IAC (push) Has been skipped
Helm Charts / Application charts pgcat (push) Has been skipped
Helm Charts / Application charts pgcat (pull_request) Has been skipped
Les décisions de conception (qui déclare, qui détient, pourquoi les octets ne
passent pas par l'API) vivent désormais dans
`factory/doc/adr/20260726-stockage-objet-minio.md`.

Le code ne garde que ce qu'un relecteur ne peut pas deviner et qui l'empêcherait
de casser quelque chose : l'absence VOLONTAIRE de s3:GetObject/PutObject dans la
politique du provisionneur, l'absence VOLONTAIRE de basic-auth sur l'ingress
S3, le caractère inconditionnel de la règle Vault, et l'avertissement sur les
noms d'actions MinIO non éprouvés. Chaque fichier pointe l'ADR.

283 → 230 lignes sur minio/iac. tofu fmt propre, tofu validate réussi sur les
deux racines.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01CoafGWmRVESaWX819USUUA
2026-07-26 10:19:28 +02:00
arcodangeandClaude Opus 5 ec71571b98 refactor(minio) — chacun son périmètre : les buckets se déclarent depuis le dépôt de l'app
Helm Charts / Detect changed charts (push) Successful in 16s
Helm Charts / Detect changed charts (pull_request) Successful in 16s
MinIO / Auth with gitea for vault (pull_request) Failing after 8m32s
MinIO / Tofu - minio IAC (pull_request) Has been skipped
MinIO / Auth with gitea for vault (push) Failing after 8m31s
MinIO / Tofu - minio IAC (push) Has been skipped
Helm Charts / Library charts tool (push) Has been cancelled
Helm Charts / Application charts pgcat (push) Has been cancelled
Hashicorp Vault / Auth with gitea for vault (push) Has been cancelled
Hashicorp Vault / Tofu - Vault IAC (push) Has been cancelled
Helm Charts / Library charts tool (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 / Application charts pgcat (pull_request) Has been skipped
« Les buckets sont à déclarer dans le repo de kadans-api. On ne va pas modifier
le repo tools à chaque changement d'application. Chacun son périmètre. Tools
peut proposer un module pour standardiser la déclaration de buckets à la
limite, mais c'est tout. » (fondateur, 26/07)

C'est une erreur de fond de ma part : j'avais fait de `tools` le PROPRIÉTAIRE de
déclarations qui appartiennent aux applications. À ce rythme, chaque nouveau
bucket de n'importe quelle app devenait une PR sur l'infra partagée.

CE QUI CHANGE. `consumers.tf` disparaît, et la liste de buckets du chart se vide.
À la place, un module réutilisable `iac/modules/minio_app` : une app lui donne
son nom et ses buckets, et reçoit des buckets privés, un compte de service qui
ne peut rien toucher d'autre, et ses clés dans `kvv2/minio/<app>`.

L'OBSTACLE, ET SA RÉPONSE. Déclarer ses buckets depuis son propre dépôt suppose
des droits d'ADMINISTRATION sur MinIO. Confier le root serait absurde : il lit et
écrit tous les objets de toutes les apps. `tools` fournit donc un compte
PROVISIONNEUR aux droits minimaux — créer un bucket, une politique, un compte de
service — et AUCUN droit sur les objets. Une app compromise pourrait créer des
buckets (une nuisance), pas lire les vidéos d'une autre. Le root, lui, ne sort
toujours pas de ce pipeline.

Le rôle CI de chaque app gagne la lecture de `kvv2/data/minio/provisioner` dans
`app_policy` — générique, et c'est exactement le genre de standardisation qui
appartient au dépôt commun.

⚠ CE QUE JE N'AI PAS PU PROUVER : les noms d'actions d'administration MinIO de la
politique du provisionneur 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 non vérifié de cette PR, et il est signalé dans le
code à l'endroit exact.

tofu fmt propre · tofu validate réussi sur minio/iac ET hashicorp-vault/iac.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01CoafGWmRVESaWX819USUUA
2026-07-26 10:07:05 +02:00
arcodangeandClaude Opus 5 0e1b6e1062 refactor(minio) — lever la restriction « un seul bucket par app »
Helm Charts / Detect changed charts (pull_request) Successful in 17s
Helm Charts / Detect changed charts (push) Successful in 16s
MinIO / Auth with gitea for vault (pull_request) Failing after 8m32s
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 / 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 started running
Helm Charts / Library charts tool (pull_request) Has been skipped
Helm Charts / Application charts pgcat (pull_request) Has been skipped
« Pourquoi sommes-nous restreints sur les buckets ? » (fondateur, 26/07) — parce
que JE l'avais décidé, pour rendre tout dérivable d'un seul nom. Rien ne
l'imposait, et la restriction aurait mordu au pas suivant : l'ADR-018 de Kadans
prévoit DEUX paliers de transfert (aperçu 240p régénérable, travail 360p à
garder), donc deux cycles de vie, donc potentiellement deux buckets aux
politiques de purge différentes.

Chaque bucket déclare désormais son app (`app: kadans`). C'est toujours la SEULE
déclaration, au même endroit — mais le nom du bucket redevient libre, et
`kadans-videos` retrouve un nom qui dit ce qu'il contient.

Le compte de service devient par APP et non par bucket : une seule clé, autorisée
sur tous ses buckets et eux seuls. Ajouter un bucket à une app existante ne crée
donc aucune nouvelle clé — le compte existant gagne l'accès. Le secret porte
`MINIO_BUCKETS` (tous) en plus de `MINIO_BUCKET` (le premier), pour que l'app
n'ait pas à les redéclarer de son côté.

Un bucket sans `app:` est ignoré plutôt que de faire échouer le plan : il n'aura
simplement pas de compte de service, ce qui se voit immédiatement et ne casse
rien d'existant.

Vérifié sur le VRAI fichier, pas en théorie : le groupement rend bien
{"kadans" = ["kadans-videos"]}, et avec un second bucket de la même app,
{"kadans" = ["kadans-videos", "kadans-apercus"]} — le cas ADR-018 exact.
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:46:32 +02:00
arcodangeandClaude Opus 5 287e3dcf1e refactor(minio) — le BUCKET est la seule déclaration : plus de liste à tenir
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
« 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
arcodangeandClaude Opus 5 91a0f09b49 refactor(vault) — lire ses identifiants MinIO devient une propriété de la plateforme
Helm Charts / Detect changed charts (pull_request) Successful in 15s
Helm Charts / Detect changed charts (push) Successful in 15s
Helm Charts / Library charts tool (push) Has been cancelled
Helm Charts / Application charts pgcat (push) Has been cancelled
Hashicorp Vault / Auth with gitea for vault (push) Failing after 8m35s
Hashicorp Vault / Tofu - Vault IAC (push) Has been skipped
MinIO / Auth with gitea for vault (pull_request) Failing after 8m32s
MinIO / Tofu - minio IAC (pull_request) Has been skipped
Helm Charts / Library charts tool (pull_request) Has been skipped
Hashicorp Vault / Auth with gitea for vault (pull_request) Failing after 8m36s
Hashicorp Vault / Tofu - Vault IAC (pull_request) Has been skipped
Helm Charts / Application charts pgcat (pull_request) Has been skipped
Retour fondateur : « je pensais que tools#21 contribuerait à app_policy pour une
policy kvv2/minio/<app name> ». Il a raison, et mon choix initial était le plus
faible des deux.

Mon objection — ne donner le droit qu'aux apps qui en ont besoin — ne tient pas
à l'examen : la règle porte le NOM de l'app, donc elle ne peut jamais exposer
que ses propres clés. Il n'y a aucun privilège à préserver. Une app qui ne
stocke rien lit un chemin qui n'existe pas : une règle inerte, pas un droit.

Son argument, lui, porte : savoir lire ses propres identifiants de stockage est
une propriété de la PLATEFORME, pas une exception par application. Et
`kv_read_paths` est documenté comme la trappe pour un secret appartenant à une
AUTRE app (les creds GCS de Longhorn pour l'ERP) — y ranger un motif standard
l'aurait rendu invisible et aurait obligé à le redéclarer à chaque app.

La règle passe donc dans `app_policy`, en prod ET pour chaque instance non-prod
(symétrie stricte), sur deux chemins : le document `kvv2/data/minio/<app>` et
ses descendants.

Un cran plus loin que la demande : la règle est INCONDITIONNELLE, sans drapeau.
Conséquence — déclarer un consommateur MinIO se fait désormais à UN SEUL
endroit, `var.consumers` du pipeline minio. Aucune synchronisation à tenir entre
deux fichiers, donc rien à oublier. Le `kv_read_paths` que j'avais ajouté à
kadans est retiré : il faisait double emploi.

tofu fmt propre · tofu validate réussi sur hashicorp-vault/iac.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01CoafGWmRVESaWX819USUUA
2026-07-26 09:26:05 +02:00
arcodangeandClaude Opus 5 4ca4a05370 feat(minio) — un compte de service par app consommatrice, borné à son bucket
Helm Charts / Detect changed charts (push) Successful in 57s
MinIO / Auth with gitea for vault (push) Failing after 8m34s
MinIO / Tofu - minio IAC (push) Has been skipped
Hashicorp Vault / Auth with gitea for vault (push) Failing after 8m33s
Hashicorp Vault / Tofu - Vault IAC (push) Has been skipped
Helm Charts / Detect changed charts (pull_request) Successful in 1m1s
Helm Charts / Library charts tool (push) Has been cancelled
Helm Charts / Application charts pgcat (push) Has been cancelled
MinIO / Auth with gitea for vault (pull_request) Failing after 8m32s
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
« On peut adopter ce pattern à toutes les apps qui pourraient avoir besoin de
MinIO, et ainsi modifier les app roles / app policy générique. » (fondateur, 26/07)

Bonne nouvelle : les modules centraux n'ont PAS eu à changer. `kv_read_paths`
existe déjà dans `app_policy` et fait exactement ça — l'ERP s'en sert pour lire
les creds GCS de Longhorn. Le motif générique était donc à moitié construit ;
il manquait la moitié MinIO.

CE QUI EST AJOUTÉ : `minio/iac/consumers.tf` provisionne, pour chaque app
déclarée dans `var.consumers`, une politique MinIO bornée à SON bucket
(GetObject/PutObject/DeleteObject sur les objets, ListBucket sur le bucket seul
— ni les autres buckets, ni l'administration), un compte de service, et écrit
ses clés dans `kvv2/minio/<app>`.

POURQUOI LES CLÉS VIVENT CHEZ MINIO ET PAS CHEZ L'APP : seul ce pipeline possède
les identifiants ROOT. 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 d'ici, 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à.

Ajouter une app = trois lignes déclaratives, documentées au README : son bucket
dans values.yaml, son entrée dans `var.consumers`, son `kv_read_paths` au tfvars
central. Aucun module à toucher.

Le provider parle à `s3.arcodange.fr` — l'endpoint PUBLIC, parce que le runner
CI n'est pas dans le LAN et que `.lab` ne s'y résout pas. C'est l'exposition
HTTPS mergée ce matin qui rend ce plan applicable.

⚠ Erreur attrapée par le validateur, et corrigée : je relisais `kvv2/minio/config`
alors que `local.config` de main.tf porte déjà les identifiants root — une
lecture qui aurait créé une dépendance circulaire avec l'écriture faite par ce
même plan.

Vérifié : `tofu fmt` propre, `tofu validate` réussi (avec TERRAFORM_VAULT_AUTH_JWT
posé — sans lui, le provider Vault échoue déjà sur main, ce n'est pas mon fait).
Je n'ai lancé AUCUN apply : c'est le workflow qui le fera, et la revue t'appartient.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01CoafGWmRVESaWX819USUUA
2026-07-26 09:12:09 +02:00
arcodange f49a79e393 Merge pull request 'feat(minio) — exposer l'API S3 en HTTPS public (s3.arcodange.fr) + CORS de la PWA' (#20) from arcodange/minio-public-fr into main
Helm Charts / Detect changed charts (push) Successful in 42s
Helm Charts / Library charts tool (push) Has been skipped
Helm Charts / Application charts pgcat (push) Has been skipped
Reviewed-on: #20
2026-07-26 07:34:48 +02:00
arcodangeandClaude Opus 5 1490f4c514 feat(minio) — exposer l'API S3 en HTTPS public (s3.arcodange.fr) + CORS de la PWA
Helm Charts / Detect changed charts (push) Successful in 1m6s
Helm Charts / Detect changed charts (pull_request) Successful in 1m5s
Helm Charts / Library charts tool (push) Has been skipped
Helm Charts / Library charts tool (pull_request) Has been skipped
Helm Charts / Application charts pgcat (push) Has been skipped
Helm Charts / Application charts pgcat (pull_request) Has been skipped
Sans ça, la synchronisation des vidéos ne peut pas marcher, et ce sont des faits
du navigateur : une page servie en https ne peut pas émettre de requête vers
http:// (contenu mixte), et `.lab` n'est pas résolvable hors du LAN. L'ingress
existant est en entrypoint `web` sans TLS, sur un domaine interne — donc
inutilisable depuis kadans.arcodange.fr, en déplacement COMME à la maison.

Même motif que grafana/templates/ingress-public.yaml : entrypoint `web`, TLS
terminé par le tunnel Cloudflare (wildcard *.arcodange.fr), middleware crowdsec.
Le `.lab` interne reste inchangé.

PAS de basic-auth, contrairement à kadans-public : une requête S3 porte sa propre
signature (SigV4), et 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.

CORS déclaré sur les origines EXACTES de la PWA (.fr et .lab), jamais « * » :
une URL présignée qui fuiterait serait sinon rejouable depuis n'importe quel
site. Sans cette liste, le préflight OPTIONS échoue et le PUT ne part même pas.

⚠ Signalé, NON mesuré : le tunnel Cloudflare plafonne probablement le corps
d'une requête (~100 Mo sur les offres gratuites). À éprouver avec un vrai
téléversement. Contexte utile : sur le corpus réel du fondateur (707 vidéos,
~2 ans), la durée MOYENNE est de 53 s et deux vidéos seulement dépassent 5 min —
au palier « travail » de l'ADR-018, 100 Mo valent ~29 min de cours.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01CoafGWmRVESaWX819USUUA
2026-07-26 01:56:35 +02:00
arcodange dce19141a8 Merge pull request 'fix(minio) — déclarer minio dans la liste centrale des applications Vault' (#19) from claude/minio-role into main
Helm Charts / Detect changed charts (push) Successful in 29s
Helm Charts / Library charts tool (push) Has been skipped
Helm Charts / Application charts pgcat (push) Has been skipped
Hashicorp Vault / Auth with gitea for vault (push) Successful in 1m28s
Hashicorp Vault / Tofu - Vault IAC (push) Successful in 1m19s
Reviewed-on: #19
2026-07-25 18:37:45 +02:00
27 changed files with 939 additions and 61 deletions
+8 -5
View File
@@ -2,12 +2,15 @@
# template source: https://github.com/bretfisher/docker-build-workflow/blob/main/templates/call-docker-build.yaml
name: Crowdsec
on: #[push,pull_request]
# À LA DEMANDE, et seulement à la demande — comme minio.yaml : auth Vault par
# flux OIDC (un humain doit ouvrir un lien) et apply `auto_approve` contre la prod.
#
# Note : les triggers `push`/`pull_request` retirés ici étaient de toute façon
# INERTES — ils passaient par une ancre YAML (`&`/`*`), que le parseur
# d'événements de Gitea ne résout pas (vécu sur arcodange/kadans, issues 113
# → 117). Ce workflow ne partait déjà qu'à la main ; c'est maintenant écrit.
on:
workflow_dispatch: {}
push: &crowdsecPaths
paths:
- 'crowdsec/**/*.tf'
pull_request: *crowdsecPaths
# cancel any previously-started, yet still active runs of this workflow on the same branch
concurrency:
+26 -8
View File
@@ -2,14 +2,32 @@
# template source: https://github.com/bretfisher/docker-build-workflow/blob/main/templates/call-docker-build.yaml
name: Helm Charts
on: [push,pull_request,workflow_dispatch]
# push: &helmPaths # turns out gitea don't handle well the paths filter
# paths:
# - '*/\.yaml'
# - '*/\.tpl'
# - '*/NOTES.txt'
# - '*/\.helmignore'
# pull_request: *helmPaths
# Celui-ci travaille SEUL (pas d'auth Vault, pas d'apply) : on le garde
# automatique. Mais `push` sur TOUTES les branches + `pull_request` faisait
# partir DEUX runs pour le même commit dès qu'une branche avait une PR.
#
# Même forme que la CI de kadans : la branche est couverte par `pull_request`,
# `main` par le `push` d'après-merge. Un run par événement, aucun angle mort.
#
# (Le filtre de chemins d'origine, resté en commentaire des années sous un
# « gitea don't handle well the paths filter », n'était probablement pas en
# cause : il passait par une ancre YAML, et le parseur d'événements de Gitea ne
# les résout pas — issues 113 → 117 de kadans. Le job `filter-chart` fait déjà
# ce tri au niveau job, donc on n'y retouche pas.)
#
# ⚠ Chaque clé porte un CORPS explicite : un `pull_request:` nu (valeur nulle)
# n'est pas une forme éprouvée sur ce Gitea, et son mode d'échec est le
# silencieux — aucun run, aucune erreur. On copie la forme qui tourne (kadans
# ci.yml), listes dupliquées à la main, sans ancre.
on:
workflow_dispatch: {}
push:
branches: [main]
paths-ignore:
- '**.md'
pull_request:
paths-ignore:
- '**.md'
# cancel any previously-started, yet still active runs of this workflow on the same branch
concurrency:
+11 -12
View File
@@ -1,20 +1,19 @@
---
name: MinIO
# ⚠ Triggers écrits EN TOUTES LETTRES, sans ancre YAML (`&`/`*`).
# Une ancre dans un trigger Gitea Actions fait taire push ET pull_request —
# en silence, aucun run, aucune erreur (vécu sur arcodange/kadans, issues 113
# → 117). Les autres workflows de ce repo utilisent encore des ancres : à
# vérifier séparément, c'est probablement pour ça qu'ils ne partent qu'à la
# main (workflow_dispatch).
# À LA DEMANDE, et seulement à la demande. Deux raisons, chacune suffisante :
#
# 1. Ce workflow ne PEUT PAS aboutir sans un humain : l'auth Vault passe par
# un flux OIDC dont le lien doit être ouvert dans un navigateur connecté.
# Déclenché tout seul, il occupe un runner jusqu'à son timeout — et retarde
# les runs que quelqu'un attend vraiment.
# 2. Il fait `terraform apply` en `auto_approve` CONTRE LA PROD. Se déclencher
# sur le push d'une branche, c'est appliquer du code que personne n'a relu.
#
# Au passage : `push` (toutes branches) + `pull_request` faisait partir DEUX runs
# par commit d'une branche en PR — le même SHA, deux fois.
on:
workflow_dispatch: {}
push:
paths:
- 'minio/**/*.tf'
pull_request:
paths:
- 'minio/**/*.tf'
# cancel any previously-started, yet still active runs of this workflow on the same branch
concurrency:
+7 -5
View File
@@ -2,12 +2,14 @@
# template source: https://github.com/bretfisher/docker-build-workflow/blob/main/templates/call-docker-build.yaml
name: Plausible
on: #[push,pull_request]
# À LA DEMANDE, et seulement à la demande — comme minio.yaml : auth Vault par
# flux OIDC (un humain doit ouvrir un lien) et apply `auto_approve` contre la prod.
#
# Note : les triggers `push`/`pull_request` retirés ici étaient de toute façon
# INERTES (ancre YAML non résolue par Gitea, issues 113 → 117 de kadans). Ce
# workflow ne partait déjà qu'à la main ; c'est maintenant écrit.
on:
workflow_dispatch: {}
push: &plausiblePaths
paths:
- 'plausible/**/*.tf'
pull_request: *plausiblePaths
# cancel any previously-started, yet still active runs of this workflow on the same branch
concurrency:
+10 -15
View File
@@ -2,23 +2,18 @@
# template source: https://github.com/bretfisher/docker-build-workflow/blob/main/templates/call-docker-build.yaml
name: Hashicorp Vault
# ⚠ Triggers écrits EN TOUTES LETTRES, sans ancre YAML (`&`/`*`) : une ancre
# dans un trigger Gitea Actions fait taire push ET pull_request, en silence
# (vécu sur arcodange/kadans, issues 113 → 117) — c'est probablement pourquoi
# ce workflow ne partait qu'à la main.
# Et `*.tfvars` compte AUTANT que `*.tf` : la liste des applications (donc les
# rôles gitea_cicd_<app>) vit dans terraform.tfvars — l'oublier, c'est ajouter
# une app sans jamais créer son rôle.
# À LA DEMANDE, et seulement à la demande — comme minio.yaml, et pour les mêmes
# deux raisons : l'auth Vault exige qu'un humain ouvre un lien OIDC (sans lui, le
# run squatte un runner jusqu'au timeout), et l'apply se fait en `auto_approve`
# contre la prod.
#
# ⚠ Ce qui change AUSSI de nature : `hashicorp-vault/**/*.tfvars` compte autant
# que `*.tf` — la liste des applications (donc les rôles gitea_cicd_<app>) vit
# dans terraform.tfvars. Ce n'est plus un filtre de chemins mais ça reste vrai
# du POURQUOI on relance : ajouter une app au tfvars sans relancer ce workflow,
# c'est une app sans rôle CI.
on:
workflow_dispatch: {}
push:
paths:
- 'hashicorp-vault/**/*.tf'
- 'hashicorp-vault/**/*.tfvars'
pull_request:
paths:
- 'hashicorp-vault/**/*.tf'
- 'hashicorp-vault/**/*.tfvars'
# cancel any previously-started, yet still active runs of this workflow on the same branch
concurrency:
+7
View File
@@ -8,6 +8,13 @@ pour chaque dossier de premier niveau contenant un fichier Chart.yaml (sauf les
le pousser dans le registre helm de gitea
```
## Réseau
- [Ce que Traefik voit du client](doc/ce-que-traefik-voit.md) — pourquoi
`ClientIP()` ne voit que le tunnel sur les hôtes `.fr`, pourquoi `localIp@file`
n'a rien à y faire, et comment obtenir « pas de mot de passe depuis la maison »
sans créer un contournement d'authentification.
## pgbouncer
## prometheus
+224
View File
@@ -0,0 +1,224 @@
# Ce que Traefik voit du client — et pourquoi « pas de mot de passe à la maison » ne se règle pas dans Traefik
Mesuré le 2026-07-28 sur le homelab (`kubectl --context=default`, k3s pi1/pi2/pi3).
Ce document existe parce que la question « peut-on sauter le basic auth quand on est
sur le Wi-Fi de la maison ? » a une réponse contre-intuitive, et que la réponse
intuitive est un **trou de sécurité**.
## 1. Le chemin réel d'une requête `.fr`
```
navigateur (maison)
└─ DNS : kadans.arcodange.fr → 172.67.130.8 / 104.21.3.16 (Cloudflare, PAS le LAN)
└─ edge Cloudflare (proxy orange)
└─ tunnel cloudflared (sortant, pod du cluster, CIDR 10.42.0.0/16)
└─ traefik.kube-system.svc:80 (entrypoint `web`, pas de TLS ici)
└─ crowdsec → basic auth → service
```
Le tunnel est **sortant** : il n'y a aucune redirection de port, aucun chemin
depuis Internet vers Traefik autre que Cloudflare. Le nom `.fr` est un CNAME
proxifié vers `<tunnel-id>.cfargotunnel.com` (`cms/cloudflare`, module
`cloudflared_tunnel`, mapping unique `*.arcodange.fr`).
**Conséquence n°1 : même depuis le salon, la requête sort de la maison, fait un
aller-retour par Cloudflare, et revient par le tunnel.** L'adresse de la socket
que Traefik accepte est toujours celle d'un pod `cloudflared` en `10.42.x.x`.
Le `.lab`, lui, va droit au but : `kadans.arcodange.lab → 192.168.1.201`.
## 2. Deux notions d'« IP client », et elles ne coïncident pas
L'entrypoint `web` fait confiance aux en-têtes transmis depuis le CIDR des pods
(`ports.web.forwardedHeaders.trustedIPs: ["10.42.0.0/16"]`, playbook
`factory/.../playbooks/system/k3s_config.yml`). Traefik **sait donc** qui est le
vrai client — le journal d'accès le prouve, sur une requête émise depuis la maison :
```
2a01:cb04:dff:cf00:f9d7:4026:9ffc:b042 - kadans [28/Jul/2026:14:22:49 +0200]
"GET /?sonde=… HTTP/1.1" 200 … "kadans-kadans-public-kadans-arcodange-fr@kubernetes"
```
Deux choses à retenir de cette ligne :
- Traefik **résout** le vrai client depuis `X-Forwarded-For` ;
- la maison sort en **IPv6** (`2a01:cb04:dff:cf00::/64`), pas en IPv4. Le
`86.238.234.54/32` que le playbook injecte dans `localIp` via `ipify` n'est
qu'**une** des deux adresses publiques du foyer, et pas celle qu'un navigateur
moderne utilise en priorité.
Mais **le matcher de routeur `ClientIP()` n'utilise PAS cette résolution.** Il
juge l'adresse de la socket. Mesuré, pas supposé — trois routeurs temporaires
posés sur `kadans.arcodange.fr`, pointant vers un Service sans endpoint (donc
`503` si la règle matche, et repli sur le routeur normal — `401` — sinon) :
| sonde | règle ajoutée à `Host(kadans.arcodange.fr)` | attendu si… | mesuré |
| ----- | -------------------------------------------- | ------------------------------ | ------ |
| T | *(témoin, aucune contrainte d'IP)* | le dispositif fonctionne → 503 | **503** |
| A | `ClientIP("2a01:cb04:dff:cf00::/64")` | `ClientIP` lit X-Forwarded-For | **401** |
| B | `ClientIP("10.42.0.0/16")` | `ClientIP` lit la socket | **503** |
Le témoin valide le montage ; A échoue, B réussit. **`ClientIP()` voit le pod
`cloudflared`, jamais le vrai client.** (Sondes supprimées après mesure.)
## 3. Les deux pièges qui en découlent
### Piège A — `localIp@file` ne doit JAMAIS toucher un routeur `.fr`
`localIp` est un `ipAllowList` dont le `sourceRange` contient `10.42.0.0/16`
(le CIDR des pods). Comme `ipAllowList` juge lui aussi l'adresse de la socket
par défaut, **l'attacher à un hôte `.fr` laisserait passer Internet entier** :
toute requête arrivant par le tunnel se présente en `10.42.x.x`, donc « locale ».
État vérifié le 2026-07-28 : aucun routeur `.fr` ne porte `localIp@file`. Le
piège est **latent**, pas ouvert. Il le reste tant que personne ne recopie
`localIp@file` depuis un values `.lab` (grafana, vault, plausible) vers un
`ingress-public.yaml`. Les deux gabarits `.fr` de ce dépôt portent un
avertissement à cet endroit précis.
### Piège B — se fier à un en-tête est un contournement d'authentification
« Sauter le basic auth si `CF-Connecting-IP` est celle de la maison » (ou si un
en-tête maison est présent) fait reposer une **exemption d'authentification** sur
une valeur que le client écrit lui-même. Cloudflare ne retire pas les en-têtes
inconnus : n'importe qui sur Internet peut les envoyer. Et Traefik reste joignable
en direct sur le LAN (`192.168.1.201:80`). **On ne prend pas cette voie.**
## 4. Pourquoi ce n'est pas réparable dans Traefik
Traefik enchaîne ses middlewares **inconditionnellement** : il n'existe pas de
« basic auth sauf si … ». Le seul moyen de sauter un middleware est de faire
matcher un **autre routeur** — et le seul discriminant d'IP au niveau routeur est
`ClientIP()`, qui, on vient de le mesurer, ne voit que le tunnel. Un
`ipAllowList` avec `ipStrategy.depth` verrait bien la vraie IP, mais un
`ipAllowList` **bloque** ; il ne sait pas « laisser entrer sans mot de passe ».
Faire pointer le LAN vers Traefik (DNS à double horizon, ou enregistrement
Cloudflare « DNS only ») rendrait `ClientIP(192.168.1.0/24)` honnête — mais
coûterait, en plus du DNS : un **certificat publiquement valide** pour
`kadans.arcodange.fr` sur Traefik (aujourd'hui il sert la CA interne,
`CN=arcodange.lab` ; un `curl --resolve … 192.168.1.201` échoue au TLS, code
`000`), donc un nouveau `certResolver` DNS-01 Cloudflare et le jeton qui va avec,
plus un routeur `websecure` pour un hôte qui vit aujourd'hui en `web` clair.
Beaucoup, pour du confort. Et « DNS only » retirerait en prime le bouclier
Cloudflare de cet hôte.
**La décision doit donc être prise là où la vraie IP est native et non
falsifiable : au bord, chez Cloudflare (`ip.src`).**
## 5. La voie retenue — Cloudflare tape le mot de passe à ta place
Le basic auth de `kadans.arcodange.fr` est un **garde-fou assumé**, pas un
secret : `kadans:kkadans`, et son hash bcrypt est en clair dans
`chart/values.yaml` du dépôt `kadans`. C'est ce fait qui rend la solution
simple **et** sans coût de sécurité :
> Une règle Cloudflare pré-remplit l'en-tête `Authorization` pour les requêtes
> venant du réseau de la maison. Traefik ne change pas, le mot de passe reste
> exigé partout ailleurs. Cloudflare ne fait que le **taper à ta place**.
Surface d'attaque ajoutée : **aucune**. Un attaquant qui forge cet en-tête
obtient exactement ce que `curl -u kadans:kkadans` lui donne déjà aujourd'hui.
C'est la différence de fond avec le piège B : ici on ne crée pas d'exemption, on
automatise une saisie.
Et quand l'IP du foyer change (elle est dynamique), la règle cesse simplement de
matcher : **le navigateur redemande le mot de passe**. Dégradation douce, jamais
de panne.
### Action manuelle du fondateur (à faire une fois)
Cloudflare → zone `arcodange.fr`**Rules****Transform Rules** → **Modify
Request Header** → *Create rule* :
- **Nom** : `kadans — confort LAN : Authorization pré-remplie depuis la maison`
- **When incoming requests match** (éditeur d'expression) :
```
(http.host eq "kadans.arcodange.fr" and ip.src in {86.238.234.54 2a01:cb04:dff:cf00::/64})
```
- **Then** → *Set static* :
- Header name : `Authorization`
- Value : `Basic a2FkYW5zOmtrYWRhbnM=` *(base64 de `kadans:kkadans`)*
Équivalent Terraform, si on préfère le déclarer dans `cms/cloudflare` :
```hcl
resource "cloudflare_ruleset" "confort_lan_kadans" {
zone_id = cloudflare_zone.arcodange_fr.id
name = "confort LAN"
kind = "zone"
phase = "http_request_late_transform"
rules = [{
expression = "(http.host eq \"kadans.arcodange.fr\" and ip.src in {86.238.234.54 2a01:cb04:dff:cf00::/64})"
description = "Depuis la maison, Cloudflare tape le garde-fou basic auth a notre place"
action = "rewrite"
action_parameters = {
headers = {
"Authorization" = { operation = "set", value = "Basic a2FkYW5zOmtrYWRhbnM=" }
}
}
}]
}
```
### Vérifier que ça marche
```bash
# depuis la maison — attendu : 200, SANS -u
curl -s -o /dev/null -w '%{http_code}\n' https://kadans.arcodange.fr/
# depuis l'extérieur (partage de connexion du téléphone) — attendu : 401
curl -s -o /dev/null -w '%{http_code}\n' https://kadans.arcodange.fr/
```
### Ce qu'il faudra surveiller
- **Les deux adresses du foyer sont dynamiques.** Le préfixe IPv6 relevé le
2026-07-28 est `2a01:cb04:dff:cf00::/64` et l'IPv4 `86.238.234.54` — la même
que celle qu'`ipify` injecte dans `localIp` à chaque passage du playbook
`k3s_config.yml`. S'il dérive, élargir en `/56` avant de soupçonner Cloudflare.
- ⚠ **Non vérifié en vrai** : que Cloudflare accepte `Authorization` comme
en-tête de requête modifiable (certains en-têtes sont réservés). Si l'éditeur
refuse la règle, le repli est **Cloudflare Access** avec une politique
*Bypass* sur les mêmes IP et une politique *Allow* par e-mail ailleurs — et le
basic auth de Traefik est alors retiré. Le repli n'est **pas** un en-tête
secret (piège B).
### La version encore plus simple, si le confort ne vaut pas la règle
Le mot de passe est public. Ce qu'il achète réellement, c'est « le produit en
chantier n'est pas indexé ni tombé dessus par hasard ». Un `robots.txt` +
`X-Robots-Tag: noindex` fait ce travail-là sans mot de passe du tout. Retirer le
garde-fou est une décision produit, pas une décision d'infra : elle n'est pas
prise ici, seulement nommée.
## 6. Ce qui a été vérifié et qui va bien
Le bouncer CrowdSec, lui, **juge la vraie IP** — contrairement à `ClientIP()`.
Journal Traefik sur une requête de la maison et sur des robots :
```
CrowdsecBouncerTraefikPlugin: ServeHTTP ip:2a01:cb04:dff:cf00:f9d7:4026:9ffc:b042 isTrusted:false
CrowdsecBouncerTraefikPlugin: ServeHTTP ip:144.24.58.222 isTrusted:false
```
Son `clientTrustedIPs` contient pourtant `10.42.0.0/16` : ce n'est **pas** un
trou, parce que le plugin ne compare pas cette liste à l'adresse de la socket
mais à l'IP résolue. La convention `.fr` (« crowdsec devant tout ») tient donc
ce qu'elle promet. Écrit ici pour que personne ne « corrige » cette ligne en
croyant reproduire le piège A.
---
**Où vit quoi** (aucun de ces réglages n'est dans ce dépôt — vérifié) :
| réglage | dépôt / fichier |
| ------------------------------------------- | ------------------------------------------------------------------------ |
| valeurs Helm Traefik, `dynamic.yaml`, `localIp` | `factory` — `ansible/arcodange/factory/playbooks/system/k3s_config.yml` |
| Middleware `crowdsec@kube-system` | `factory` — `.../playbooks/tools/roles/crowdsec/tasks/main.yml` |
| tunnel + DNS `*.arcodange.fr` | `cms` — `cloudflare/iac.tf`, `cloudflare/modules/cloudflared_tunnel/` |
| basic auth `.fr` de kadans | `kadans` — `chart/values.yaml` (`public.basicAuth`) |
| gabarits d'exposition `.fr` de ce dépôt | `grafana/templates/ingress-public.yaml`, `minio/templates/ingress-public.yaml` |
+6
View File
@@ -10,6 +10,12 @@
# NB : ce chart grafana est en mode `tool.kind: SubChart`, donc les templates
# helm-chart*.yaml ne rendent rien ; ce fichier, lui, est rendu tel quel et
# appliqué par ArgoCD (app `grafana`, destination namespace `tools`).
#
# ⚠ NE JAMAIS ajouter `localIp@file` ici (ni sur aucun routeur `.fr`). Le trafic
# `.fr` arrive par le tunnel cloudflared, donc avec l'adresse d'un pod en
# 10.42.x.x — or `10.42.0.0/16` est dans le `sourceRange` de `localIp`, et
# `ipAllowList` juge l'adresse de la SOCKET. Le middleware « IP locale
# seulement » laisserait donc entrer Internet entier. Mesuré : doc/ce-que-traefik-voit.md §3.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
+2
View File
@@ -285,6 +285,8 @@ grafana: &grafana_config
traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
traefik.ingress.kubernetes.io/router.tls.domains.0.main: arcodange.lab
traefik.ingress.kubernetes.io/router.tls.domains.0.sans: grafana.arcodange.lab
# ⚠ Valable parce que cet hôte est en `.lab` (le client arrive en direct sur
# 192.168.1.201). À NE PAS recopier sur un hôte `.fr` : cf. doc/ce-que-traefik-voit.md §3.
traefik.ingress.kubernetes.io/router.middlewares: localIp@file
hosts:
- grafana.arcodange.lab
+1 -1
View File
@@ -1,5 +1,5 @@
locals {
factory_crowdsec_conf_sa_name = "factory-ansible-tool-crowdsec-traefik-plugin"
factory_crowdsec_conf_sa_name = "factory-ansible-tool-crowdsec-traefik-plugin"
}
@@ -36,6 +36,13 @@ data "vault_policy_document" "ops" {
path = "kvv1/google/credentials"
capabilities = ["read"]
}
# Provisionneur MinIO l'app crée ses buckets depuis son dépôt sans le root.
# ADR : factory/doc/adr/20260726-stockage-objet-minio.md
rule {
path = "kvv2/data/minio/provisioner"
capabilities = ["read"]
}
# read cloudflare related secrets
rule {
path = "kvv1/cloudflare/${local.name}*"
@@ -178,6 +185,19 @@ data "vault_policy_document" "app" {
path = "postgres/creds/${local.name}*"
capabilities = ["read"]
}
# Ses identifiants MinIO. INCONDITIONNEL : le chemin porte le nom de l'app,
# donc la règle ne peut exposer que ses propres clés ; une app sans stockage
# lit un chemin qui n'existe pas.
# ADR : factory/doc/adr/20260726-stockage-objet-minio.md
rule {
path = "kvv2/data/minio/${local.name}/*"
capabilities = ["read", "list"]
}
rule {
# Le document lui-même (la règle ci-dessus ne couvre que ses descendants).
path = "kvv2/data/minio/${local.name}"
capabilities = ["read", "list"]
}
# Extra shared paths this app's prod runtime may read (e.g. backup creds).
dynamic "rule" {
for_each = var.kv_read_paths
@@ -204,6 +224,15 @@ data "vault_policy_document" "app_non_prod" {
path = "postgres/creds/${each.key}*"
capabilities = ["read"]
}
# Idem prod : chaque instance lit les identifiants MinIO portant SON nom.
rule {
path = "kvv2/data/minio/${each.key}/*"
capabilities = ["read", "list"]
}
rule {
path = "kvv2/data/minio/${each.key}"
capabilities = ["read", "list"]
}
}
resource "vault_policy" "app_non_prod" {
for_each = toset(local.non_prod_instances)
@@ -30,9 +30,36 @@ resource "vault_database_secret_backend_role" "role" {
backend = local.vault_mount_postgres.path
name = local.instance
db_name = "postgres"
# Le rôle ÉPHÉMÈRE endosse le rôle STABLE, dès le login
#
# En PostgreSQL, un objet appartient au rôle qui l'a CRÉÉ. Comme chaque
# démarrage de pod obtient un rôle `v-kubernet-` neuf, toute migration crée
# des objets que le pod SUIVANT ne peut plus lire. C'est ce qui a mis l'API
# kadans à terre une demi-journée le 2026-07-28 (« permission denied for table
# qualification_video »), et c'est ce que le CronJob `pg-fix-table-ownership`
# rattrape tous les jours à 03:00 a posteriori, et pour les seules TABLES.
#
# `ALTER ROLE SET ROLE` fait de l'endossement un DÉFAUT DE CONNEXION : plus
# rien à poser côté application, et ça vaut aussi pour les clients qui ne sont
# pas l'application (le `psql` d'un job, une console d'exploitation).
#
# AUCUN privilège nouveau : le `GRANT` de la ligne précédente rend déjà le
# rôle éphémère MEMBRE du rôle stable. Endosser une casquette qu'on porte
# déjà, ce n'est pas une élévation et c'est pourquoi cette instruction ne
# peut pas échouer le `GRANT` réussit.
#
# MESURÉ (PostgreSQL 16, compte CREATEROLE non-superutilisateur, comme celui
# de Vault) : l'instruction passe, le login donne `session_user=v-test-1` /
# `current_role=proprio_v`, et un `ALTER ROLE RESET role` la retire.
# Vérifié aussi qu'elle SURVIT à `RESET ALL` / `DISCARD ALL` donc elle
# compose avec le `server_reset_query` de pgbouncer au lieu de s'y opposer.
#
# Ne vaut que pour les identifiants créés APRÈS l'apply : les baux en cours
# gardent leur ancien comportement jusqu'à leur renouvellement.
creation_statements = [
"CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';",
"GRANT ${local.owner_role} TO \"{{name}}\";",
"ALTER ROLE \"{{name}}\" SET ROLE ${local.owner_role};",
]
revocation_statements = [
"REASSIGN OWNED BY \"{{name}}\" TO ${local.owner_role};", # reassign must be executed in the database where the reassgined objects are - TODO (one connection per database/app)
+2
View File
@@ -17,6 +17,8 @@ vault: &vault_config
traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
traefik.ingress.kubernetes.io/router.tls.domains.0.main: arcodange.lab
traefik.ingress.kubernetes.io/router.tls.domains.0.sans: vault.arcodange.lab
# ⚠ Valable parce que cet hôte est en `.lab` (le client arrive en direct sur
# 192.168.1.201). À NE PAS recopier sur un hôte `.fr` : cf. doc/ce-que-traefik-voit.md §3.
traefik.ingress.kubernetes.io/router.middlewares: localIp@file
hosts:
- host: vault.arcodange.lab
+116 -2
View File
@@ -23,9 +23,9 @@ application vivent avec cette application.
| 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` (Traefik) |
| 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) |
| Bucket | `kadans-videos`, **privé** l'accès passe par des URL signées (ADR-0002 du dossier produit) |
| 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
@@ -52,3 +52,117 @@ L'ordre compte, et il compte **deux fois** :
> [!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.
### 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 :
```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 }
}
```
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.
+100
View File
@@ -0,0 +1,100 @@
# `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/<app>` 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 ;
- 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
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 **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.
+99
View File
@@ -0,0 +1,99 @@
# Module `minio_app` une app déclare SES buckets depuis SON dépôt.
# Décisions : factory/doc/adr/20260726-stockage-objet-minio.md
resource "minio_s3_bucket" "app" {
for_each = toset(var.buckets)
bucket = each.key
acl = "private" # l'accès passe par des URL présignées
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({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
Resource = [for b in var.buckets : "arn:aws:s3:::${b}/*"]
},
{
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"
secret = random_password.app.result
force_destroy = false # une rotation ne recrée pas l'utilisateur : les objets gardent leur propriétaire
}
resource "minio_iam_user_policy_attachment" "app" {
user_name = minio_iam_user.app.id
policy_name = minio_iam_policy.app.id
}
# Lu par le pod de l'app `app_policy` lui accorde déjà ce chemin.
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
})
}
+9
View File
@@ -0,0 +1,9 @@
output "vault_path" {
value = "kvv2/minio/${var.app}"
description = "Où le pod lira ses identifiants (VaultStaticSecret)."
}
output "buckets" {
value = var.buckets
description = "Écho des buckets créés."
}
+21
View File
@@ -0,0 +1,21 @@
terraform {
required_providers {
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 = {
source = "hashicorp/vault"
}
random = {
source = "hashicorp/random"
}
}
}
+15
View File
@@ -0,0 +1,15 @@
variable "app" {
type = string
description = "Nom de l'app (= son rôle Vault). Décide du chemin du secret et du nom du compte de service."
}
variable "buckets" {
type = list(string)
description = "Ses buckets, créés ici (privés). Le compte de service n'a de droits que sur eux."
}
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."
}
+16
View File
@@ -4,6 +4,14 @@ terraform {
source = "vault"
version = "4.4.0"
}
minio = {
source = "aminueza/minio"
version = "3.3.0"
}
random = {
source = "hashicorp/random"
version = "3.6.3"
}
}
}
@@ -14,3 +22,11 @@ provider "vault" {
role = "gitea_cicd_minio"
}
}
# Provider MinIO crée le compte de provisionnement (provisioner.tf).
provider "minio" {
minio_server = var.minio_endpoint
minio_user = local.config.rootUser
minio_password = local.config.rootPassword
minio_ssl = true
}
+69
View File
@@ -0,0 +1,69 @@
# Compte de PROVISIONNEMENT : crée buckets, politiques et comptes de service
# aucun droit sur les objets. C'est lui que lisent les rôles CI des apps, pour
# qu'elles déclarent leurs buckets sans qu'on leur confie le root.
# Décisions : factory/doc/adr/20260726-stockage-objet-minio.md
#
# Noms d'actions confirmés par le premier apply réel (kadans, 2026-07-26) : le
# bloc admin passe tel quel ; côté s3 il manquait `s3:ListBucket`, que le
# provider appelle AVANT de créer un bucket pour savoir s'il existe déjà.
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:::*"]
},
{
# s3:GetObject / s3:PutObject volontairement ABSENTS : le provisionneur
# ne LIT ni n'ÉCRIT aucun objet, c'est ce qui rend son partage entre
# rôles CI acceptable.
#
# `s3:ListBucket` est la seule concession : MinIO le demande pour un
# HeadBucket, et le provider teste l'existence du bucket avant de le
# créer. Il donne la vue des CLÉS d'un bucket, jamais leur contenu.
Effect = "Allow"
Action = ["s3:CreateBucket", "s3:DeleteBucket", "s3:ListBucket", "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
}
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
})
}
+5
View File
@@ -0,0 +1,5 @@
variable "minio_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."
}
+33
View File
@@ -0,0 +1,33 @@
# Exposition PUBLIQUE s3.arcodange.fr — TLS terminé par le tunnel Cloudflare,
# middleware crowdsec. Le `.lab` interne reste inchangé.
#
# ⚠ PAS de basic-auth ici, contrairement à kadans-public : une requête S3 porte
# sa propre signature (SigV4), et un défi HTTP Basic casserait le PUT présigné
# auquel le navigateur ne peut pas répondre.
#
# ⚠ Et PAS de `localIp@file` non plus : sur un routeur `.fr`, ce middleware
# laisserait entrer Internet entier (le tunnel se présente en 10.42.x.x, qui est
# dans son `sourceRange`). Mesuré : doc/ce-que-traefik-voit.md §3.
#
# ADR : factory/doc/adr/20260726-stockage-objet-minio.md
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: minio-public
namespace: tools
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web
traefik.ingress.kubernetes.io/router.middlewares: kube-system-crowdsec@kubernetescrd
spec:
ingressClassName: traefik
rules:
- host: s3.arcodange.fr
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: minio
port:
number: 9000
+7 -5
View File
@@ -61,11 +61,10 @@ 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.
buckets:
- name: kadans-videos
policy: none # privé : l'accès passe par des URL signées (ADR-0002 du dossier)
purge: false
versioning: false
# AUCUN bucket ici : chaque app déclare les siens depuis son dépôt, via le
# module `iac/modules/minio_app`.
# ADR : factory/doc/adr/20260726-stockage-objet-minio.md
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.
@@ -74,6 +73,9 @@ minio: &minio_config
enabled: false # pas d'opérateur Prometheus ici : scrape par annotation
environment:
MINIO_PROMETHEUS_AUTH_TYPE: "public"
# CORS : le navigateur téléverse directement (URL présignées) — origines
# EXACTES, jamais « * » : une URL qui fuite serait sinon rejouable partout.
MINIO_API_CORS_ALLOW_ORIGIN: "https://kadans.arcodange.fr,https://kadans.arcodange.lab"
tool:
# kind: 'SubChart' or 'HelmChart', if subchart then uncomment Chart.yaml dependency, else comment and use tool library with helm chart template
+57 -1
View File
@@ -14,7 +14,63 @@ pgbouncer: &pgbouncer_config
auth_type: scram-sha-256
auth_query: SELECT uname, phash FROM user_lookup($1)
ignore_startup_parameters: extra_float_digits # unsupported jdbc extra_float_digits=2 argument
server_reset_query: DEALLOCATE ALL # fix prepared statement already exist (crowdsec)
# Ce pgbouncer est PARTAGÉ (crowdsec, plausible, kadans, + le compte que
# Vault utilise sur la base `postgres`). Ce qu'un client laisse derrière
# lui sur une connexion serveur, le client SUIVANT en hérite : le reset
# est la SEULE barrière entre deux clients d'un même pool.
#
# `DEALLOCATE ALL` ne nettoie QUE les requêtes préparées — d'où sa mise en
# place (07e2c6d, « prepared statement already exists » de crowdsec).
# Tout le reste de l'état de session passait au suivant. MESURÉ contre un
# pgbouncer **1.23.1** — la version RÉELLEMENT déployée (image
# ghcr.io/icoretech/pgbouncer-docker:1.23.1-fixed, chart pgbouncer-2.3.1),
# montée avec CETTE configuration extraite du cluster (session,
# pool_size=1) : le client 2, qui n'avait rien demandé, héritait de
# `work_mem=17MB`, du `LISTEN canal_test` du client 1, de sa table TEMP —
# et de son `SET ROLE`, au point de créer des tables appartenant à un
# autre rôle que le sien.
#
# Portée de la fuite, mesurée et non supposée : les pools sont partitionnés
# par (base, utilisateur) — 210 pools, aucun ne mélange deux bases ni deux
# comptes. Le seul héritier possible d'un `SET ROLE` posé par kadans-api
# est un client de la base `kadans` avec le MÊME identifiant éphémère,
# c'est-à-dire kadans-api elle-même. Défaut d'hygiène, pas brèche
# inter-applications.
#
# `DISCARD ALL` est le défaut de pgbouncer, et c'est un SUR-ENSEMBLE strict
# des deux valeurs qui l'ont précédé ici : il contient `DEALLOCATE ALL`
# (donc le correctif crowdsec est conservé — vérifié : deux clients
# successifs préparent le même nom sans erreur) ET
# `SELECT pg_advisory_unlock_all()` (la valeur que pose le sous-chart).
#
# Il ne peut RIEN casser pour un client vivant : en `pool_mode = session`
# la connexion serveur n'est rendue qu'à la déconnexion du client, donc le
# reset ne court jamais entre deux requêtes d'une même session. Vérifié :
# table TEMP, `SET work_mem` et `LISTEN` d'un client VIVANT survivent.
#
# ⚠⚠ CE QUE CETTE LIGNE REND FRAGILE — et c'est l'inverse de ce qu'on croit.
#
# `pool_mode` n'est PAS déclaré ici, donc il vaut `session`, le défaut.
# C'est ce qui rend `DISCARD ALL` sans danger. **Le jour où quelqu'un
# écrira `pool_mode: transaction`, il cassera kadans-api en silence** :
# son `SET ROLE` est posé UNE FOIS à l'ouverture (`AfterConnect`, db.go),
# et en transaction pooling `DISCARD ALL` court ENTRE deux transactions —
# donc le rôle est effacé avant les migrations suivantes. MESURÉ, les
# quatre combinaisons, propriétaire de la table créée :
#
# session + DEALLOCATE ALL → rôle stable ✅ (mais la fuite reste)
# session + DISCARD ALL → rôle stable ✅ ← ce qu'on déploie
# transaction + DEALLOCATE ALL → rôle stable ✅ (par ACCIDENT : la fuite
# qu'on referme est ce qui le sauvait)
# transaction + DISCARD ALL → rôle ÉPHÉMÈRE ❌ le défaut du 28/07,
# qui a mis l'API à terre une demi-journée
#
# Et `poserRoleProprietaire` ne peut pas le voir : sa relecture de
# `current_role` a lieu à l'ouverture, où le rôle est encore correct.
# Ce qui couvre ce cas, c'est la ceinture Vault (`ALTER ROLE … SET ROLE`,
# module app_roles) : un défaut de rôle survit à `DISCARD ALL`. **Avant de
# passer en transaction pooling, vérifier que les baux Vault ont tourné.**
server_reset_query: DISCARD ALL # défaut pgbouncer — ⚠ ne pas réduire : voir ci-dessus
server_idle_timeout: 7200
pgbouncerExporter:
enabled: false
+2
View File
@@ -61,6 +61,8 @@ ingressRoute:
rule: Host(`analytics.arcodange.lab`)
# -- List of [middleware objects](https://doc.traefik.io/traefik/routing/providers/kubernetes-crd/#kind-middleware) for the ingress route.
middlewares:
# ⚠ Valable parce que la règle ci-dessus est en `.lab` (le client arrive en direct
# sur 192.168.1.201). À NE PAS recopier sur un hôte `.fr` : cf. doc/ce-que-traefik-voit.md §3.
- name: localIp@file
# -- Use an existing secret containing the TLS certificate.
tlsSecretName: ''
+30 -7
View File
@@ -140,13 +140,36 @@ redis: &redis_config
runAsUser: 999
# -- Compute resources used by the container. More info [here](https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/).
resources: {}
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
#
# ⚠ CE N'EST PAS DU CONFORT — c'est ce qui empêche Redis de mourir en boucle.
#
# Le sous-chart FIGE ses sondes à `timeoutSeconds: 1` sur `redis-cli ping`, et
# n'expose aucune valeur pour les surcharger. Sur ce matériel (Raspberry Pi),
# un conteneur SANS réservation tombe dans la classe QoS `BestEffort` : il est
# le premier affamé quand le nœud est chargé, et le simple lancement de
# `redis-cli` y dépasse la seconde.
#
# Mesuré le 2026-07-29 sur `redis-0` : « Liveness probe failed: command timed
# out: "redis-cli ping" timed out after 1s » **836 fois en 23 jours**, 135
# redémarrages, le conteneur tué par SIGTERM après ~90 s de vie à chaque tour.
#
# ⚠ CE QUE ÇA CASSAIT, ET QUI N'AVAIT RIEN À VOIR AVEC REDIS EN APPARENCE : le
# plugin crowdsec de Traefik utilise ce Redis comme cache de décisions. Cache
# injoignable ⇒ `isCrowdsecStreamHealthy:false` ⇒ le plugin REFUSE PAR DÉFAUT
# tout client non listé dans `clientTrustedIPs` ⇒ **403, corps vide, sur
# `kadans.arcodange.fr` et `gitea.arcodange.fr`**, pendant que les hôtes qui ne
# portent pas ce middleware répondaient normalement. Le symptôme ne nomme ni
# Redis, ni crowdsec, ni la sonde — il ressemble à un bannissement d'IP, et
# c'est par là qu'on cherche d'abord.
#
# Une réservation fait passer le conteneur en `Burstable` et lui garantit sa
# part. Les limites restent larges : Redis n'est pas ce qui sature ce nœud.
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi
# -- Pod-level affinity. More info [here](https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling).
affinity: {}