2 Commits
Author SHA1 Message Date
arcodangeandClaude Opus 4.7 399cf38fb4 style: tofu fmt the two files my multi-env change reformatted
Helm Charts / Detect changed charts (pull_request) Successful in 39s
Helm Charts / Detect changed charts (push) Successful in 42s
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
Whitespace-only. `tofu fmt` realigned two spots that my Phase A edits
shifted:
- app_roles/main.tf: the REASSIGN revocation-statement trailing comment
  re-aligned after the GRANT line gained ${local.owner_role}
- variables.tf: the applications object keys re-aligned after adding the
  longer `envs` key

The two pre-existing unformatted files (factory_auth.tf, terraform.tfvars)
are left as-is — they were already unformatted on origin/main and are
outside this PR's scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-15 14:15:02 +02:00
arcodangeandClaude Opus 4.7 5de9793bdf modules: add env/envs parameter to app_roles + app_policy (multi-env)
Helm Charts / Detect changed charts (push) Successful in 1m18s
Helm Charts / Detect changed charts (pull_request) Successful in 38s
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
Phase A of the multi-environment evolution agreed in the erp repo design
thread. Both modules gain an optional env coordinate that defaults to
"prod"; by the elision rule, env=prod produces the existing single-env
derived names character-for-character, so every existing app's tofu plan
should be a no-op.

app_roles (per-instance module — caller iterates over envs):
- variables.tf: add optional env = "prod"
- main.tf: compute local.instance via elision rule + local.owner_role
  (snake-case <name>_<env>_role for the Postgres owner)
- main.tf: substitute local.name -> local.instance in all derived names
  (dynamic role name, k8s role name, SA bindings, token_policies)
- outputs.tf: add env + instance outputs; kvv2_path_prefix now derives
  from local.instance (== local.name when env=prod -> backwards-compat)

app_policy (per-repo module — accepts list of envs):
- variables.tf: add optional envs = ["prod"]
- main.tf: compute local.instances + local.non_prod_instances
- main.tf: refactor kvv2 ops rules to dynamic blocks iterating local.instances
  preserving the original rule order (data, delete, undelete, destroy,
  metadata) so prod-only apps render a byte-identical policy document
- main.tf: allowed_parameter blocks for k8s role's bound_service_account_*
  and token_policies use comprehensions over local.instances
- main.tf: keep vault_policy.app (the env=prod runtime policy) at its
  original address; add vault_policy.app_non_prod via for_each over
  non_prod_instances for the other envs

Top-level wiring:
- iac/variables.tf: add envs = optional(list(string), ["prod"]) to the
  applications set(object) type
- iac/main.tf: pass envs = each.value.envs through to app_policies

`tofu validate` passes. Every existing app's tofu plan should report no
changes because: (1) env="prod" defaults are used everywhere, (2) the
elision rule makes local.instance == local.name for prod, (3) dynamic
rule blocks preserve declaration order, (4) the new app_non_prod resource
is created via for_each over an empty set when no non-prod envs are
declared.

