Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7074a94d7e | ||
|
|
ec71571b98 | ||
|
|
0e1b6e1062 | ||
|
|
287e3dcf1e | ||
|
|
91a0f09b49 | ||
|
|
4ca4a05370 | ||
|
|
f49a79e393 | ||
|
|
1490f4c514 | ||
|
|
dce19141a8 | ||
|
|
d146affbcd | ||
|
|
2202e7bbfe | ||
|
|
84edfc8640 | ||
|
|
342c4ab346 | ||
|
|
5fbc7f6ff8 | ||
|
|
bbf6653464 | ||
|
|
347ebee1b0 | ||
|
|
53710d9fde | ||
|
|
6f398f4ddc | ||
|
|
ffce6990a3 | ||
|
|
b9626d878b | ||
|
|
bc62304bc3 | ||
|
|
8e236230d3 | ||
|
|
65ff6fcc34 | ||
|
|
ef9e6c62c2 | ||
|
|
4133396720 | ||
|
|
5fae1f31db | ||
|
|
4005d89c0c | ||
|
|
7a8c8537a5 | ||
|
|
714e2bc2ab | ||
|
|
1eabae6936 | ||
|
|
e419cb6307 | ||
|
|
e9998e3b48 | ||
|
|
e6fca752a2 | ||
|
|
b4f945c310 | ||
|
|
31a66884d6 | ||
|
|
5b24738dcf | ||
|
|
2953ec3202 | ||
|
|
06c5eb4391 | ||
|
|
3170a341d1 | ||
|
|
fd93359f2e | ||
|
|
25569eb29d | ||
|
|
01de97853d | ||
|
|
a3e121b468 |
@@ -0,0 +1,70 @@
|
||||
---
|
||||
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
|
||||
@@ -2,12 +2,23 @@
|
||||
# template source: https://github.com/bretfisher/docker-build-workflow/blob/main/templates/call-docker-build.yaml
|
||||
name: Hashicorp Vault
|
||||
|
||||
on: #[push,pull_request]
|
||||
# ⚠ 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:
|
||||
workflow_dispatch: {}
|
||||
push: &vaultPaths
|
||||
push:
|
||||
paths:
|
||||
- 'hashicorp-vault/**/*.tf'
|
||||
pull_request: *vaultPaths
|
||||
- 'hashicorp-vault/**/*.tfvars'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'hashicorp-vault/**/*.tf'
|
||||
- 'hashicorp-vault/**/*.tfvars'
|
||||
|
||||
# cancel any previously-started, yet still active runs of this workflow on the same branch
|
||||
concurrency:
|
||||
|
||||
+2
-1
@@ -8,4 +8,5 @@ tools:
|
||||
clickhouse: {}
|
||||
grafana: {}
|
||||
plausible: {}
|
||||
prometheus: {}
|
||||
prometheus: {}
|
||||
minio: {}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# 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
|
||||
+387
-1
@@ -69,13 +69,26 @@ 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: 10
|
||||
failureThreshold: 60
|
||||
|
||||
## Use an alternate scheduler, e.g. "stork".
|
||||
## ref: https://kubernetes.io/docs/tasks/administer-cluster/configure-multiple-schedulers/
|
||||
@@ -703,6 +716,22 @@ 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
|
||||
@@ -757,6 +786,363 @@ 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,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"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -74,12 +74,37 @@ 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
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -6,9 +6,16 @@
|
||||
# - postgres role
|
||||
|
||||
locals {
|
||||
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)
|
||||
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) }
|
||||
}
|
||||
|
||||
data "vault_policy_document" "ops" {
|
||||
@@ -29,6 +36,13 @@ data "vault_policy_document" "ops" {
|
||||
path = "kvv1/google/credentials"
|
||||
capabilities = ["read"]
|
||||
}
|
||||
# Provisionneur MinIO — l'app crée ses buckets depuis son dépôt sans le root.
|
||||
# ADR : factory/doc/adr/20260726-stockage-objet-minio.md
|
||||
rule {
|
||||
path = "kvv2/data/minio/provisioner"
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
# read cloudflare related secrets
|
||||
rule {
|
||||
path = "kvv1/cloudflare/${local.name}*"
|
||||
@@ -60,41 +74,61 @@ data "vault_policy_document" "ops" {
|
||||
}
|
||||
allowed_parameter {
|
||||
key = "bound_service_account_names"
|
||||
value = [jsonencode(local.bound_service_account_names)]
|
||||
value = [for inst in local.instances : jsonencode(local.per_instance_sa_names[inst])]
|
||||
}
|
||||
allowed_parameter {
|
||||
key = "bound_service_account_namespaces"
|
||||
value = [jsonencode(local.bound_service_account_namespaces)]
|
||||
value = [for inst in local.instances : jsonencode(local.per_instance_sa_namespaces[inst])]
|
||||
}
|
||||
allowed_parameter {
|
||||
key = "token_policies"
|
||||
value = [
|
||||
jsonencode(["default", local.name]),
|
||||
jsonencode([local.name, "default"])
|
||||
]
|
||||
value = flatten([
|
||||
for inst in local.instances : [
|
||||
jsonencode(["default", inst]),
|
||||
jsonencode([inst, "default"])
|
||||
]
|
||||
])
|
||||
}
|
||||
|
||||
}
|
||||
# allow editing app secrets
|
||||
rule {
|
||||
path = "kvv2/data/${local.name}/*"
|
||||
capabilities = ["create", "update", "read", "delete"]
|
||||
# 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"]
|
||||
}
|
||||
}
|
||||
rule {
|
||||
path = "kvv2/delete/${local.name}/*"
|
||||
capabilities = ["update"]
|
||||
dynamic "rule" {
|
||||
for_each = local.instances
|
||||
content {
|
||||
path = "kvv2/delete/${rule.value}/*"
|
||||
capabilities = ["update"]
|
||||
}
|
||||
}
|
||||
rule {
|
||||
path = "kvv2/undelete/${local.name}/*"
|
||||
capabilities = ["update"]
|
||||
dynamic "rule" {
|
||||
for_each = local.instances
|
||||
content {
|
||||
path = "kvv2/undelete/${rule.value}/*"
|
||||
capabilities = ["update"]
|
||||
}
|
||||
}
|
||||
rule {
|
||||
path = "kvv2/destroy/${local.name}/*"
|
||||
capabilities = ["update"]
|
||||
dynamic "rule" {
|
||||
for_each = local.instances
|
||||
content {
|
||||
path = "kvv2/destroy/${rule.value}/*"
|
||||
capabilities = ["update"]
|
||||
}
|
||||
}
|
||||
rule {
|
||||
path = "kvv2/metadata/${local.name}/*"
|
||||
capabilities = ["read", "list", "delete"]
|
||||
dynamic "rule" {
|
||||
for_each = local.instances
|
||||
content {
|
||||
path = "kvv2/metadata/${rule.value}/*"
|
||||
capabilities = ["read", "list", "delete"]
|
||||
}
|
||||
}
|
||||
# allow edit vault role (risky ?)
|
||||
}
|
||||
@@ -139,6 +173,9 @@ 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}/*"
|
||||
@@ -148,8 +185,57 @@ 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,6 +1,11 @@
|
||||
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
|
||||
}
|
||||
@@ -17,4 +22,9 @@ 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."
|
||||
}
|
||||
@@ -4,10 +4,18 @@ data "vault_auth_backend" "kubernetes" {
|
||||
|
||||
locals {
|
||||
name = lower(var.name)
|
||||
database = var.database == null ? local.name : var.database
|
||||
env = lower(var.env)
|
||||
database = var.database == null ? local.instance : var.database
|
||||
|
||||
bound_service_account_names = concat([var.name], var.service_account_names)
|
||||
bound_service_account_namespaces = concat([var.name], var.service_account_namespaces)
|
||||
# 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)
|
||||
|
||||
vault_mount_postgres = { path = "postgres" }
|
||||
vault_mount_kvv2 = { path = "kvv2" }
|
||||
@@ -20,14 +28,14 @@ moved {
|
||||
resource "vault_database_secret_backend_role" "role" {
|
||||
count = var.disable_database ? 0 : 1
|
||||
backend = local.vault_mount_postgres.path
|
||||
name = local.name
|
||||
name = local.instance
|
||||
db_name = "postgres"
|
||||
creation_statements = [
|
||||
"CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';",
|
||||
"GRANT ${local.name}_role TO \"{{name}}\";",
|
||||
"GRANT ${local.owner_role} TO \"{{name}}\";",
|
||||
]
|
||||
revocation_statements = [
|
||||
"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)
|
||||
"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)
|
||||
"REVOKE ALL ON DATABASE ${local.database} FROM \"{{name}}\";", # should we drop the role ? -> YES after fixing reassign
|
||||
]
|
||||
renew_statements = []
|
||||
@@ -36,11 +44,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.name
|
||||
role_name = local.instance
|
||||
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.name]
|
||||
token_policies = ["default", local.instance]
|
||||
audience = "vault"
|
||||
alias_name_source = "serviceaccount_name"
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
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
|
||||
}
|
||||
@@ -12,5 +19,6 @@ output "mount_paths" {
|
||||
}
|
||||
}
|
||||
output "kvv2_path_prefix" {
|
||||
value = format("%s/", local.name)
|
||||
# Identical to format("%s/", local.name) when env=prod (backwards compat).
|
||||
value = format("%s/", local.instance)
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
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
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
applications = [
|
||||
{ name = "webapp" },
|
||||
{ name = "erp" },
|
||||
{
|
||||
name = "erp"
|
||||
envs = ["prod", "sandbox"]
|
||||
kv_read_paths = ["kvv2/data/longhorn/gcs-backup"] # backup CronJob reads the shared GCS creds
|
||||
},
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
@@ -11,9 +11,16 @@ variable "POSTGRES_CREDENTIALS_EDITOR_PASSWORD" {
|
||||
}
|
||||
variable "applications" {
|
||||
type = set(object({
|
||||
name = string
|
||||
policies = optional(list(string), [])
|
||||
service_account_names = optional(list(string), [])
|
||||
name = string
|
||||
ops_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), [])
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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
@@ -0,0 +1,145 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,6 @@
|
||||
terraform {
|
||||
backend "gcs" {
|
||||
bucket = "arcodange-tf"
|
||||
prefix = "tools/minio/main"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# 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)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# `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 ;
|
||||
- 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.
|
||||
@@ -0,0 +1,57 @@
|
||||
# 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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
output "vault_path" {
|
||||
value = "kvv2/minio/${var.app}"
|
||||
description = "Où le pod lira ses identifiants (VaultStaticSecret)."
|
||||
}
|
||||
|
||||
output "buckets" {
|
||||
value = var.buckets
|
||||
description = "Écho des buckets créés."
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
minio = {
|
||||
source = "aminueza/minio"
|
||||
configuration_aliases = [minio]
|
||||
}
|
||||
vault = {
|
||||
source = "hashicorp/vault"
|
||||
}
|
||||
random = {
|
||||
source = "hashicorp/random"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
variable "app" {
|
||||
type = string
|
||||
description = "Nom de l'app (= son rôle Vault). Décide du chemin du secret et du nom du compte de service."
|
||||
}
|
||||
|
||||
variable "buckets" {
|
||||
type = list(string)
|
||||
description = "Ses buckets, créés ici (privés). Le compte de service n'a de droits que sur eux."
|
||||
}
|
||||
|
||||
variable "endpoint" {
|
||||
type = string
|
||||
default = "s3.arcodange.fr"
|
||||
description = "Hôte de l'API S3, sans schéma. Public : le runner CI n'est pas dans le LAN."
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# 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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
variable "minio_endpoint" {
|
||||
type = string
|
||||
default = "s3.arcodange.fr"
|
||||
description = "Hôte de l'API S3, sans schéma. Public : le runner CI n'est pas dans le LAN."
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{{- if eq .Values.tool.kind "HelmChart" -}}
|
||||
{{- include "tool.helm-chart-config.tpl" . -}}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,3 @@
|
||||
{{- if eq .Values.tool.kind "HelmChart" -}}
|
||||
{{- include "tool.helm-chart.tpl" . -}}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,29 @@
|
||||
# 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
|
||||
@@ -0,0 +1,18 @@
|
||||
{{- /* 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
|
||||
@@ -0,0 +1,17 @@
|
||||
{{- /* 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
|
||||
@@ -0,0 +1,86 @@
|
||||
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
|
||||
@@ -0,0 +1,48 @@
|
||||
# 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
|
||||
+182
-3
@@ -612,8 +612,11 @@ prometheus: &prometheus_config
|
||||
podLabels: {}
|
||||
|
||||
## Prometheus AlertManager configuration
|
||||
##
|
||||
alertmanagers: []
|
||||
## Lien Prometheus -> Alertmanager (service du sous-chart, ns tools).
|
||||
alertmanagers:
|
||||
- static_configs:
|
||||
- targets:
|
||||
- prometheus-alertmanager:9093
|
||||
|
||||
## Use a StatefulSet if replicaCount needs to be greater than 1 (see below)
|
||||
##
|
||||
@@ -816,6 +819,22 @@ 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: ""
|
||||
@@ -1115,7 +1134,137 @@ prometheus: &prometheus_config
|
||||
serverFiles:
|
||||
## Alerts configuration
|
||||
## Ref: https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/
|
||||
alerting_rules.yml: {}
|
||||
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."
|
||||
# groups:
|
||||
# - name: Instances
|
||||
# rules:
|
||||
@@ -1181,6 +1330,36 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user