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
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
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
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
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
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
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
« 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
« 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
« 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
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
« 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
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
CI en échec sur le workflow MinIO : role "gitea_cicd_minio" could not be found.
Diagnostic : les rôles CI gitea_cicd_<app> ne naissent PAS dans l'IaC de
l'application — ils viennent du module app_policy, appliqué centralement par
hashicorp-vault/iac pour chaque entrée de terraform.tfvars. J'avais posé
minio/iac (qui s'authentifie AVEC ce rôle) sans alimenter la liste : le run
tentait donc de s'authentifier avec un rôle que personne n'avait créé. Amorçage
circulaire, entièrement de mon fait.
- hashicorp-vault/iac/terraform.tfvars : minio ajouté aux applications, avec
service_account_namespaces = ["tools"] comme crowdsec et plausible ;
- .gitea/workflows/vault.yaml : les triggers couvrent désormais *.tfvars en
plus de *.tf. C'est là que vit la liste des applications : sans ça, ajouter
une app ne déclenchait jamais la création de son rôle — le piège qui vient
de mordre. Les ancres YAML des triggers sont retirées au passage (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) ;
- minio/README.md : l'ordre de mise en service dit maintenant les DEUX étapes
et nomme l'erreur exacte à laquelle on s'expose en les inversant.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CoafGWmRVESaWX819USUUA
Le fondateur : « on déploie MinIO dans le repo tools du homelab non ? » — oui,
c'est bien le pattern : le dossier tools/ porte les briques PARTAGÉES
(pgbouncer, clickhouse, grafana…) et le namespace tools les fait tourner ; les
charts applicatifs vivent dans le repo de leur app.
Décidé de longue date côté produit, jamais déployé : ADR-012 « MinIO local
d'abord » (bascule R2 à 100+ utilisateurs / 10 To par mois), ADR-013 (le gratuit
reste local-first, MinIO sert les paliers payants). Vérifié avant d'écrire :
aucun pod ni service MinIO dans le cluster.
Le chart suit la recette du repo (dépendance à la library "tool" + chart amont
en SubChart, deux gardes dans templates/) :
- mode STANDALONE, 1 réplique : ce qui transite est DÉRIVÉ (le master d'une
vidéo reste sur l'appareil de son propriétaire, ADR-018) 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 ;
- 50 Gi sur longhorn ≈ 250 h de cours au palier « travail » (360p, 3,4 Mo/min) ;
⚠ Longhorn réplique : compter ×3 sur la capacité avant d'augmenter ;
- ressources bornées (512 Mi / 2 Gi) : la limite protège les voisins de tools ;
- API s3.arcodange.lab + console minio.arcodange.lab (Traefik) ;
- bucket kadans-videos PRIVÉ — l'accès passera par des URL signées (ADR-0002) ;
- identifiants JAMAIS au dépôt : iac/ les génère dans Vault (kvv2/minio/config),
le Vault Secrets Operator les matérialise, le chart les lit via existingSecret.
Le SA du pod est nommé "minio" (pas le "minio-sa" amont) car le module
app_roles borne l'authentification au SA portant le nom de l'app.
⚠ Le workflow minio.yaml écrit ses triggers EN TOUTES LETTRES : une ancre YAML
dans un trigger Gitea Actions fait taire push ET pull_request en silence (vécu
sur arcodange/kadans, issues 113→117). Les workflows plausible/crowdsec/vault de
ce repo en utilisent encore — à vérifier séparément, c'est probablement
pourquoi ils ne partent qu'à la main.
Vérifié : helm dependency update + helm template (11 ressources rendues, SA et
VaultAuth cohérents) + helm lint ✓.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CoafGWmRVESaWX819USUUA
Second Ingress (grafana-public) en PLUS du .lab, comme kadans :
- entrypoint `web` — TLS terminé en amont par le tunnel Cloudflare
(wildcard *.arcodange.fr → traefik:80, rien à changer côté cms/cloudflare) ;
- middleware crowdsec (convention .fr) ;
- pas de basic-auth : Grafana a sa propre authentification (aucun accès
anonyme dans grafana.ini), contrairement à kadans qui est ouvert.
Le .lab garde son localIp@file ; rendu par le wrapper SubChart et appliqué
par ArgoCD (précédent : prometheus/templates/vault-telegram.yaml).
Co-Authored-By: Claude Fable 5 <[email protected]>
- prometheus : cible statique kadans-worker-mac (192.168.1.103:9105, tier
laptop). Un scrape raté n'est PAS un incident : le Mac dort, up==0 raconte
la latence — ne pas alerter dessus. ⚠ réserver l'IP au routeur (DHCP).
- grafana : provider + dashboard « Kadans — jobs d'analyse » (uid
kadans-jobs-analyse) : worker en écoute / dernier poll / file pending + âge
du plus ancien (seuils 1 h / 24 h = le SLO), file par statut et publications
par issue (couleurs de STATUT sémantiques), lanes exécutées et durée moyenne
par lane. La façade, elle, arrive par les annotations de son pod
(kadans-jobs#3) — zéro config ici.
Co-Authored-By: Claude Fable 5 <[email protected]>
Les 4 premières règles n'auraient rien vu le matin du 2026-07-24 : nœuds up,
traefik up… mais wildcard 24 h expiré (renouvellement en échec silencieux
depuis des heures). certmanager_certificate_expiration_timestamp_seconds est
déjà scrapée (vérifié live : 2 séries). Cert-manager renouvelle à ~16 h d'âge ;
<4 h restantes = plusieurs cycles ratés → critical, 15 min de grâce.
Co-Authored-By: Claude Fable 5 <[email protected]>
Incident 2026-07-23 : un build CI sans limites a épuisé la RAM de pi1 (0 swap),
load15 >100, traefik + apiserver affamés → tout *.arcodange.lab injoignable,
Gitea compris (pourtant sain sur pi2). Personne n'est prévenu : on subit.
Quatre règles, livrées par la chaîne Telegram déjà en place (testée live) :
- NoeudInjoignable (critical) : node-exporter muet 3 min — nœud down ou noyé.
- IngressLabIndisponible (critical) : 0 replica traefik dispo, ou métrique
absente (kube-state-metrics vit sur pi1) — *.arcodange.lab est HS.
- NoeudPressionMemoire (warning) : <500 Mo dispo 5 min — le précurseur exact
de l'incident, déclenche avant le thrash.
- NoeudEnSurcharge (warning) : load15 >8 pendant 10 min.
Exprs validées contre le Prometheus live (parse + match) : pendant la rédaction,
IngressLabIndisponible et NoeudEnSurcharge matchaient l'incident en cours.
Prometheus (pi3) et Alertmanager (pi2) survivent à la perte de pi1.
Co-Authored-By: Claude Fable 5 <[email protected]>
ProspectionBriefNotSent (pushed==0, severity info) ne couvrait que la
livraison et déclenchait à tort quand l'étape brief était volontairement
sautée (metrics.py émet brief_rendered=0 aussi sur skip — seul
step_status{step="brief"} distingue skip(2) d'erreur(0)).
Remplacé par deux règles warning, gardées contre le skip :
- ProspectionBriefFailed : rendered==0 AND step_status{brief}!=2 → la
PRODUCTION de la vidéo a échoué (TTS/ffmpeg/PIL ou erreur amont) ;
- ProspectionBriefNotSent : pushed==0 AND rendered==1 → vidéo produite
mais PAS livrée sur Telegram (token/chat_id/API).
Expressions validées en live sur Prometheus (match on(job) prouvé par
requête témoin, les deux exprs vides sur l'état sain du jour). En-tête du
groupe mis à jour : la livraison AM→Telegram est câblée et testée.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 22:05:45 +02:00
37 changed files with 1410 additions and 41 deletions
# ── 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 là où 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)
ce qui transite est **dérivé** (aperçu 240p ~50 Ko, travail 360p ~3,4 Mo/min) —
le master reste chez l'utilisateur. D'où le dimensionnement ci-dessous.
## Ce que ce chart pose
| | |
|---|---|
| Mode | **standalone** (1 réplique) — la donnée est dérivée et Longhorn réplique déjà le volume ; l'erasure coding distribué coûterait de la RAM que des Pi 5 n'ont pas à dépenser pour ça |
| Volume | **50 Gi** sur `longhorn` ≈ **250 h de cours** au palier « travail ». ⚠ Longhorn réplique : compter **×3** sur la capacité du cluster avant d'augmenter |
| Ressources | requests 512 Mi / 100 m · limit 2 Gi — la limite protège les voisins de `tools`, pas MinIO |
| Buckets | **aucun ici** — chaque app déclare les siens depuis son dépôt (module `minio_app`). Tous privés : l'accès passe par des URL signées (ADR-0002 du dossier produit) |
| Identifiants | **jamais dans le dépôt** : `iac/` les génère dans Vault (`kvv2/minio/config`), le Vault Secrets Operator les matérialise en secret `minio-config`, le chart les lit via `existingSecret` |
Le ServiceAccount du pod est nommé `minio` (et non le `minio-sa` par défaut du
chart amont) parce que le module Vault `app_roles` borne l'authentification au
SA portant le nom de l'app : un seul SA, rien à réconcilier.
## Première mise en service
L'ordre compte, et il compte **deux fois** :
1.**Workflow `Hashicorp Vault`** — MinIO doit d'abord figurer dans
`hashicorp-vault/iac/terraform.tfvars` (c'est fait) : c'est **là** que naît
le rôle CI `gitea_cicd_minio`, et non dans `minio/iac`. Sans cette étape,
le workflow MinIO échoue sur
`role "gitea_cicd_minio" could not be found` — il essaie de s'authentifier
avec un rôle que personne n'a encore créé.
2.**Workflow `MinIO`** — applique `minio/iac` : rôle Kubernetes pour le Vault
Secrets Operator, et **génération** du mot de passe root dans
`kvv2/minio/config`.
3.**ArgoCD** synchronise l'application (déclarée dans `chart/values.yaml`).
4. Vérifier : `kubectl -n tools get vaultstaticsecret minio` (secret
matérialisé) puis `kubectl -n tools get pods -l app=minio`.
> [!NOTE]
> Sans le secret `minio-config`, le pod ne démarre pas. C'est voulu — mieux
> vaut un pod en attente qu'un MinIO ouvert avec des identifiants par défaut.
## Pourquoi une exposition publique (`s3.arcodange.fr`)
La PWA Kadans est servie en `https://kadans.arcodange.fr` et téléverse ses vidéos
**directement** vers MinIO, avec des URL présignées émises par kadans-api
(kadans-api#23) : les octets ne passent jamais par l'API.
Deux raisons rendent le `.lab` inutilisable pour ça, et ce sont des faits du
navigateur, pas des préférences :
1.**Contenu mixte** — une page servie en `https` ne peut pas émettre une requête
vers `http://`. L'ingress `.lab` est en entrypoint `web` sans TLS.
2.**`.lab` n'est pas résolvable hors du LAN** — la synchronisation ne marcherait
qu'à la maison, ce qui vide de son sens « retrouver mes vidéos sur mon autre
appareil ».
**Pas de basic-auth** sur cet ingress, contrairement à `kadans-public` : une
requête S3 porte sa propre signature (SigV4). Un défi HTTP Basic casserait le PUT
présigné, auquel le navigateur ne peut pas répondre. L'autorisation vient de
l'URL signée et de sa durée de vie courte (15 min pour déposer, 1 h pour lire).
**CORS** (`MINIO_API_CORS_ALLOW_ORIGIN`) liste les origines EXACTES de la PWA —
jamais `*` : une URL présignée qui fuiterait serait sinon rejouable depuis
n'importe quel site.
### ⚠ À vérifier avant de s'y fier : la taille maximale d'une requête
Le trafic public passe par un **tunnel Cloudflare**. Les offres gratuites de
Cloudflare plafonnent la taille du corps d'une requête proxifiée (de l'ordre de
**100 Mo**) — ce plafond n'a **pas** été mesuré ici, il doit l'être avec un vrai
téléversement avant d'annoncer une limite aux utilisateurs.
Ce qu'on sait, en revanche, et qui rend le sujet peu urgent : sur le corpus réel
du fondateur (707 vidéos, ~2 ans), **la durée moyenne est de 53 secondes** et
**deux vidéos seulement dépassent 5 minutes**. Au palier « travail » de l'ADR-018
(360p ≈ 3,4 Mo/min), 100 Mo représentent ~29 minutes de cours : le corpus entier
passe très largement. Si la limite se confirme, le plafond de 200 Mio annoncé
côté API mérite d'être ramené sous celle du tunnel — mieux vaut refuser tôt, avec
une phrase claire, qu'échouer au milieu d'un téléversement.
## Donner à une app l'accès au stockage
**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 :
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.
or absent(kube_deployment_status_replicas_available{namespace="kube-system",deployment="traefik"})
for:3m
labels:
severity:critical
app:homelab
annotations:
summary:"Homelab — ingress traefik indisponible : *.arcodange.lab est HS"
description:"Aucun replica traefik disponible (ou métrique absente = kube-state-metrics muet). Gitea, ArgoCD, Grafana, Vault… sont injoignables via leurs URLs .lab. Gitea reste accessible en direct : http://192.168.1.202:3000."
- alert:NoeudPressionMemoire
# Précurseur direct de l'incident : <500 Mo dispo sur un Pi 8 Go — sans
# swap, le kernel part en thrash bien avant d'atteindre 0.
summary:"Homelab — mémoire critique sur {{ $labels.node }}"
description:"{{ $labels.node }} n'a plus que {{ $value | humanize1024 }}B de mémoire disponible depuis 5 min : risque imminent de thrash (pas de swap sur les Pis). Suspect n°1 : un job CI trop gourmand."
description:"load15 > 8 depuis 10 min sur {{ $labels.node }} (4 cœurs) : quelque chose sature la machine, l'ingress et l'API k3s sont en danger si c'est pi1."
- alert:CertificatNonRenouvele
# Les certs .lab durent 24 h et cert-manager renouvelle à ~16 h d'âge : s'il
# reste <4 h, le renouvellement a raté plusieurs fois (incident 2026-07-24 :
# step-issuer ne résolvait plus ssl-ca.arcodange.lab, wildcard expiré au matin
# → « le https ne fonctionne plus » sur tout *.arcodange.lab).
description:"Le renouvellement automatique (cert-manager → step-issuer → step-ca) est en échec. Vérifier : kubectl get certificaterequest -A, logs step-issuer (résolution DNS de ssl-ca.arcodange.lab), santé de step-ca sur pi1:8443."
# -- 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:{}
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.