Phase B (factory postgres iac + argocd + runbook docs) and Phase D
(erp iac/main.tf for_each + activate sandbox) follow in their own PRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-15 13:35:04 +02:00
42 changed files with 57 additions and 2020 deletions
+5 -8
View File
@@ -2,15 +2,12 @@
# template source: https://github.com/bretfisher/docker-build-workflow/blob/main/templates/call-docker-build.yaml
name: Crowdsec
# À 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:
on: #[push,pull_request]
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:
+8 -26
View File
@@ -2,32 +2,14 @@
# template source: https://github.com/bretfisher/docker-build-workflow/blob/main/templates/call-docker-build.yaml
name: Helm Charts
# 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'
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
# cancel any previously-started, yet still active runs of this workflow on the same branch
concurrency:
-69
View File
@@ -1,69 +0,0 @@
---
name: MinIO
# À 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: {}
# cancel any previously-started, yet still active runs of this workflow on the same branch
concurrency:
group: ${{ github.ref }}-${{ github.workflow }}
cancel-in-progress: true
.vault_step: &vault_step
name: read vault secret
uses: https://gitea.arcodange.lab/arcodange-org/vault-action.git@main
id: vault-secrets
with:
url: https://vault.arcodange.lab
caCertificate: ${{ secrets.HOMELAB_CA_CERT }}
jwtGiteaOIDC: ${{ needs.gitea_vault_auth.outputs.gitea_vault_jwt }}
role: gitea_cicd_minio
method: jwt
path: gitea_jwt
secrets: |
kvv1/google/credentials credentials | GOOGLE_BACKEND_CREDENTIALS ;
kvv1/gitea/tofu_module_reader ssh_private_key | TERRAFORM_SSH_KEY ;
jobs:
gitea_vault_auth:
name: Auth with gitea for vault
runs-on: ubuntu-latest
outputs:
gitea_vault_jwt: ${{steps.gitea_vault_jwt.outputs.id_token}}
steps:
- name: Auth with gitea for vault
id: gitea_vault_jwt
run: |
echo -n "${{ secrets.vault_oauth__sh_b64 }}" | base64 -d | bash
tofu:
name: Tofu - minio IAC
needs:
- gitea_vault_auth
runs-on: ubuntu-latest
env:
OPENTOFU_VERSION: 1.8.2
TERRAFORM_VAULT_AUTH_JWT: ${{ needs.gitea_vault_auth.outputs.gitea_vault_jwt }}
VAULT_CACERT: "${{ github.workspace }}/homelab.pem"
steps:
- *vault_step
- uses: actions/checkout@v4
- name: prepare vault self signed cert
run: echo -n "${{ secrets.HOMELAB_CA_CERT }}" | base64 -d > $VAULT_CACERT
- name: terraform apply
uses: dflook/terraform-apply@v1
with:
path: minio/iac
auto_approve: true
+5 -7
View File
@@ -2,14 +2,12 @@
# template source: https://github.com/bretfisher/docker-build-workflow/blob/main/templates/call-docker-build.yaml
name: Plausible
# À 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:
on: #[push,pull_request]
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:
+5 -11
View File
@@ -2,18 +2,12 @@
# template source: https://github.com/bretfisher/docker-build-workflow/blob/main/templates/call-docker-build.yaml
name: Hashicorp Vault
# À 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:
on: #[push,pull_request]
workflow_dispatch: {}
push: &vaultPaths
paths:
- 'hashicorp-vault/**/*.tf'
pull_request: *vaultPaths
# cancel any previously-started, yet still active runs of this workflow on the same branch
concurrency:
-7
View File
@@ -8,13 +8,6 @@ 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
+1 -2
View File
@@ -8,5 +8,4 @@ tools:
clickhouse: {}
grafana: {}
plausible: {}
prometheus: {}
minio: {}
prometheus: {}
-224
View File
@@ -1,224 +0,0 @@
# 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` |
-39
View File
@@ -1,39 +0,0 @@
# Exposition PUBLIQUE grafana.arcodange.fr, EN PLUS du .lab interne (qui reste
# inchangé derrière son middleware localIp@file). Pattern kadans
# (chart/templates/ingress-public.yaml) : entrypoint `web` — le TLS est terminé
# en amont par le tunnel Cloudflare (wildcard *.arcodange.fr →
# traefik.kube-system.svc:80, cf. cms/cloudflare module cf_tunnel) — et
# middleware crowdsec (WAF/bouncer, convention .fr).
# Pas de basic-auth ici, contrairement à kadans : Grafana a sa propre
# authentification (login requis, pas d'accès anonyme dans grafana.ini).
#
# 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:
name: grafana-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: grafana.arcodange.fr
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: grafana
port:
number: 80
+1 -389
View File
@@ -69,26 +69,13 @@ grafana: &grafana_config
path: /api/health
port: 3000
# Base Grafana en SQLite sur emptyDir → migration complète du schéma à CHAQUE démarrage du
# pod, lente sur Raspberry Pi (> 160 s). Sans garde suffisante, la liveness tuait Grafana en
# pleine migration → CrashLoop au moindre rollout. startupProbe : ~10 min avant d'armer la
# liveness. failureThreshold de liveness relevé aussi (filet de sécurité si le chart n'expose
# pas startupProbe). Fix pérenne : DB persistante (PVC) ou externe (postgres) — hors scope ici.
startupProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 60
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 60
timeoutSeconds: 30
failureThreshold: 60
failureThreshold: 10
## Use an alternate scheduler, e.g. "stork".
## ref: https://kubernetes.io/docs/tasks/administer-cluster/configure-multiple-schedulers/
@@ -285,8 +272,6 @@ 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
@@ -718,22 +703,6 @@ grafana: &grafana_config
editable: true
options:
path: /var/lib/grafana/dashboards/grafana-dashboards-kubernetes
- name: 'prospection'
orgId: 1
folder: 'Prospection'
type: file
disableDeletion: false
editable: true
options:
path: /var/lib/grafana/dashboards/prospection
- name: 'kadans'
orgId: 1
folder: 'Kadans'
type: file
disableDeletion: false
editable: true
options:
path: /var/lib/grafana/dashboards/kadans
# - name: 'default'
# orgId: 1
@@ -788,363 +757,6 @@ grafana: &grafana_config
k8s-views-pods:
url: https://raw.githubusercontent.com/dotdc/grafana-dashboards-kubernetes/master/dashboards/k8s-views-pods.json
token: ''
# Jobs d'analyse Kadans : la file de la façade (pod kadans-jobs, annoté
# prometheus.io/*) + le worker du LAPTOP (cible statique kadans-worker-mac).
# Couleurs de STATUT sémantiques (pending/running/done/failed) — jamais
# recyclées pour de l'identité ; le sommeil du Mac est un ÉTAT, pas une
# alerte (« sa disponibilité n'impacte que la latence »).
kadans:
kadans-jobs:
json: |
{
"annotations": { "list": [] },
"editable": true,
"graphTooltip": 1,
"schemaVersion": 39,
"tags": ["kadans", "jobs", "arcodange"],
"title": "Kadans — jobs d'analyse (façade + worker Mac)",
"uid": "kadans-jobs-analyse",
"time": { "from": "now-24h", "to": "now" },
"panels": [
{
"type": "stat", "title": "Worker Mac", "id": 1,
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 0 },
"targets": [{ "expr": "up{job=\"kadans-worker-mac\"}", "refId": "A" }],
"fieldConfig": { "defaults": {
"mappings": [
{ "type": "value", "options": { "1": { "text": "En écoute", "color": "green" }, "0": { "text": "Endormi", "color": "text" } } },
{ "type": "special", "options": { "match": "null", "result": { "text": "Endormi", "color": "text" } } }
],
"thresholds": { "mode": "absolute", "steps": [{ "color": "text", "value": null }] }
}, "overrides": [] },
"options": { "colorMode": "value", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat", "title": "Dernier poll (il y a)", "id": 2,
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 0 },
"targets": [{ "expr": "time() - kadans_worker_dernier_poll_timestamp_seconds", "refId": "A" }],
"fieldConfig": { "defaults": {
"unit": "s", "decimals": 0,
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null }, { "color": "yellow", "value": 600 }
] }
}, "overrides": [] },
"options": { "colorMode": "value", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat", "title": "Jobs en attente", "id": 3,
"gridPos": { "h": 5, "w": 6, "x": 12, "y": 0 },
"targets": [{ "expr": "sum(kadans_jobs_jobs{statut=\"pending\"})", "refId": "A" }],
"fieldConfig": { "defaults": {
"thresholds": { "mode": "absolute", "steps": [{ "color": "text", "value": null }] }
}, "overrides": [] },
"options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat", "title": "Plus ancien job en attente", "id": 4,
"gridPos": { "h": 5, "w": 6, "x": 18, "y": 0 },
"targets": [{ "expr": "kadans_jobs_plus_ancien_pending_age_seconds", "refId": "A" }],
"fieldConfig": { "defaults": {
"unit": "s", "decimals": 0,
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 3600 },
{ "color": "red", "value": 86400 }
] }
}, "overrides": [] },
"options": { "colorMode": "value", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "timeseries", "title": "La file, par statut", "id": 5,
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 5 },
"targets": [{ "expr": "kadans_jobs_jobs", "legendFormat": "{{statut}}", "refId": "A" }],
"fieldConfig": { "defaults": {
"custom": { "lineWidth": 2, "fillOpacity": 8, "pointSize": 4 }, "min": 0
}, "overrides": [
{ "matcher": { "id": "byName", "options": "pending" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "yellow" } }] },
{ "matcher": { "id": "byName", "options": "running" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } }] },
{ "matcher": { "id": "byName", "options": "done" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } }] },
{ "matcher": { "id": "byName", "options": "failed" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } }] }
] },
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }
},
{
"type": "timeseries", "title": "Publications (par heure)", "id": 6,
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 5 },
"targets": [{ "expr": "increase(kadans_jobs_publies_total[1h])", "legendFormat": "{{issue}}", "refId": "A" }],
"fieldConfig": { "defaults": {
"custom": { "lineWidth": 2, "fillOpacity": 8, "pointSize": 4 }, "min": 0, "decimals": 0
}, "overrides": [
{ "matcher": { "id": "byName", "options": "done" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } }] },
{ "matcher": { "id": "byName", "options": "failed" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } }] }
] },
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }
},
{
"type": "timeseries", "title": "Lanes exécutées par le worker (par heure)", "id": 7,
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 13 },
"targets": [{ "expr": "increase(kadans_worker_lanes_total[1h])", "legendFormat": "{{lane}} — {{issue}}", "refId": "A" }],
"fieldConfig": { "defaults": {
"custom": { "lineWidth": 2, "fillOpacity": 8, "pointSize": 4 }, "min": 0, "decimals": 0
}, "overrides": [] },
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }
},
{
"type": "timeseries", "title": "Durée moyenne d'une lane", "id": 8,
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 13 },
"targets": [{
"expr": "increase(kadans_worker_lane_duree_seconds_sum[1h]) / increase(kadans_worker_lane_duree_seconds_count[1h])",
"legendFormat": "{{lane}}", "refId": "A"
}],
"fieldConfig": { "defaults": {
"unit": "s", "custom": { "lineWidth": 2, "fillOpacity": 0, "pointSize": 5 }, "min": 0
}, "overrides": [] },
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }
}
]
}
prospection:
prospection:
json: |
{
"__inputs": [],
"annotations": { "list": [] },
"editable": true,
"graphTooltip": 1,
"schemaVersion": 39,
"tags": ["prospection", "arcodange"],
"title": "Prospection — pipeline BI missions",
"uid": "prospection-pipeline",
"time": { "from": "now-7d", "to": "now" },
"refresh": "30m",
"templating": {
"list": [
{
"name": "DS_PROMETHEUS",
"label": "Datasource",
"type": "datasource",
"query": "prometheus",
"current": {},
"hide": 0,
"refresh": 1
}
]
},
"panels": [
{
"type": "row", "title": "Vue d'ensemble du dernier run",
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "collapsed": false
},
{
"type": "stat", "title": "Dernier run réussi il y a", "id": 2,
"gridPos": { "h": 4, "w": 5, "x": 0, "y": 1 },
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"targets": [ { "refId": "A", "expr": "time() - prospection_run_timestamp_seconds", "instant": true, "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } } ],
"fieldConfig": { "defaults": {
"unit": "s",
"thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "yellow", "value": 86400 }, { "color": "red", "value": 90000 } ] }
}, "overrides": [] },
"options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" }
},
{
"type": "stat", "title": "Statut du run", "id": 3,
"gridPos": { "h": 4, "w": 4, "x": 5, "y": 1 },
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"targets": [ { "refId": "A", "expr": "prospection_run_success", "instant": true, "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } } ],
"fieldConfig": { "defaults": {
"mappings": [ { "type": "value", "options": { "0": { "text": "ÉCHEC", "color": "red", "index": 0 }, "1": { "text": "OK", "color": "green", "index": 1 } } } ],
"thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "green", "value": 1 } ] }
}, "overrides": [] },
"options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" }
},
{
"type": "stat", "title": "Durée du run", "id": 4,
"gridPos": { "h": 4, "w": 5, "x": 9, "y": 1 },
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"targets": [ { "refId": "A", "expr": "prospection_run_duration_seconds", "instant": true, "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } } ],
"fieldConfig": { "defaults": { "unit": "s", "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "yellow", "value": 1200 }, { "color": "red", "value": 1700 } ] } }, "overrides": [] },
"options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat", "title": "Étapes en erreur", "id": 5,
"gridPos": { "h": 4, "w": 4, "x": 14, "y": 1 },
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"targets": [ { "refId": "A", "expr": "prospection_run_errors_total", "instant": true, "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } } ],
"fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 1 } ] } }, "overrides": [] },
"options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat", "title": "Missions A qualifiées", "id": 6,
"gridPos": { "h": 4, "w": 3, "x": 18, "y": 1 },
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"targets": [ { "refId": "A", "expr": "prospection_opportunities_total{kind=\"A\"}", "instant": true, "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } } ],
"fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "yellow", "value": 1 }, { "color": "green", "value": 10 } ] } }, "overrides": [] },
"options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "gauge", "title": "Meilleur score", "id": 7,
"gridPos": { "h": 4, "w": 3, "x": 21, "y": 1 },
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"targets": [ { "refId": "A", "expr": "prospection_opportunity_top_score", "instant": true, "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } } ],
"fieldConfig": { "defaults": { "min": 0, "max": 100, "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "yellow", "value": 65 }, { "color": "green", "value": 85 } ] } }, "overrides": [] },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "showThresholdLabels": false, "showThresholdMarkers": true }
},
{
"type": "row", "title": "Collecte par étape",
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, "id": 8, "collapsed": false
},
{
"type": "table", "title": "Étapes — dernier run", "id": 9,
"gridPos": { "h": 9, "w": 12, "x": 0, "y": 6 },
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"targets": [
{ "refId": "Statut", "expr": "prospection_step_status", "instant": true, "format": "table", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } },
{ "refId": "Items", "expr": "prospection_step_items", "instant": true, "format": "table", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } },
{ "refId": "Durée", "expr": "prospection_step_duration_seconds", "instant": true, "format": "table", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } }
],
"transformations": [
{ "id": "merge", "options": {} },
{ "id": "organize", "options": {
"excludeByName": { "Time": true, "__name__": true, "job": true, "instance": true },
"renameByName": { "step": "Étape", "Value #Statut": "Statut", "Value #Items": "Items", "Value #Durée": "Durée (s)" },
"indexByName": { "step": 0, "Value #Statut": 1, "Value #Items": 2, "Value #Durée": 3 }
} }
],
"fieldConfig": { "defaults": { "custom": { "align": "auto" } }, "overrides": [
{ "matcher": { "id": "byName", "options": "Statut" }, "properties": [
{ "id": "mappings", "value": [ { "type": "value", "options": { "0": { "text": "⛔ erreur", "color": "red", "index": 0 }, "1": { "text": "✅ ok", "color": "green", "index": 1 }, "2": { "text": "⏭️ skip", "color": "blue", "index": 2 } } } ] },
{ "id": "custom.cellOptions", "value": { "type": "color-text" } }
] },
{ "matcher": { "id": "byName", "options": "Durée (s)" }, "properties": [ { "id": "unit", "value": "s" } ] }
] },
"options": { "showHeader": true, "sortBy": [ { "displayName": "Durée (s)", "desc": true } ] }
},
{
"type": "barchart", "title": "Durée par étape — dernier run", "id": 21,
"gridPos": { "h": 9, "w": 12, "x": 12, "y": 6 },
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"targets": [ { "refId": "A", "expr": "prospection_step_duration_seconds", "instant": true, "format": "table", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } } ],
"transformations": [ { "id": "organize", "options": { "excludeByName": { "Time": true, "__name__": true, "job": true, "instance": true }, "renameByName": { "step": "Étape", "Value": "Durée (s)" } } } ],
"fieldConfig": { "defaults": { "unit": "s", "color": { "mode": "continuous-GrYlRd" }, "custom": { "lineWidth": 1, "fillOpacity": 80 } }, "overrides": [] },
"options": { "orientation": "horizontal", "xField": "Étape", "showValue": "auto", "legend": { "showLegend": false }, "tooltip": { "mode": "single", "sort": "none" } }
},
{
"type": "barchart", "title": "Items par étape — dernier run", "id": 22,
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 },
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"targets": [ { "refId": "A", "expr": "prospection_step_items", "instant": true, "format": "table", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } } ],
"transformations": [ { "id": "organize", "options": { "excludeByName": { "Time": true, "__name__": true, "job": true, "instance": true }, "renameByName": { "step": "Étape", "Value": "Items" } } } ],
"fieldConfig": { "defaults": { "unit": "short", "color": { "mode": "continuous-BlPu" }, "custom": { "lineWidth": 1, "fillOpacity": 80 } }, "overrides": [] },
"options": { "orientation": "horizontal", "xField": "Étape", "showValue": "auto", "legend": { "showLegend": false }, "tooltip": { "mode": "single", "sort": "none" } }
},
{
"type": "timeseries", "title": "Items collectés par étape (historique)", "id": 10,
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 },
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"targets": [ { "refId": "A", "expr": "prospection_step_items > 0", "legendFormat": "{{step}}", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } } ],
"fieldConfig": { "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 40, "stacking": { "mode": "none" }, "lineWidth": 1 } }, "overrides": [] },
"options": { "legend": { "displayMode": "table", "placement": "right", "calcs": ["lastNotNull"] }, "tooltip": { "mode": "multi", "sort": "desc" } }
},
{
"type": "row", "title": "Modèle de données & scoring",
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 }, "id": 11, "collapsed": false
},
{
"type": "timeseries", "title": "Opportunités — missions A / cibles B", "id": 12,
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 24 },
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"targets": [
{ "refId": "A", "expr": "prospection_opportunities_total{kind=\"A\"}", "legendFormat": "Missions A", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } },
{ "refId": "B", "expr": "prospection_opportunities_total{kind=\"B\"}", "legendFormat": "Cibles B", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } }
],
"fieldConfig": { "defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "pointSize": 5, "showPoints": "always" } }, "overrides": [] },
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }
},
{
"type": "timeseries", "title": "Offres & entités collectées (historique)", "id": 13,
"gridPos": { "h": 8, "w": 8, "x": 12, "y": 24 },
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"targets": [
{ "refId": "A", "expr": "prospection_offers_total", "legendFormat": "Offres (missions)", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } },
{ "refId": "B", "expr": "prospection_entities_total", "legendFormat": "Entités (territoire)", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } },
{ "refId": "C", "expr": "prospection_scores_total", "legendFormat": "Offres scorées", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } }
],
"fieldConfig": { "defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "auto" } }, "overrides": [] },
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }
},
{
"type": "bargauge", "title": "Modèle — dernier run", "id": 14,
"gridPos": { "h": 8, "w": 4, "x": 20, "y": 24 },
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"targets": [
{ "refId": "A", "expr": "prospection_signals_total", "legendFormat": "Signaux", "instant": true, "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } },
{ "refId": "B", "expr": "prospection_entities_total", "legendFormat": "Entités", "instant": true, "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } },
{ "refId": "C", "expr": "prospection_offers_total", "legendFormat": "Offres", "instant": true, "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } },
{ "refId": "D", "expr": "prospection_scores_total", "legendFormat": "Scorées", "instant": true, "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } }
],
"fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [ { "color": "blue", "value": null } ] } }, "overrides": [] },
"options": { "displayMode": "gradient", "orientation": "horizontal", "reduceOptions": { "calcs": ["lastNotNull"] }, "legend": { "showLegend": false } }
},
{
"type": "row", "title": "Livraison & alertes",
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 32 }, "id": 15, "collapsed": false
},
{
"type": "stat", "title": "Brief rendu", "id": 16,
"gridPos": { "h": 5, "w": 3, "x": 0, "y": 33 },
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"targets": [ { "refId": "A", "expr": "prospection_brief_rendered", "instant": true, "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } } ],
"fieldConfig": { "defaults": { "mappings": [ { "type": "value", "options": { "0": { "text": "non", "color": "red", "index": 0 }, "1": { "text": "oui", "color": "green", "index": 1 } } } ], "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "green", "value": 1 } ] } }, "overrides": [] },
"options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat", "title": "Poussé sur Telegram", "id": 17,
"gridPos": { "h": 5, "w": 4, "x": 3, "y": 33 },
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"targets": [ { "refId": "A", "expr": "prospection_brief_telegram_pushed", "instant": true, "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } } ],
"fieldConfig": { "defaults": { "mappings": [ { "type": "value", "options": { "0": { "text": "non", "color": "red", "index": 0 }, "1": { "text": "oui", "color": "green", "index": 1 } } } ], "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "green", "value": 1 } ] } }, "overrides": [] },
"options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat", "title": "Messages / mission", "id": 18,
"gridPos": { "h": 5, "w": 3, "x": 7, "y": 33 },
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"targets": [ { "refId": "A", "expr": "prospection_telegram_messages_sent", "instant": true, "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } } ],
"fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [ { "color": "blue", "value": null } ] } }, "overrides": [] },
"options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat", "title": "Offres du brief", "id": 23,
"gridPos": { "h": 5, "w": 3, "x": 10, "y": 33 },
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"targets": [ { "refId": "A", "expr": "prospection_brief_offres", "instant": true, "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } } ],
"fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "green", "value": 1 } ] } }, "overrides": [] },
"options": { "colorMode": "value", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat", "title": "Vidéo brief", "id": 19,
"gridPos": { "h": 5, "w": 3, "x": 13, "y": 33 },
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"targets": [ { "refId": "A", "expr": "prospection_brief_video_kb * 1024", "instant": true, "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } } ],
"fieldConfig": { "defaults": { "unit": "bytes", "thresholds": { "mode": "absolute", "steps": [ { "color": "blue", "value": null } ] } }, "overrides": [] },
"options": { "colorMode": "value", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "table", "title": "Alertes prospection actives", "id": 20,
"gridPos": { "h": 5, "w": 8, "x": 16, "y": 33 },
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"targets": [ { "refId": "A", "expr": "ALERTS{alertname=~\"Prospection.*\", alertstate=\"firing\"}", "instant": true, "format": "table", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" } } ],
"transformations": [
{ "id": "organize", "options": {
"excludeByName": { "Time": true, "Value": true, "__name__": true, "job": true, "instance": true, "alertstate": true },
"renameByName": { "alertname": "Alerte", "severity": "Sévérité" }
} }
],
"fieldConfig": { "defaults": { "custom": { "align": "auto" } }, "overrides": [] },
"options": { "showHeader": true, "footer": { "show": false } }
}
]
}
# default:
# some-dashboard:
+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"
}
+1 -25
View File
@@ -74,36 +74,12 @@ resource "vault_kubernetes_auth_backend_role" "vso" {
alias_name_source = "serviceaccount_name"
}
# Alertmanager (ns tools) doit lire le token du bot Telegram de prospection
# pour livrer les alertes. Rôle k8s dédié + policy read-only sur kvv2/prospection/telegram.
data "vault_policy_document" "alertmanager_telegram" {
rule {
path = "kvv2/data/prospection/telegram"
capabilities = ["read"]
}
}
resource "vault_policy" "alertmanager_telegram" {
name = "alertmanager-telegram"
policy = data.vault_policy_document.alertmanager_telegram.hcl
}
resource "vault_kubernetes_auth_backend_role" "alertmanager" {
backend = vault_auth_backend.kubernetes.path
role_name = "alertmanager"
bound_service_account_names = ["prometheus-alertmanager"]
bound_service_account_namespaces = ["tools"]
token_ttl = 3600
token_policies = ["default", vault_policy.alertmanager_telegram.name]
audience = "vault"
alias_name_source = "serviceaccount_name"
}
module "app_policies" {
source = "./modules/app_policy"
for_each = { for app in var.applications : app.name => app }
name = each.value.name
envs = each.value.envs
ops_policies = each.value.ops_policies
kv_read_paths = each.value.kv_read_paths
ops_policies = each.value.policies
service_account_names = each.value.service_account_names
service_account_namespaces = each.value.service_account_namespaces
gitea_app_id = var.gitea_app_id
+6 -39
View File
@@ -8,14 +8,18 @@
locals {
name = lower(var.name)
envs = [for e in var.envs : lower(e)]
# Elision rule: env=prod → bare name; else <name>-<env>
instances = [for e in local.envs : e == "prod" ? local.name : "${local.name}-${e}"]
instances = [for e in local.envs : e == "prod" ? local.name : "${local.name}-${e}"]
# Non-prod instances only (for the per-env runtime policy iteration that doesn't touch the prod state address)
non_prod_instances = [for e in local.envs : "${local.name}-${e}" if e != "prod"]
# Per-instance SA name/namespace sets used by the CI policy's allowed_parameter blocks.
per_instance_sa_names = { for inst in local.instances : inst => concat([inst], var.service_account_names) }
per_instance_sa_namespaces = { for inst in local.instances : inst => concat([inst], var.service_account_namespaces) }
# Backwards-compat aliases kept for any caller that referenced these (unused outside the module).
bound_service_account_names = concat([var.name], var.service_account_names)
bound_service_account_namespaces = concat([var.name], var.service_account_namespaces)
}
data "vault_policy_document" "ops" {
@@ -36,13 +40,6 @@ 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}*"
@@ -185,27 +182,6 @@ 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
content {
path = rule.value
capabilities = ["read", "list"]
}
}
}
resource "vault_policy" "app" {
name = local.name
@@ -224,15 +200,6 @@ 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)
@@ -22,9 +22,4 @@ variable "service_account_namespaces" {
type = list(string)
default = []
description = "var.name will always be included by default - whitelist service account namespaces that can take this policy"
}
variable "kv_read_paths" {
type = list(string)
default = []
description = "Extra kvv2 data paths the env=prod runtime policy may read (read,list) — e.g. a shared backup-creds path owned by another app (kvv2/data/longhorn/gcs-backup). Default none."
}
+6 -33
View File
@@ -3,17 +3,17 @@ data "vault_auth_backend" "kubernetes" {
}
locals {
name = lower(var.name)
env = lower(var.env)
database = var.database == null ? local.instance : var.database
name = lower(var.name)
env = lower(var.env)
# Elision rule (factory runbook conventions.md):
# env == prod → identical to the single-env baseline (no suffix)
# else → kebab-case "<name>-<env>" for K8s/Vault paths.
# else → kebab-case "<name>-<env>" for K8s/Vault paths
instance = local.env == "prod" ? local.name : "${local.name}-${local.env}"
# Postgres owner role stays snake-case for consistency with the existing "_role" suffix.
instance = local.env == "prod" ? local.name : "${local.name}-${local.env}"
owner_role = local.env == "prod" ? "${local.name}_role" : "${local.name}_${local.env}_role"
database = var.database == null ? local.instance : var.database
bound_service_account_names = concat([local.instance], var.service_account_names)
bound_service_account_namespaces = concat([local.instance], var.service_account_namespaces)
@@ -30,36 +30,9 @@ 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 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)
+6 -16
View File
@@ -1,28 +1,18 @@
applications = [
{ name = "webapp" },
{
name = "erp"
envs = ["prod", "sandbox"]
kv_read_paths = ["kvv2/data/longhorn/gcs-backup"] # backup CronJob reads the shared GCS creds
},
{ name = "erp" },
{ name = "dance-lessons-coach" },
{
name = "cms"
ops_policies = ["factory__cf_r2_arcodange_tf"]
name = "cms"
ops_policies = ["factory__cf_r2_arcodange_tf"]
service_account_names = ["cloudflared"]
},
{
name = "crowdsec"
name = "crowdsec"
service_account_namespaces = ["tools"]
},
{
name = "plausible"
name = "plausible"
service_account_namespaces = ["tools"]
},
{
name = "minio"
service_account_namespaces = ["tools"]
},
{ name = "prospection" },
{ name = "kadans" },
]
]
+1 -4
View File
@@ -12,15 +12,12 @@ variable "POSTGRES_CREDENTIALS_EDITOR_PASSWORD" {
variable "applications" {
type = set(object({
name = string
ops_policies = optional(list(string), [])
policies = optional(list(string), [])
service_account_names = optional(list(string), [])
service_account_namespaces = optional(list(string), [])
# Multi-env extension: list of envs this app deploys to. Defaults to ["prod"] for
# every existing app — backwards compatible by the elision rule. Non-prod envs
# produce additional runtime policies named "<name>-<env>".
envs = optional(list(string), ["prod"])
# Extra kvv2 data paths the app's prod runtime policy may read (read,list) —
# e.g. a shared backup-creds path owned by another app. Default none.
kv_read_paths = optional(list(string), [])
}))
}
@@ -1,26 +0,0 @@
# Le sous-chart vendored `vault` ne rend son PodDisruptionBudget qu'en mode HA
# (server.ha.disruptionBudget.enabled, gardé par `eq .mode "ha"` dans
# server-disruptionbudget.yaml). Ce Vault tourne en standalone (storage "file",
# un seul réplica, scellement Shamir sans auto-unseal) : le garde-fou du chart
# ne s'applique jamais ici, d'où ce PDB écrit à la main dans le chart parapluie.
#
# Il ne protège que des évictions VOLONTAIRES (kubectl drain, descheduler) — pas
# d'une panne de nœud ni d'une suppression manuelle. Avec un seul réplica et
# aucun auto-unseal, toute interruption exige un unseal humain ensuite ; ce PDB
# réduit seulement les interruptions évitables.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ .Release.Name }}-server
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: vault
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
spec:
maxUnavailable: 0
selector:
matchLabels:
app.kubernetes.io/name: vault
app.kubernetes.io/instance: {{ .Release.Name }}
component: server
-16
View File
@@ -9,20 +9,6 @@ vault: &vault_config
auditStorage:
enabled: true
statefulSet:
securityContext:
pod:
# Le défaut du chart (runAsNonRoot/runAsGroup/runAsUser/fsGroup) est repris tel
# quel ici — seul fsGroupChangePolicy s'ajoute. Sans lui, chaque remount du
# volume (même sur le MÊME nœud) déclenche un chown -R complet ; mesuré à ~9 min
# sur ce PVC (kubelet event "VolumePermissionChangeInProgress"). OnRootMismatch
# saute le chown si le propriétaire racine est déjà bon.
runAsNonRoot: true
runAsGroup: 1000
runAsUser: 100
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
ingress:
enabled: true
annotations:
@@ -31,8 +17,6 @@ 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
-40
View File
@@ -1,40 +0,0 @@
# -----------------------------------------------------------------------------
# MinIO — stockage objet S3 du homelab (3 × Raspberry Pi 5, arm64).
#
# Pourquoi ici : c'est une BRIQUE PARTAGÉE, au même titre que pgbouncer ou
# clickhouse — le namespace `tools` en héberge le serveur ; les buckets, quotas
# et identifiants d'une application vivent, eux, avec cette application.
#
# Premier consommateur : Kadans (ADR-012 « MinIO local d'abord », ADR-013
# « OPFS local-first, MinIO/R2 = paliers payants »). La bascule vers Cloudflare
# R2 est prévue par l'ADR-012 aux seuils : 100+ utilisateurs actifs, > 10 To/mois
# de bande passante, ou dispersion géographique.
#
# Mode STANDALONE assumé : 3 nœuds, mais la donnée servie ici est DÉRIVÉE (le
# master d'une vidéo reste sur l'appareil de son propriétaire — ADR-018 du front) ;
# la redondance de Longhorn suffit, l'erasure coding distribué de MinIO coûterait
# de la RAM et des IOPS que des Pi n'ont pas à dépenser pour ça.
# -----------------------------------------------------------------------------
apiVersion: v2
name: minio
description: A Helm chart for Kubernetes
dependencies:
- name: tool
version: 0.1.0
repository: https://gitea.arcodange.lab/api/packages/arcodange-org/helm
- name: minio
version: 5.4.0
repository: https://charts.min.io/
# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application
version: 0.1.0
appVersion: "latest"
-168
View File
@@ -1,168 +0,0 @@
# MinIO — stockage objet S3 du homelab
Brique **partagée** du namespace `tools`, au même titre que pgbouncer ou
clickhouse. Le serveur vit ici ; les buckets, quotas et identifiants d'une
application vivent avec cette application.
## Premier consommateur : Kadans
- [ADR-012](https://gitea.arcodange.lab/arcodange/kadans/src/branch/main/docs/adr/012-video-storage-minio-first.md)
« MinIO local d'abord » — bascule vers Cloudflare R2 prévue aux seuils :
100+ utilisateurs actifs, > 10 To/mois, ou dispersion géographique.
- [ADR-013](https://gitea.arcodange.lab/arcodange/kadans/src/branch/main/docs/adr/013-video-storage-opfs-local-first.md)
le gratuit est **local-first** (la vidéo ne quitte pas l'appareil) ; MinIO sert
les **paliers payants**.
- [ADR-018](https://gitea.arcodange.lab/arcodange/kadans/src/branch/main/docs/adr/018-qualite-video-au-transfert.md)
ce qui transite est **dérivé** (aperçu 240p ~50 Ko, travail 360p ~3,4 Mo/min) —
le master reste chez l'utilisateur. D'où le dimensionnement ci-dessous.
## Ce que ce chart pose
| | |
|---|---|
| Mode | **standalone** (1 réplique) — la donnée est dérivée et Longhorn réplique déjà le volume ; l'erasure coding distribué coûterait de la RAM que des Pi 5 n'ont pas à dépenser pour ça |
| Volume | **50 Gi** sur `longhorn`**250 h de cours** au palier « travail ». ⚠ Longhorn réplique : compter **×3** sur la capacité du cluster avant d'augmenter |
| Ressources | requests 512 Mi / 100 m · limit 2 Gi — la limite protège les voisins de `tools`, pas MinIO |
| API S3 | `s3.arcodange.lab` (interne) **et `s3.arcodange.fr`** (public, tunnel Cloudflare → entrypoint `web` + crowdsec) — voir « Pourquoi une exposition publique » |
| Console | `minio.arcodange.lab` (Traefik) |
| Buckets | **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.
### 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.
-6
View File
@@ -1,6 +0,0 @@
terraform {
backend "gcs" {
bucket = "arcodange-tf"
prefix = "tools/minio/main"
}
}
-30
View File
@@ -1,30 +0,0 @@
# Rôle Vault + politique pour que le VaultAuth du chart puisse lire kvv2/minio/*
# (module partagé du repo — même recette que plausible).
module "app_roles" {
source = "git::ssh://[email protected]:2222/arcodange-org/tools.git//hashicorp-vault/iac/modules/app_roles?depth=1&ref=main"
name = "minio"
service_account_namespaces = ["tools"]
}
# Identifiants ROOT de MinIO. Générés ici, jamais choisis à la main et jamais
# écrits dans le dépôt : le chart officiel les lit dans le secret k8s
# `minio-config` (clés rootUser / rootPassword), matérialisé par le Vault
# Secrets Operator depuis ce chemin.
resource "random_password" "root" {
length = 40
special = false # les outils S3 transportent mal certains caractères en URL
}
locals {
config = {
rootUser = "kadans-root"
rootPassword = random_password.root.result
}
}
resource "vault_kv_secret_v2" "config" {
mount = "kvv2"
name = "minio/config"
cas = 1
data_json = jsonencode(local.config)
}
-100
View File
@@ -1,100 +0,0 @@
# `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://[email protected]: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
@@ -1,99 +0,0 @@
# 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-là appartient au BUCKET, pas au code applicatif —
# c'est la seule place d'où 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 où 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
@@ -1,9 +0,0 @@
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
@@ -1,21 +0,0 @@
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
@@ -1,15 +0,0 @@
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."
}
-32
View File
@@ -1,32 +0,0 @@
terraform {
required_providers {
vault = {
source = "vault"
version = "4.4.0"
}
minio = {
source = "aminueza/minio"
version = "3.3.0"
}
random = {
source = "hashicorp/random"
version = "3.6.3"
}
}
}
provider "vault" {
address = "https://vault.arcodange.lab"
auth_login_jwt { # TERRAFORM_VAULT_AUTH_JWT environment variable
mount = "gitea_jwt"
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
@@ -1,69 +0,0 @@
# 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
@@ -1,5 +0,0 @@
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."
}
-3
View File
@@ -1,3 +0,0 @@
{{- if eq .Values.tool.kind "HelmChart" -}}
{{- include "tool.helm-chart-config.tpl" . -}}
{{- end -}}
-3
View File
@@ -1,3 +0,0 @@
{{- if eq .Values.tool.kind "HelmChart" -}}
{{- include "tool.helm-chart.tpl" . -}}
{{- end -}}
-33
View File
@@ -1,33 +0,0 @@
# 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
-18
View File
@@ -1,18 +0,0 @@
{{- /* Identifiants MinIO : jamais dans le dépôt. Le Vault Secrets Operator
authentifie ce chart auprès de Vault (rôle créé par iac/) puis
matérialise le secret k8s consommé via `existingSecret`. Même recette
que plausible. */ -}}
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: minio
namespace: tools
spec:
vaultConnectionRef: default
method: kubernetes
mount: kubernetes
kubernetes:
role: minio
serviceAccount: minio
audiences:
- vault
-17
View File
@@ -1,17 +0,0 @@
{{- /* kvv2/minio/config → secret `minio-config` (clés rootUser / rootPassword,
les deux noms exigés par le chart officiel). Le mot de passe est généré
par Terraform (iac/), jamais choisi à la main. */ -}}
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: minio
namespace: tools
spec:
type: kv-v2
mount: kvv2
path: minio/config
destination:
name: minio-config
create: true
refreshAfter: 30s
vaultAuthRef: minio
-86
View File
@@ -1,86 +0,0 @@
minio: &minio_config
# Image officielle MinIO — multi-arch, arm64 inclus (les nœuds sont des Pi 5).
image:
repository: quay.io/minio/minio
pullPolicy: IfNotPresent
# STANDALONE : un seul serveur, un seul volume. La donnée servie ici est
# DÉRIVÉE (le master reste chez l'utilisateur) et Longhorn réplique déjà le
# volume ; l'erasure coding distribué coûterait de la RAM que les Pi n'ont pas
# à dépenser pour ça. `replicas` est ignoré en standalone.
mode: standalone
replicas: 1
# Le rôle Vault du module `app_roles` borne l'authentification au SERVICE
# ACCOUNT nommé comme l'app (`bound_service_account_names = [minio]`) : on
# aligne donc le SA du pod sur ce nom, plutôt que le « minio-sa » par défaut
# du chart amont — un SA pour le pod, le même pour Vault, rien à réconcilier.
serviceAccount:
create: true
name: minio
# Identifiants JAMAIS dans le dépôt : le secret est matérialisé par le
# Vault Secrets Operator depuis kvv2/minio/config (voir resources/ et iac/).
# Le chart lit `.data.rootUser` et `.data.rootPassword` de ce secret.
existingSecret: minio-config
persistence:
enabled: true
storageClass: longhorn
accessMode: ReadWriteOnce
# 50 Gi ≈ 250 heures de cours au palier « travail » de Kadans (360p,
# 3,4 Mo/min — ADR-018 du front). Longhorn réplique ce volume sur les
# nœuds : compter ×3 sur la capacité du cluster avant d'augmenter.
size: 50Gi
# Des Pi 5 à 8 Go partagés avec le reste de `tools` : on borne franchement.
# MinIO standalone est frugal ; la limite protège les voisins, pas MinIO.
resources:
requests:
memory: 512Mi
cpu: 100m
limits:
memory: 2Gi
# API S3 (les applications parlent ici).
ingress:
enabled: true
ingressClassName: traefik
path: /
hosts:
- s3.arcodange.lab
# Console d'administration (humains).
consoleIngress:
enabled: true
ingressClassName: traefik
path: /
hosts:
- minio.arcodange.lab
# 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.
# 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.
metrics:
serviceMonitor:
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
kind: 'SubChart'
repo: https://charts.min.io/
chart: minio
version: 5.4.0
values: *minio_config
+1 -57
View File
@@ -14,63 +14,7 @@ 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
# 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_reset_query: DEALLOCATE ALL # fix prepared statement already exist (crowdsec)
server_idle_timeout: 7200
pgbouncerExporter:
enabled: false
-2
View File
@@ -61,8 +61,6 @@ 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: ''
-48
View File
@@ -1,48 +0,0 @@
# Livraison des alertes Prometheus vers Telegram (bot prospection).
#
# Alertmanager tourne dans le namespace `tools`, mais le token du bot vit dans Vault
# (kvv2/prospection/telegram). Le Secret `prospection-telegram` synchronisé par VSO est
# namespace-scoped (prospection) et non réutilisable ici. On resynchronise donc le même
# chemin kvv2 vers un Secret `alertmanager-telegram` dans `tools`, via un VaultAuth dédié
# (rôle k8s `alertmanager`, provisionné par hashicorp-vault/iac).
#
# NB: ce chart prometheus 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 `prometheus`, destination namespace `tools`).
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: alertmanager-telegram
namespace: tools
spec:
# Dans le ns tools, VSO exige un vaultConnectionRef explicite (contrairement au ns
# prospection qui hérite d'une connexion par défaut). On pointe la VaultConnection
# `default` déjà présente dans tools (http://hashicorp-vault.tools.svc:8200).
vaultConnectionRef: default
method: kubernetes
mount: kubernetes
kubernetes:
role: alertmanager
serviceAccount: prometheus-alertmanager
audiences:
- vault
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: alertmanager-telegram
namespace: tools
spec:
type: kv-v2
mount: kvv2
path: prospection/telegram
destination:
name: alertmanager-telegram
create: true
refreshAfter: 1h
vaultAuthRef: alertmanager-telegram
# Alertmanager lit le token depuis un fichier monté au démarrage et ne recharge pas à
# chaud un secret monté : on redémarre le StatefulSet quand le token change dans Vault.
rolloutRestartTargets:
- kind: StatefulSet
name: prometheus-alertmanager
+3 -182
View File
@@ -612,11 +612,8 @@ prometheus: &prometheus_config
podLabels: {}
## Prometheus AlertManager configuration
## Lien Prometheus -> Alertmanager (service du sous-chart, ns tools).
alertmanagers:
- static_configs:
- targets:
- prometheus-alertmanager:9093
##
alertmanagers: []
## Use a StatefulSet if replicaCount needs to be greater than 1 (see below)
##
@@ -819,22 +816,6 @@ prometheus: &prometheus_config
static_configs:
- targets:
- localhost:9090
# Le worker d'analyse Kadans sur le LAPTOP du fondateur (tier 1 VOLATILE,
# kadans-jobs cmd/worker, /metrics stdlib sur :9105). Un scrape en échec
# n'est PAS un incident : le Mac dort — `up == 0` raconte exactement ça
# (latence des analyses, jamais une indisponibilité produit). Ne PAS
# alerter sur cette cible.
# ⚠ IP DHCP : réserver 192.168.1.103 pour le Mac au routeur (ou remplacer
# par un nom résolvable du LAN) — sinon la cible dérive au renouvellement.
kadans-worker-mac:
enabled: true
job_name: "kadans-worker-mac"
static_configs:
- targets:
- 192.168.1.103:9105
labels:
tier: laptop
app: kadans-worker
kubernetes-api-servers:
enabled: true
job_name: ""
@@ -1134,137 +1115,7 @@ prometheus: &prometheus_config
serverFiles:
## Alerts configuration
## Ref: https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/
alerting_rules.yml:
groups:
# Pipeline prospection (métriques poussées au Pushgateway job=prospection en fin de
# run). Livraison : Alertmanager → receiver telegram (chaîne testée live 2026-07-10,
# firing + resolved reçus). Visibles aussi dans Prometheus /alerts + le dashboard
# Grafana « Prospection » (panneau Alertes actives).
- name: prospection
rules:
- alert: ProspectionRunStale
expr: time() - prospection_run_timestamp_seconds > 90000 # > 25 h (cron quotidien)
for: 10m
labels:
severity: warning
app: prospection
annotations:
summary: "Prospection — aucun run réussi depuis plus de 25 h"
description: "Dernier run réussi il y a {{ $value | humanizeDuration }} ; le CronJob quotidien (~06:30 UTC) n'a pas abouti."
- alert: ProspectionRunFailed
expr: prospection_run_success == 0
for: 5m
labels:
severity: warning
app: prospection
annotations:
summary: "Prospection — le dernier run a échoué"
description: "prospection_run_success=0 : toutes les collectes ont échoué au dernier run."
- alert: ProspectionStepError
expr: prospection_step_status == 0
for: 5m
labels:
severity: info
app: prospection
annotations:
summary: "Prospection — étape {{ $labels.step }} en erreur"
description: "L'étape {{ $labels.step }} du pipeline a fini en erreur au dernier run."
- alert: ProspectionNoOffers
expr: prospection_offers_total == 0
for: 15m
labels:
severity: warning
app: prospection
annotations:
summary: "Prospection — 0 offre (mission) collectée"
description: "Aucune offre au dernier run : collecte France Travail / Free-Work potentiellement cassée."
# Vidéo quotidienne : deux modes d'échec distincts, tous deux gardés contre le
# skip volontaire — metrics.py met brief_rendered=0 aussi quand l'étape est
# SAUTÉE (cadence/kill-switch), seul step_status distingue skip(2) d'erreur(0).
- alert: ProspectionBriefFailed
# PRODUCTION en échec : l'étape brief a tourné mais n'a pas rendu la vidéo.
expr: prospection_brief_rendered == 0 and on(job) prospection_step_status{step="brief"} != 2
for: 15m
labels:
severity: warning
app: prospection
annotations:
summary: "Prospection — la vidéo du brief quotidien n'a pas été produite"
description: "L'étape brief a échoué au dernier run (rendu vidéo KO : TTS/ffmpeg/PIL ou erreur amont). Voir les logs du CronJob prospection."
- alert: ProspectionBriefNotSent
# LIVRAISON en échec : vidéo produite mais pas poussée sur Telegram.
expr: prospection_brief_telegram_pushed == 0 and prospection_brief_rendered == 1
for: 15m
labels:
severity: warning
app: prospection
annotations:
summary: "Prospection — brief produit mais non poussé sur Telegram"
description: "La vidéo du brief a été rendue mais l'envoi Telegram a échoué (token/chat_id/API). Voir les logs du CronJob prospection."
# Santé du socle : être prévenu quand (ou juste avant que) le homelab tombe.
# Incident 2026-07-23 : build CI sans limites sur pi1 → RAM épuisée (0 swap),
# load15 >100, traefik + apiserver k3s affamés → tout *.arcodange.lab injoignable
# (Gitea compris, pourtant sain sur pi2). Prometheus vit sur pi3 et Alertmanager
# sur pi2 : cette chaîne d'alerte survit donc à la perte de pi1.
- name: homelab
rules:
- alert: NoeudInjoignable
# node-exporter ne répond plus : nœud éteint, réseau HS, ou surcharge telle
# que plus rien n'y répond (le cas de l'incident).
expr: up{job="kubernetes-service-endpoints", app_kubernetes_io_name="prometheus-node-exporter"} == 0
for: 3m
labels:
severity: critical
app: homelab
annotations:
summary: "Homelab — {{ $labels.node }} est injoignable"
description: "node-exporter de {{ $labels.node }} ({{ $labels.instance }}) ne répond plus depuis 3 min : nœud down ou en surcharge sévère."
- alert: IngressLabIndisponible
# Plus aucun replica traefik dispo — ou kube-state-metrics muet (il vit sur
# pi1 : quand pi1 tombe, la métrique disparaît au lieu de passer à 0).
expr: >-
kube_deployment_status_replicas_available{namespace="kube-system",deployment="traefik"} < 1
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.
expr: node_memory_MemAvailable_bytes < 500 * 1024 * 1024
for: 5m
labels:
severity: warning
app: homelab
annotations:
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."
- alert: NoeudEnSurcharge
expr: node_load15 > 8
for: 10m
labels:
severity: warning
app: homelab
annotations:
summary: "Homelab — {{ $labels.node }} en surcharge (load15 = {{ $value | humanize }})"
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).
expr: certmanager_certificate_expiration_timestamp_seconds - time() < 4 * 3600
for: 15m
labels:
severity: critical
app: homelab
annotations:
summary: "Homelab — cert {{ $labels.namespace }}/{{ $labels.name }} expire dans {{ $value | humanizeDuration }}"
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."
alerting_rules.yml: {}
# groups:
# - name: Instances
# rules:
@@ -1330,36 +1181,6 @@ prometheus: &prometheus_config
##
enabled: true
## Configuration Alertmanager : livraison native Telegram (bot prospection).
## Le token est lu depuis le fichier monté via extraSecretMounts (Secret
## alertmanager-telegram, synchronisé par VSO — cf. templates/vault-telegram.yaml).
## chat_id est public (non sensible), donc inline.
config:
enabled: true
global: {}
templates:
- /etc/alertmanager/*.tmpl
route:
group_by: ["alertname", "app"]
group_wait: 30s
group_interval: 5m
repeat_interval: 3h
receiver: telegram
receivers:
- name: telegram
telegram_configs:
- bot_token_file: /etc/alertmanager/telegram/BOT_TOKEN
chat_id: 7497777082
parse_mode: HTML
send_resolved: true
## Montage du token du bot dans le pod Alertmanager (fichier BOT_TOKEN).
extraSecretMounts:
- name: telegram
mountPath: /etc/alertmanager/telegram
secretName: alertmanager-telegram
readOnly: true
persistence:
## If true, storage will create or use Persistence Volume
## If false, storage will use emptyDir
+7 -30
View File
@@ -140,36 +140,13 @@ redis: &redis_config
runAsUser: 999
# -- Compute resources used by the container. More info [here](https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/).
#
# ⚠ 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
resources: {}
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
# -- Pod-level affinity. More info [here](https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling).
affinity: {}