Author SHA1 Message Date
arcodange 7a62cebc92 🤖 ci(vault): declare dance-lessons-coach JWT role + ops policy
Helm Charts / Detect changed charts (push) Successful in 1m8s
Helm Charts / Library charts tool (push) Has been skipped
Helm Charts / Detect changed charts (pull_request) Successful in 1m5s
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
2026-05-06 12:55:17 +02:00
34 changed files with 59 additions and 1573 deletions
-70
View File
@@ -1,70 +0,0 @@
---
name: MinIO
# ⚠ Triggers écrits EN TOUTES LETTRES, sans ancre YAML (`&`/`*`).
# Une ancre dans un trigger Gitea Actions fait taire push ET pull_request —
# en silence, aucun run, aucune erreur (vécu sur arcodange/kadans, issues 113
# → 117). Les autres workflows de ce repo utilisent encore des ancres : à
# vérifier séparément, c'est probablement pour ça qu'ils ne partent qu'à la
# main (workflow_dispatch).
on:
workflow_dispatch: {}
push:
paths:
- 'minio/**/*.tf'
pull_request:
paths:
- 'minio/**/*.tf'
# cancel any previously-started, yet still active runs of this workflow on the same branch
concurrency:
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
+3 -14
View File
@@ -2,23 +2,12 @@
# template source: https://github.com/bretfisher/docker-build-workflow/blob/main/templates/call-docker-build.yaml
name: Hashicorp Vault
# ⚠ Triggers écrits EN TOUTES LETTRES, sans ancre YAML (`&`/`*`) : une ancre
# dans un trigger Gitea Actions fait taire push ET pull_request, en silence
# (vécu sur arcodange/kadans, issues 113 → 117) — c'est probablement pourquoi
# ce workflow ne partait qu'à la main.
# Et `*.tfvars` compte AUTANT que `*.tf` : la liste des applications (donc les
# rôles gitea_cicd_<app>) vit dans terraform.tfvars — l'oublier, c'est ajouter
# une app sans jamais créer son rôle.
on:
on: #[push,pull_request]
workflow_dispatch: {}
push:
push: &vaultPaths
paths:
- 'hashicorp-vault/**/*.tf'
- 'hashicorp-vault/**/*.tfvars'
pull_request:
paths:
- 'hashicorp-vault/**/*.tf'
- 'hashicorp-vault/**/*.tfvars'
pull_request: *vaultPaths
# cancel any previously-started, yet still active runs of this workflow on the same branch
concurrency:
+1 -2
View File
@@ -8,5 +8,4 @@ tools:
clickhouse: {}
grafana: {}
plausible: {}
prometheus: {}
minio: {}
prometheus: {}
-33
View File
@@ -1,33 +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`).
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 -387
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/
@@ -716,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
@@ -786,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"
}
+7 -32
View File
@@ -74,37 +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
service_account_names = each.value.service_account_names
service_account_namespaces = each.value.service_account_namespaces
gitea_app_id = var.gitea_app_id
source = "./modules/app_policy"
for_each = { for app in var.applications : app.name => app }
name = each.value.name
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
}
+25 -111
View File
@@ -6,16 +6,9 @@
# - postgres role
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}"]
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) }
name = lower(var.name)
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 +29,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}*"
@@ -74,61 +60,41 @@ data "vault_policy_document" "ops" {
}
allowed_parameter {
key = "bound_service_account_names"
value = [for inst in local.instances : jsonencode(local.per_instance_sa_names[inst])]
value = [jsonencode(local.bound_service_account_names)]
}
allowed_parameter {
key = "bound_service_account_namespaces"
value = [for inst in local.instances : jsonencode(local.per_instance_sa_namespaces[inst])]
value = [jsonencode(local.bound_service_account_namespaces)]
}
allowed_parameter {
key = "token_policies"
value = flatten([
for inst in local.instances : [
jsonencode(["default", inst]),
jsonencode([inst, "default"])
]
])
value = [
jsonencode(["default", local.name]),
jsonencode([local.name, "default"])
]
}
}
# allow editing app secrets — one rule per (capability × instance) preserves the
# original rule order (data, delete, undelete, destroy, metadata) so prod-only apps
# render a byte-identical policy document (no Vault state diff). Multi-env apps add
# extra rules per non-prod instance.
dynamic "rule" {
for_each = local.instances
content {
path = "kvv2/data/${rule.value}/*"
capabilities = ["create", "update", "read", "delete"]
}
# allow editing app secrets
rule {
path = "kvv2/data/${local.name}/*"
capabilities = ["create", "update", "read", "delete"]
}
dynamic "rule" {
for_each = local.instances
content {
path = "kvv2/delete/${rule.value}/*"
capabilities = ["update"]
}
rule {
path = "kvv2/delete/${local.name}/*"
capabilities = ["update"]
}
dynamic "rule" {
for_each = local.instances
content {
path = "kvv2/undelete/${rule.value}/*"
capabilities = ["update"]
}
rule {
path = "kvv2/undelete/${local.name}/*"
capabilities = ["update"]
}
dynamic "rule" {
for_each = local.instances
content {
path = "kvv2/destroy/${rule.value}/*"
capabilities = ["update"]
}
rule {
path = "kvv2/destroy/${local.name}/*"
capabilities = ["update"]
}
dynamic "rule" {
for_each = local.instances
content {
path = "kvv2/metadata/${rule.value}/*"
capabilities = ["read", "list", "delete"]
}
rule {
path = "kvv2/metadata/${local.name}/*"
capabilities = ["read", "list", "delete"]
}
# allow edit vault role (risky ?)
}
@@ -173,9 +139,6 @@ resource "vault_jwt_auth_backend_role" "gitea_jwt_cicd" {
role_type = "jwt"
}
# Runtime policy for the env=prod instance — kept at its single-env address
# (data.vault_policy_document.app, vault_policy.app, name = local.name) so existing
# state isn't disturbed when this module is upgraded.
data "vault_policy_document" "app" {
rule {
path = "kvv2/data/${local.name}/*"
@@ -185,57 +148,8 @@ 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
policy = data.vault_policy_document.app.hcl
}
# Runtime policies for non-prod envs. Each one is named <name>-<env> and reads
# only its own kvv2 + postgres creds paths.
data "vault_policy_document" "app_non_prod" {
for_each = toset(local.non_prod_instances)
rule {
path = "kvv2/data/${each.key}/*"
capabilities = ["read", "list"]
}
rule {
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)
name = each.key
policy = data.vault_policy_document.app_non_prod[each.key].hcl
}
@@ -1,11 +1,6 @@
variable "name" {
type = string
}
variable "envs" {
type = list(string)
default = ["prod"]
description = "List of environments this app deploys to. The CI policy + JWT role + identity group are created ONCE per repo regardless. One runtime policy is created per env; the env=prod runtime policy keeps its single-env address for backwards compatibility (no state move)."
}
variable "gitea_app_id" {
type = string
}
@@ -22,9 +17,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."
}
+8 -16
View File
@@ -4,18 +4,10 @@ data "vault_auth_backend" "kubernetes" {
locals {
name = lower(var.name)
env = lower(var.env)
database = var.database == null ? local.instance : var.database
database = var.database == null ? local.name : var.database
# 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.
# 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"
bound_service_account_names = concat([local.instance], var.service_account_names)
bound_service_account_namespaces = concat([local.instance], var.service_account_namespaces)
bound_service_account_names = concat([var.name], var.service_account_names)
bound_service_account_namespaces = concat([var.name], var.service_account_namespaces)
vault_mount_postgres = { path = "postgres" }
vault_mount_kvv2 = { path = "kvv2" }
@@ -28,14 +20,14 @@ moved {
resource "vault_database_secret_backend_role" "role" {
count = var.disable_database ? 0 : 1
backend = local.vault_mount_postgres.path
name = local.instance
name = local.name
db_name = "postgres"
creation_statements = [
"CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';",
"GRANT ${local.owner_role} TO \"{{name}}\";",
"GRANT ${local.name}_role TO \"{{name}}\";",
]
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)
"REASSIGN OWNED BY \"{{name}}\" TO ${local.name}_role;", # reassign must be executed in the database where the reassgined objects are - TODO (one connection per database/app)
"REVOKE ALL ON DATABASE ${local.database} FROM \"{{name}}\";", # should we drop the role ? -> YES after fixing reassign
]
renew_statements = []
@@ -44,11 +36,11 @@ resource "vault_database_secret_backend_role" "role" {
resource "vault_kubernetes_auth_backend_role" "role" {
backend = data.vault_auth_backend.kubernetes.path
role_name = local.instance
role_name = local.name
bound_service_account_names = local.bound_service_account_names
bound_service_account_namespaces = local.bound_service_account_namespaces
token_ttl = 3600
token_policies = ["default", local.instance]
token_policies = ["default", local.name]
audience = "vault"
alias_name_source = "serviceaccount_name"
}
@@ -1,13 +1,6 @@
output "name" {
value = local.name
}
output "env" {
value = local.env
}
output "instance" {
value = local.instance
description = "Derived id by the elision rule: equals name when env=prod, else <name>-<env>."
}
output "database" {
value = local.database
}
@@ -19,6 +12,5 @@ output "mount_paths" {
}
}
output "kvv2_path_prefix" {
# Identical to format("%s/", local.name) when env=prod (backwards compat).
value = format("%s/", local.instance)
value = format("%s/", local.name)
}
@@ -1,11 +1,6 @@
variable "name" {
type = string
}
variable "env" {
type = string
default = "prod"
description = "Deployment environment. By the elision rule (factory runbook conventions.md), env=prod produces names identical to the single-env baseline; non-prod values produce <name>-<env> kebab-case and <name>_<env>_role for the Postgres owner role."
}
variable "database" {
type = string
nullable = true
+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" },
]
]
+3 -10
View File
@@ -11,16 +11,9 @@ variable "POSTGRES_CREDENTIALS_EDITOR_PASSWORD" {
}
variable "applications" {
type = set(object({
name = string
ops_policies = optional(list(string), [])
service_account_names = optional(list(string), [])
name = 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), [])
}))
}
-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"
-145
View File
@@ -1,145 +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.
### ⚠ À vérifier avant de s'y fier : la taille maximale d'une requête
Le trafic public passe par un **tunnel Cloudflare**. Les offres gratuites de
Cloudflare plafonnent la taille du corps d'une requête proxifiée (de l'ordre de
**100 Mo**) — ce plafond n'a **pas** été mesuré ici, il doit l'être avec un vrai
téléversement avant d'annoncer une limite aux utilisateurs.
Ce qu'on sait, en revanche, et qui rend le sujet peu urgent : sur le corpus réel
du fondateur (707 vidéos, ~2 ans), **la durée moyenne est de 53 secondes** et
**deux vidéos seulement dépassent 5 minutes**. Au palier « travail » de l'ADR-018
(360p ≈ 3,4 Mo/min), 100 Mo représentent ~29 minutes de cours : le corpus entier
passe très largement. Si la limite se confirme, le plafond de 200 Mio annoncé
côté API mérite d'être ramené sous celle du tunnel — mieux vaut refuser tôt, avec
une phrase claire, qu'échouer au milieu d'un téléversement.
## Donner à une app l'accès au stockage
**Rien à faire ici.** Chaque application déclare **ses** buckets **depuis son
propre dépôt**, avec le module que ce dépôt-ci fournit :
```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)
}
-56
View File
@@ -1,56 +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://git@192.168.1.202:2222/arcodange-org/tools.git//minio/iac/modules/minio_app?depth=1&ref=main"
app = "mon-app" # = le nom de son rôle Vault
buckets = ["mon-app-fichiers"]
providers = { minio = minio }
}
```
Puis, côté chart : une `VaultStaticSecret` sur `kvv2/minio/<app>` et l'injection
des variables dans le Deployment.
## Ce que le module garantit
- les buckets sont **privés** — l'accès passe par des URL présignées ;
- 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 règle de cycle de vie, ni versioning : ces choix
appartiennent à l'app et varient d'un bucket à l'autre. À ajouter le jour où
un besoin réel apparaît, pas avant.
-57
View File
@@ -1,57 +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
}
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."
}
-14
View File
@@ -1,14 +0,0 @@
terraform {
required_providers {
minio = {
source = "aminueza/minio"
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
}
-62
View File
@@ -1,62 +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 issus de la documentation MinIO, NON éprouvés contre le
# serveur : le premier apply les confirmera ou les corrigera.
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.
Effect = "Allow"
Action = ["s3:CreateBucket", "s3:DeleteBucket", "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 -}}
-29
View File
@@ -1,29 +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.
#
# 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
-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