diff --git a/.gitea/workflows/helmcharts.yaml b/.gitea/workflows/helmcharts.yaml index 399d8c1..03d3ec5 100644 --- a/.gitea/workflows/helmcharts.yaml +++ b/.gitea/workflows/helmcharts.yaml @@ -35,6 +35,23 @@ concurrency: cancel-in-progress: true .helm_install_dependencies_sh: &helm_install_dependencies_sh |- + # ⚠⚠ MESURÉ le 2026-09-07 (run 6728, étape « RELEVÉ ») : ce runner NE RÉSOUT + # PAS `gitea.arcodange.lab` — `getent` code 2, `curl` code 6 — alors qu'il + # résout parfaitement les registres publics. SEPT charts sur dix déclarent le + # registre Helm interne en dépendance (crowdsec, grafana, hashicorp-vault, + # pgbouncer, pgcat, prometheus, redis) : depuis que #32 a énuméré les charts, + # leurs jobs mouraient TOUS ici, en 7 s, avant la moindre étape utile — et le + # premier à en faire les frais est le premier à avoir été construit. + # + # Gitea donne au job sa PROPRE adresse joignable dans `GITHUB_SERVER_URL` + # (mesuré : http://192.168.65.254:43000). On ne s'en sert qu'en REPLI, et + # seulement après avoir constaté que le nom d'origine ne répond pas : rien ne + # change sur un runner qui, lui, sait le résoudre. Lecture anonyme du registre + # vérifiée (HTTP 200), donc aucun jeton n'entre ici. + FORGE_INTERNE='https://gitea.arcodange.lab' + + joignable() { curl -sf -o /dev/null -m 15 "$1"; } + helm_install_dependencies() { chart_file="$1/Chart.yaml" [[ ! -f "$chart_file" ]] && echo "Chart.yaml not found in $1" && return 1 @@ -43,7 +60,27 @@ concurrency: name=$(jq -r '.name' <<< "$dep") version=$(jq -r '.version' <<< "$dep") repo=$(jq -r '.repository' <<< "$dep") + + en_repli=non + if ! joignable "${repo}/index.yaml"; then + repli="${repo/$FORGE_INTERNE/${GITHUB_SERVER_URL:-$FORGE_INTERNE}}" + if [[ "$repli" != "$repo" ]] && joignable "${repli}/index.yaml"; then + echo " ⚠ ${repo} injoignable depuis ce runner — repli sur ${repli}" + repo="$repli" + en_repli=oui + fi + fi + url=$(curl -s "${repo}/index.yaml" | yq eval ".entries.${name}[] | select(.version == \"${version}\") | .urls[0]" -) + # L'index du registre interne publie des URLs ABSOLUES vers le même nom + # d'hôte : le repli doit les suivre, sinon on retombe dans le code 6. + # ⚠ On ne réécrit QUE si l'on est effectivement en repli — sur un runner + # sain, le chemin d'origine reste le chemin d'origine. + # ⚠ Un `[[ … ]] && …` ici serait un piège : sous `set -e`, la liste rend + # non nul quand la condition est fausse, et le script AVORTE. + if [[ "$en_repli" = oui ]]; then + url="${url/$FORGE_INTERNE/$GITHUB_SERVER_URL}" + fi echo "Dependency: $name, Version: $version, URL: $url" mkdir -p "$1/charts" && curl -sL "$url" -o "$1/charts/${name}-${version}.tgz" @@ -170,6 +207,52 @@ jobs: - uses: actions/checkout@v4 + # ⚠⚠ UNE GATE QUI N'EST CÂBLÉE NULLE PART N'EXISTE PAS (tools#36) — et une + # gate placée APRÈS une étape qui échoue toujours n'existe pas non plus : + # elle sort `Skipped`, ni rouge ni absente, rien ne dépasse. Le banc du + # veilleur passe donc AVANT le téléchargement des dépendances, qui a besoin + # du réseau et du registre Helm interne. Il n'a besoin d'aucun des deux : + # stdlib Python seule, plus `yq`/`jq` déjà présents. + - name: Banc du veilleur (chart prometheus) + if: >- + ${{ + matrix.chart == 'prometheus' + && ( + contains(fromJSON('["","pull_request"]'), github.event_name) + || github.ref != 'refs/heads/main' + ) + }} + run: | + set -euo pipefail + python3 -m unittest discover -s prometheus/veilleur -v + + # Le témoin toujours allumé ne doit JAMAIS partir sur Telegram : sans + # sa route vers le récepteur vide il sonnerait toutes les 3 h, et un + # témoin qui sonne est un témoin qu'on coupe. + yq -o=json eval '.prometheus.alertmanager.config' prometheus/values.yaml > /tmp/am.json + jq -e '[.route.routes[] | select(.matchers[0] == "alertname=\"Veilleur\"") | .receiver] == ["neant"]' /tmp/am.json + jq -e '[.receivers[] | select(.name == "neant") | keys] == [["name"]]' /tmp/am.json + + # ⚠ RELEVÉ, PAS UNE GATE — il ne peut pas faire échouer le job. Le premier + # run du job `Application charts prometheus` (le tout premier depuis que + # #32 a énuméré les charts) est mort ICI, `curl` code 6 : le runner ne + # RÉSOUT PAS l'hôte de la dépendance. Sept charts sur dix dépendent du + # registre Helm interne `gitea.arcodange.lab` — donc sept jobs sur dix ne + # peuvent pas passer, quel que soit leur contenu. Ce relevé nomme lequel + # des deux hôtes tombe, pour que la prochaine session n'ait pas à le + # deviner. À retirer quand le trou est bouché. + - name: RELEVÉ — ce que le runner sait résoudre + run: | + set +e + echo "GITHUB_SERVER_URL=${GITHUB_SERVER_URL:-}" + for hote in gitea.arcodange.lab prometheus-community.github.io; do + echo "--- $hote ---" + getent hosts "$hote" || echo " PAS DE RÉSOLUTION (getent code $?)" + curl -sS -o /dev/null -m 10 -w " http=%{http_code}\n" "https://$hote/" \ + || echo " curl code $?" + done + exit 0 + - run: *helm_install_dependencies_sh - name: Install Helm for test diff --git a/prometheus/.helmignore b/prometheus/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/prometheus/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/prometheus/templates/veilleur.yaml b/prometheus/templates/veilleur.yaml new file mode 100644 index 0000000..c43396d --- /dev/null +++ b/prometheus/templates/veilleur.yaml @@ -0,0 +1,108 @@ +# Le veilleur — le guetteur qui vit HORS du chemin d'alerte de Prometheus. +# +# arcodange-org/tools#36 : du 2026-09-04 02:41 au 2026-09-07 07:37, Prometheus +# n'a rien enregistré (WAL en lecture seule) et RIEN N'A PRÉVENU — les 11 règles +# d'alerte vivent dans le Prometheus en panne, et sans échantillon frais une +# règle ne se déclenche pas : elle se tait. +# +# ⚠ C'est pour ça que ce guetteur n'est PAS une règle Prometheus de plus. Il +# interroge Prometheus depuis l'extérieur et pousse LUI-MÊME sur Telegram, sans +# passer par Alertmanager. Le déplacer dans `serverFiles.alerting_rules.yml` +# reconstruirait exactement le silence qu'il existe pour supprimer. +# +# 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`), comme +# vault-telegram.yaml. +# +# La propriété gardée, et son arithmétique : +# « si Prometheus cesse d'enregistrer, une notification arrive en ≤ 12 min » +# seuil de fraîcheur 180 s (3 × `scrape_interval: 1m`) +# + période du CronJob 300 s (*/5 — le pire cas est de le manquer d'un rien) +# + tolérance au roulement 180 s (2 × 90 s : un redéploiement de Prometheus ne +# doit pas réveiller le fondateur) +# + exécution ~5 s +# = 665 s, soit 11 min 5 s au pire. En face : 4 620 minutes de silence réel. +apiVersion: v1 +kind: ConfigMap +metadata: + name: veilleur-prometheus + namespace: tools +data: + veilleur.py: | +{{ .Files.Get "veilleur/veilleur.py" | indent 4 }} +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: veilleur-prometheus + namespace: tools +spec: + schedule: "*/5 * * * *" + # Un guetteur qui se double dérange deux fois pour une seule panne. + concurrencyPolicy: Forbid + # Si le contrôleur a pris du retard (nœud saturé), on saute le tour plutôt que + # d'en rejouer une pile d'un coup. + startingDeadlineSeconds: 120 + successfulJobsHistoryLimit: 1 + # ⚠ On garde les échecs : un Job `Failed` qui reste dans le cluster EST + # l'auto-plainte du veilleur quand il n'a pas pu joindre Telegram. + failedJobsHistoryLimit: 5 + jobTemplate: + spec: + # 3 observations × 90 s + marge. + activeDeadlineSeconds: 420 + # ⚠ Pas de reprise : une reprise renverrait le même message Telegram. Le + # tour suivant (5 min) est la reprise. + backoffLimit: 0 + template: + metadata: + labels: + app.kubernetes.io/name: veilleur-prometheus + spec: + restartPolicy: Never + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 65534 + runAsGroup: 65534 + containers: + - name: veilleur + image: python:3.13-alpine + imagePullPolicy: IfNotPresent + command: ["python3", "/veilleur/veilleur.py"] + env: + # Le jeton et le salon viennent du MÊME Secret que celui + # d'Alertmanager (`alertmanager-telegram`, synchronisé depuis + # Vault kvv2/prospection/telegram par le VaultStaticSecret de + # templates/vault-telegram.yaml). Un second chemin de secret + # serait un second chemin à réparer. + - name: BOT_TOKEN + valueFrom: + secretKeyRef: + name: alertmanager-telegram + key: BOT_TOKEN + - name: CHAT_ID + valueFrom: + secretKeyRef: + name: alertmanager-telegram + key: CHAT_ID + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + memory: 96Mi + volumeMounts: + - name: veilleur + mountPath: /veilleur + readOnly: true + volumes: + - name: veilleur + configMap: + name: veilleur-prometheus diff --git a/prometheus/values.yaml b/prometheus/values.yaml index d062f75..05f2f47 100644 --- a/prometheus/values.yaml +++ b/prometheus/values.yaml @@ -1265,6 +1265,64 @@ prometheus: &prometheus_config 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." + # Le veilleur (arcodange-org/tools#36). Du 2026-09-04 02:41 au + # 2026-09-07 07:37, Prometheus n'a rien enregistré et les 11 règles + # ci-dessus se sont TUES — sans échantillon frais, une règle ne se + # déclenche pas. Sur Telegram, ce silence est indiscernable du bon + # fonctionnement. + # + # ⚠⚠ CES DEUX RÈGLES NE SONT PAS LE REMÈDE : elles vivent dans le + # Prometheus surveillé, donc elles sont muettes exactement quand il + # faudrait qu'elles parlent. Le remède est le CronJob + # `veilleur-prometheus` (templates/veilleur.yaml), qui interroge + # Prometheus depuis l'EXTÉRIEUR et pousse lui-même sur Telegram. + # Ces deux règles-ci sont ce que le CronJob observe et ce qui le + # complète — rien de plus. + - name: veilleur + rules: + - alert: Veilleur + # Témoin toujours allumé (dead man's switch). Il ne dit rien de + # l'état du système : il est TOUJOURS vrai. C'est son ABSENCE + # dans Alertmanager qui est le signal, et c'est le CronJob + # `veilleur-prometheus` qui la constate depuis dehors. + # + # ⚠ `vector(1)` ne lit AUCUNE série : ce témoin aurait continué + # de tirer pendant les 3 j 5 h de panne. Il prouve que la chaîne + # évaluation → Alertmanager vit, jamais que le TSDB enregistre. + # C'est le contrôle de fraîcheur du CronJob qui garde ça. + # + # Il ne part PAS sur Telegram : la route `veilleur` d'Alertmanager + # l'envoie au récepteur vide (cf. alertmanager.config plus bas). + # Un témoin qui sonne toutes les 3 h serait un témoin qu'on coupe. + expr: vector(1) + labels: + severity: none + app: veilleur + annotations: + summary: "Témoin toujours allumé — c'est son absence qui est le signal" + description: "Si cette alerte disparaît d'Alertmanager, l'évaluation des règles de Prometheus ou sa livraison vers Alertmanager est arrêtée : AUCUNE alerte ne partirait plus." + - alert: VeilleurMuet + # L'auto-plainte du veilleur, deuxième moitié : il pousse un + # battement au Pushgateway à chaque exécution menée à son terme. + # 1200 s = 4 tours de CronJob (*/5). + # + # ⚠ Cette règle est DANS le Prometheus surveillé : elle ne peut + # rien dire d'un Prometheus mort — ce n'est pas son travail, c'est + # celui du CronJob. Elle couvre le cas inverse : Prometheus en + # pleine forme et le veilleur, lui, à l'arrêt (CronJob suspendu, + # image introuvable, Telegram injoignable). Le cas « les deux à la + # fois » reste NON COUVERT : il faudrait un second canal, + # indépendant de ce homelab. + expr: >- + time() - veilleur_prometheus_derniere_reussite_timestamp_seconds > 1200 + or absent(veilleur_prometheus_derniere_reussite_timestamp_seconds) + for: 5m + labels: + severity: critical + app: veilleur + annotations: + summary: "Veilleur — le guetteur extérieur de Prometheus ne rapporte plus" + description: "Le CronJob veilleur-prometheus (ns tools) n'a pas mené une exécution à son terme depuis plus de 20 min. Tant qu'il est muet, une panne d'enregistrement de Prometheus redeviendrait invisible. Vérifier : kubectl -n tools get cronjob,jobs -l app.kubernetes.io/name=veilleur-prometheus puis les journaux du dernier Job." # groups: # - name: Instances # rules: @@ -1345,7 +1403,19 @@ prometheus: &prometheus_config group_interval: 5m repeat_interval: 3h receiver: telegram + routes: + # Le témoin toujours allumé ne dérange personne : il est là pour être + # CONSTATÉ par le CronJob veilleur-prometheus via /api/v2/alerts, pas + # pour être lu. Sans cette route il sonnerait toutes les 3 h, et un + # témoin qui sonne est un témoin qu'on finit par couper. + - matchers: + - alertname="Veilleur" + receiver: neant + group_wait: 0s + repeat_interval: 8760h receivers: + # Récepteur vide = trou noir. C'est la forme prévue par Alertmanager. + - name: neant - name: telegram telegram_configs: - bot_token_file: /etc/alertmanager/telegram/BOT_TOKEN diff --git a/prometheus/veilleur/test_veilleur.py b/prometheus/veilleur/test_veilleur.py new file mode 100644 index 0000000..295e588 --- /dev/null +++ b/prometheus/veilleur/test_veilleur.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""La gate du veilleur — elle rejoue la panne du 4 au 7 septembre 2026. + + python3 -m unittest discover -s prometheus/veilleur -v + +Elle lance le VRAI `veilleur.py` en sous-processus, contre une doublure HTTP qui +rend les réponses MESURÉES pendant l'incident (arcodange-org/tools#36), et elle +relève le code de sortie et les messages effectivement poussés. + +⚠ Ce qu'elle ne rejoue PAS : la couche ext4/Longhorn qui a causé la panne. Elle +rejoue la SURFACE que la panne présentait — c'est-à-dire tout ce que le veilleur +peut voir depuis l'extérieur. + +⚠ Un test qui ne peut pas rougir ne prouve rien. Les deux moitiés se sabotent : +retirer la branche du vecteur vide de `observer()` doit faire ROUGIR +`test_cas_c_repond_bien_mais_n_enregistre_rien`, et le nominal doit rester VERT. +""" + +import http.server +import json +import os +import socket +import subprocess +import sys +import threading +import unittest + +ICI = os.path.dirname(os.path.abspath(__file__)) +VEILLEUR = os.path.join(ICI, "veilleur.py") + +# La réponse EXACTE que rendait Prometheus pendant les 3 j 5 h : un succès, un +# vecteur vide. C'est le cœur du défaut — il ne ressemble pas à une panne. +VECTEUR_VIDE = {"status": "success", "data": {"resultType": "vector", "result": []}} + + +def vecteur(valeur): + return { + "status": "success", + "data": {"resultType": "vector", "result": [{"metric": {}, "value": [0, str(valeur)]}]}, + } + + +class Doublure(http.server.BaseHTTPRequestHandler): + """Prometheus + Alertmanager + Pushgateway + l'API Telegram, en un serveur.""" + + def log_message(self, *_args): + pass + + def _rendre(self, code, charge): + corps = json.dumps(charge).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(corps))) + self.end_headers() + self.wfile.write(corps) + + def do_GET(self): + etat = self.server.etat + if self.path.startswith("/api/v1/query"): + etat["appels_query"] += 1 + reponses = etat["reponses_query"] + i = min(etat["appels_query"] - 1, len(reponses) - 1) + return self._rendre(200, reponses[i]) + if self.path.startswith("/api/v1/targets"): + # 23 cibles annoncées vivantes — le chiffre mesuré pendant la panne. + return self._rendre( + 200, + {"status": "success", "data": {"activeTargets": [{"health": "up"}] * 23}}, + ) + if self.path.startswith("/api/v2/alerts"): + return self._rendre(200, etat["alertes"]) + return self._rendre(404, {"erreur": self.path}) + + def do_POST(self): + etat = self.server.etat + taille = int(self.headers.get("Content-Length", "0")) + charge = self.rfile.read(taille).decode("utf-8") + if "/sendMessage" in self.path: + etat["messages"].append(charge) + return self._rendre(200, {"ok": True, "result": {"message_id": len(etat["messages"])}}) + return self._rendre(404, {"erreur": self.path}) + + def do_PUT(self): + taille = int(self.headers.get("Content-Length", "0")) + self.server.etat["battements"].append(self.rfile.read(taille).decode("utf-8")) + self._rendre(200, {"ok": True}) + + +def port_ferme(): + """Un port sur lequel personne n'écoute : c'est (a), Prometheus mort.""" + prise = socket.socket() + prise.bind(("127.0.0.1", 0)) + numero = prise.getsockname()[1] + prise.close() + return numero + + +class BancDuVeilleur(unittest.TestCase): + def setUp(self): + self.serveur = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Doublure) + self.serveur.etat = { + "reponses_query": [vecteur(5.7)], + "appels_query": 0, + "alertes": [{"labels": {"alertname": "Veilleur"}, "status": {"state": "active"}}], + "messages": [], + "battements": [], + } + threading.Thread(target=self.serveur.serve_forever, daemon=True).start() + self.base = "http://127.0.0.1:%d" % self.serveur.server_address[1] + + def tearDown(self): + self.serveur.shutdown() + self.serveur.server_close() + + def lancer(self, **surcharges): + environnement = dict(os.environ) + environnement.update( + { + "PROMETHEUS_URL": self.base, + "ALERTMANAGER_URL": self.base, + "PUSHGATEWAY_URL": self.base, + "TELEGRAM_API": self.base, + "BOT_TOKEN": "jeton-de-banc", + "CHAT_ID": "0", + "TENTATIVES": "1", + "ENTRE_TENTATIVES_S": "0", + "TIMEOUT_S": "3", + } + ) + environnement.update({k: str(v) for k, v in surcharges.items()}) + try: + fini = subprocess.run( + [sys.executable, VEILLEUR], + env=environnement, + capture_output=True, + text=True, + timeout=60, + ) + except subprocess.TimeoutExpired: + # ⚠ Un sous-processus TUÉ ne rend pas un code : sans cette branche, + # l'assertion « il a crié » serait satisfaite par sa MORT. + self.fail("le veilleur n'a pas rendu la main en 60 s — verdict impossible") + return fini + + def messages(self): + return self.serveur.etat["messages"] + + # --- (c) le défaut RÉEL : il répond bien, il n'enregistre rien ------------- + def test_cas_c_repond_bien_mais_n_enregistre_rien(self): + self.serveur.etat["reponses_query"] = [VECTEUR_VIDE] + fini = self.lancer() + self.assertEqual(fini.returncode, 0, fini.stdout + fini.stderr) + self.assertEqual(len(self.messages()), 1, "le veilleur devait crier UNE fois") + self.assertIn("enregistre+RIEN", self.messages()[0].replace("%20", "+")) + # Le message doit porter la contradiction qui a ouvert l'enquête. + self.assertIn("23", self.messages()[0]) + + # --- (b) il répond, ses données sont périmées ------------------------------ + def test_cas_b_donnees_perimees(self): + self.serveur.etat["reponses_query"] = [vecteur(277500)] # 3 j 5 h + fini = self.lancer() + self.assertEqual(fini.returncode, 0, fini.stdout + fini.stderr) + self.assertEqual(len(self.messages()), 1) + self.assertIn("retarde", self.messages()[0]) + self.assertIn("3+j", self.messages()[0].replace("%20", "+")) + + # --- (a) il est mort ------------------------------------------------------ + def test_cas_a_prometheus_injoignable(self): + fini = self.lancer(PROMETHEUS_URL="http://127.0.0.1:%d" % port_ferme()) + self.assertEqual(fini.returncode, 0, fini.stdout + fini.stderr) + self.assertEqual(len(self.messages()), 1) + self.assertIn("injoignable", self.messages()[0]) + + # --- la chaîne d'alerte est muette ---------------------------------------- + def test_temoin_disparu_d_alertmanager(self): + self.serveur.etat["alertes"] = [] + fini = self.lancer() + self.assertEqual(fini.returncode, 0, fini.stdout + fini.stderr) + self.assertEqual(len(self.messages()), 1) + self.assertIn("muette", self.messages()[0]) + + # --- LE SILENCE NOMINAL --------------------------------------------------- + # Un veilleur qui parle quand tout va bien est pire qu'inutile : on apprend + # à ne plus l'ouvrir, et il redevient le silence qu'il devait supprimer. + def test_se_tait_quand_tout_va_bien(self): + fini = self.lancer() + self.assertEqual(fini.returncode, 0, fini.stdout + fini.stderr) + self.assertEqual(self.messages(), [], "le veilleur a parlé pour rien") + self.assertEqual(len(self.serveur.etat["battements"]), 1, "battement non poussé") + self.assertIn("veilleur_prometheus_alarmes 0", self.serveur.etat["battements"][0]) + + # --- il ne réveille personne pour un redéploiement ------------------------- + def test_tolere_un_roulement_du_pod(self): + self.serveur.etat["reponses_query"] = [VECTEUR_VIDE, vecteur(4.2)] + fini = self.lancer(TENTATIVES=3, ENTRE_TENTATIVES_S="0.1") + self.assertEqual(fini.returncode, 0, fini.stdout + fini.stderr) + self.assertEqual(self.messages(), [], "une panne transitoire a réveillé le fondateur") + + # --- l'auto-plainte : il ne peut pas crier --------------------------------- + def test_se_plaint_quand_telegram_est_injoignable(self): + self.serveur.etat["reponses_query"] = [VECTEUR_VIDE] + fini = self.lancer(TELEGRAM_API="http://127.0.0.1:%d" % port_ferme()) + self.assertEqual(fini.returncode, 2, fini.stdout + fini.stderr) + self.assertIn("n'a PAS PU crier", fini.stdout) + # Pas de battement : la règle VeilleurMuet finira par tirer. + self.assertEqual(self.serveur.etat["battements"], []) + + # --- il ne se croit pas sur parole ---------------------------------------- + def test_refuse_de_tourner_sans_jeton(self): + fini = self.lancer(BOT_TOKEN="") + self.assertEqual(fini.returncode, 1, fini.stdout + fini.stderr) + self.assertIn("BOT_TOKEN", fini.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/prometheus/veilleur/veilleur.py b/prometheus/veilleur/veilleur.py new file mode 100644 index 0000000..85575be --- /dev/null +++ b/prometheus/veilleur/veilleur.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 +"""Le veilleur — il transforme le silence de Prometheus en bruit sur Telegram. + +Pourquoi il existe (arcodange-org/tools#36) +------------------------------------------- +Du 2026-09-04 02:41 au 2026-09-07 07:37, le système de fichiers du WAL de +Prometheus est passé en lecture seule. Pendant 3 j 5 h, RIEN n'a été +enregistré — et rien n'a prévenu, parce que les 11 règles d'alerte vivent +DANS ce Prometheus : sans échantillon frais, une règle ne se déclenche pas, +elle se TAIT. Sur Telegram, le silence d'une alerte est indiscernable du bon +fonctionnement. + +Ce programme est donc DÉLIBÉRÉMENT hors du chemin d'alerte de Prometheus : +il interroge Prometheus depuis l'extérieur et pousse lui-même sur Telegram, +sans passer par Alertmanager. Un CronJob le réveille toutes les 5 minutes. + +Les trois modes de panne qu'il doit couvrir +------------------------------------------- + (a) Prometheus est mort → l'appel HTTP échoue + (b) Prometheus répond, données périmées → l'âge dépasse le seuil + (c) Prometheus répond PARFAITEMENT, ses 23 cibles sont annoncées `up`, + et il n'enregistre rien → la requête rend un VECTEUR VIDE + +⚠⚠ Le (c) est celui qui est ARRIVÉ. Voir le commentaire de `observer()`. + +Ce qu'il ne prétend pas être +---------------------------- +Il ne surveille pas la CAUSE (ext4, Longhorn, le disque). Il surveille le +SYMPTÔME qui compte : « Prometheus n'enregistre plus ». La cause première du +passage en lecture seule n'est toujours pas établie (issue #36). +""" + +import json +import os +import socket +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +# --- Réglages, tous surchargeables par l'environnement (c'est ce qui rend le +# --- sabotage possible sans toucher au code : cf. le tableau de la PR). +PROMETHEUS_URL = os.environ.get("PROMETHEUS_URL", "http://prometheus-server:80") +ALERTMANAGER_URL = os.environ.get("ALERTMANAGER_URL", "http://prometheus-alertmanager:9093") +PUSHGATEWAY_URL = os.environ.get("PUSHGATEWAY_URL", "http://prometheus-prometheus-pushgateway:9091") +TELEGRAM_API = os.environ.get("TELEGRAM_API", "https://api.telegram.org") + +# `up` est la seule métrique dont l'ABSENCE est un défaut certain : Prometheus +# l'écrit lui-même à chaque scrutation de chaque cible. Pas de cible = pas de +# Prometheus utile ; cible scrutée mais rien d'écrit = le défaut du 4 septembre. +REQUETE_FRAICHEUR = os.environ.get("REQUETE_FRAICHEUR", "time() - max(timestamp(up))") +# Sert UNIQUEMENT à enrichir le message quand l'alarme est déjà décidée : une +# sous-requête sur 12 h coûte trop cher pour être le test principal. +REQUETE_AGE = os.environ.get( + "REQUETE_AGE", "time() - max(max_over_time(timestamp(up)[12h:1m]))" +) + +# 180 s = 3 intervalles de scrutation (`scrape_interval: 1m` dans values.yaml). +# Assez lâche pour absorber une scrutation ratée et un rechargement de config +# (sub-seconde) ; assez serré pour voir un Prometheus qui RALENTIT avant qu'il +# ne devienne muet. Cf. `observer()` pour la borne des 5 minutes qui lui est +# imposée par `--query.lookback-delta`. +SEUIL_FRAICHEUR_S = float(os.environ.get("SEUIL_FRAICHEUR_S", "180")) + +# Le témoin toujours allumé, déclaré dans prometheus/values.yaml. Sa PRÉSENCE +# dans Alertmanager prouve que la chaîne règle → Alertmanager vit encore. +ALERTE_TEMOIN = os.environ.get("ALERTE_TEMOIN", "Veilleur") +VERIFIER_TEMOIN = os.environ.get("VERIFIER_TEMOIN", "oui") == "oui" + +# Trois observations espacées de 90 s : un roulement du pod Prometheus (arrêt, +# rattachement du volume Longhorn, rejeu du WAL — 14,0 s mesurés sur un WAL de +# 3 jours) dure moins que ça. Sans cette tolérance, CHAQUE synchronisation +# ArgoCD enverrait une fausse alarme — et on apprendrait à ignorer le veilleur, +# ce qui le rendrait pire qu'absent. +TENTATIVES = int(os.environ.get("TENTATIVES", "3")) +ENTRE_TENTATIVES_S = float(os.environ.get("ENTRE_TENTATIVES_S", "90")) +TIMEOUT_S = float(os.environ.get("TIMEOUT_S", "10")) + +BOT_TOKEN = os.environ.get("BOT_TOKEN", "") +CHAT_ID = os.environ.get("CHAT_ID", "") +# Préfixe du message : les essais de sabotage le portent pour que le fondateur +# distingue un exercice d'une vraie panne. +PREFIXE = os.environ.get("PREFIXE", "") + +JOB_PUSHGATEWAY = os.environ.get("JOB_PUSHGATEWAY", "veilleur-prometheus") +POUSSER_TEMOIN = os.environ.get("POUSSER_TEMOIN", "oui") == "oui" + + +def journal(message): + """Tout passe par ici : les journaux du Job sont la seule trace qui reste + quand Telegram est injoignable.""" + print("%s %s" % (time.strftime("%Y-%m-%dT%H:%M:%S%z"), message), flush=True) + + +def http(url, donnees=None, entetes=None, methode=None): + requete = urllib.request.Request( + url, data=donnees, headers=entetes or {}, method=methode + ) + with urllib.request.urlopen(requete, timeout=TIMEOUT_S) as reponse: + return reponse.status, reponse.read().decode("utf-8", "replace") + + +def interroger(expression): + """Une requête instantanée. Rend le corps JSON décodé, ou LÈVE.""" + url = "%s/api/v1/query?%s" % ( + PROMETHEUS_URL.rstrip("/"), + urllib.parse.urlencode({"query": expression}), + ) + _, corps = http(url) + return json.loads(corps) + + +def duree_lisible(secondes): + secondes = int(secondes) + jours, reste = divmod(secondes, 86400) + heures, reste = divmod(reste, 3600) + minutes, secondes = divmod(reste, 60) + morceaux = [] + if jours: + morceaux.append("%d j" % jours) + if heures: + morceaux.append("%d h" % heures) + if minutes: + morceaux.append("%d min" % minutes) + morceaux.append("%d s" % secondes) + return " ".join(morceaux) + + +def age_du_dernier_echantillon(): + """Enrichit le message d'alarme. N'est JAMAIS ce qui décide de l'alarme : + si cette requête échoue, on le dit et on continue.""" + try: + reponse = interroger(REQUETE_AGE) + resultat = reponse.get("data", {}).get("result", []) + if not resultat: + return "aucun échantillon de `up` dans les 12 dernières heures" + return "dernier échantillon il y a %s" % duree_lisible( + float(resultat[0]["value"][1]) + ) + except Exception as erreur: + return "âge indéterminable (%s)" % type(erreur).__name__ + + +def cibles_annoncees(): + """Le contraste qui a ouvert l'enquête du 7 septembre : 23 cibles vivantes + en face de zéro série enregistrée.""" + try: + _, corps = http("%s/api/v1/targets?state=active" % PROMETHEUS_URL.rstrip("/")) + actives = json.loads(corps).get("data", {}).get("activeTargets", []) + montantes = [c for c in actives if c.get("health") == "up"] + return "%d cibles actives, %d annoncées `up`" % (len(actives), len(montantes)) + except Exception as erreur: + return "cibles indénombrables (%s)" % type(erreur).__name__ + + +def observer(): + """Rend la liste des alarmes. Liste vide = tout va bien, et le veilleur SE TAIT.""" + alarmes = [] + + try: + reponse = interroger(REQUETE_FRAICHEUR) + except Exception as erreur: + # (a) — Prometheus est mort, ou le réseau vers lui est coupé. + return [ + ( + "Prometheus est injoignable", + "%s sur %s\n%s: %s" + % (REQUETE_FRAICHEUR, PROMETHEUS_URL, type(erreur).__name__, erreur), + ) + ] + + if reponse.get("status") != "success": + return [ + ( + "Prometheus refuse la requête", + "%s\n%s" % (REQUETE_FRAICHEUR, json.dumps(reponse)[:500]), + ) + ] + + resultat = reponse.get("data", {}).get("result", []) + + # ⚠⚠ LE PIÈGE EST ICI, ET C'EST LE DÉFAUT RÉEL DU 4 AU 7 SEPTEMBRE 2026. + # + # Quelqu'un finira par vouloir simplifier ces lignes — en « GET /-/healthy », + # ou en « age = float(resultat[0]["value"][1]) ; if age > SEUIL ». LES DEUX + # SONT AVEUGLES au défaut qui est arrivé. + # + # Pendant 3 j 5 h, avec le WAL en lecture seule : + # /-/healthy → 200, « Prometheus Server is Healthy. » + # /api/v1/targets → 23 cibles, presque toutes `up` + # le pod → Running 2/2, 0 redémarrage, 11 jours d'âge + # Longhorn → volume `attached`, robustesse `healthy` + # count(up) → AUCUNE SÉRIE + # + # Un veilleur branché sur /-/healthy n'aurait RIEN vu. Un veilleur qui lit + # `resultat[0]` sans regarder lèverait un IndexError — ou, avec un `if + # resultat:` posé par politesse, tomberait dans la branche « rien à + # signaler » et rassurerait pendant trois jours de plus. + # + # LE VECTEUR VIDE EST L'ALARME, jamais un cas limite à ignorer. Et il ne + # l'est pas par accident : `--query.lookback-delta` vaut 5 min par défaut, + # donc dès que le dernier échantillon a plus de 5 minutes, `up` ne rend + # PLUS RIEN. La branche « âge > seuil » ci-dessous ne peut mesurer qu'entre + # 0 et 5 minutes de retard ; au-delà, c'est TOUJOURS ici que ça se joue. + if not resultat: + alarmes.append( + ( + "Prometheus répond, mais n'enregistre RIEN", + "`%s` ne rend aucune série.\n%s\n%s" + % (REQUETE_FRAICHEUR, age_du_dernier_echantillon(), cibles_annoncees()), + ) + ) + else: + age = float(resultat[0]["value"][1]) + if age > SEUIL_FRAICHEUR_S: + # (b) — il enregistre encore, mais il RALENTIT. Fenêtre étroite + # (0 à 5 min, cf. ci-dessus) : c'est un signal précoce, pas le filet. + alarmes.append( + ( + "Prometheus retarde", + "dernier échantillon il y a %s (seuil : %s)\n%s" + % ( + duree_lisible(age), + duree_lisible(SEUIL_FRAICHEUR_S), + cibles_annoncees(), + ), + ) + ) + else: + journal("fraîcheur : %.1f s (seuil %.0f s) — rien à signaler" % (age, SEUIL_FRAICHEUR_S)) + + if VERIFIER_TEMOIN: + alarmes.extend(observer_le_temoin()) + + return alarmes + + +def observer_le_temoin(): + """La règle `Veilleur` (expr: vector(1)) part en continu vers Alertmanager. + Sa présence prouve que la chaîne évaluation → Alertmanager vit. + + ⚠ Elle NE PROUVE PAS que le TSDB enregistre : `vector(1)` ne lit pas une + seule série. Pendant les 3 j 5 h de panne, cette règle aurait continué de + tirer sans faillir. C'est le contrôle de fraîcheur ci-dessus qui attrape la + panne réelle ; le témoin attrape l'autre moitié — un Alertmanager mort ou + une route cassée, que le contrôle de fraîcheur ne voit pas, puisque le + veilleur pousse sur Telegram sans passer par lui.""" + url = "%s/api/v2/alerts?%s" % ( + ALERTMANAGER_URL.rstrip("/"), + urllib.parse.urlencode( + { + "active": "true", + "silenced": "false", + "inhibited": "false", + "filter": 'alertname="%s"' % ALERTE_TEMOIN, + } + ), + ) + try: + _, corps = http(url) + alertes = json.loads(corps) + except Exception as erreur: + return [ + ( + "Alertmanager est injoignable", + "%s\n%s: %s" % (ALERTMANAGER_URL, type(erreur).__name__, erreur), + ) + ] + + vivantes = [ + a for a in alertes if a.get("status", {}).get("state") == "active" + ] + if not vivantes: + return [ + ( + "La chaîne d'alerte de Prometheus est muette", + "le témoin toujours allumé `%s` n'est plus dans Alertmanager : " + "l'évaluation des règles ou la livraison vers Alertmanager est " + "arrêtée. Une VRAIE alerte ne partirait pas non plus." % ALERTE_TEMOIN, + ) + ] + journal("témoin `%s` : %d alerte(s) active(s) dans Alertmanager" % (ALERTE_TEMOIN, len(vivantes))) + return [] + + +def crier(alarmes): + """Pousse sur Telegram, sans passer par Alertmanager. LÈVE si l'envoi rate : + c'est l'auto-plainte du veilleur (cf. `principal`).""" + lignes = ["%s⚠ Veilleur — Prometheus" % PREFIXE] + for titre, detail in alarmes: + lignes.append("") + lignes.append("%s" % echapper(titre)) + lignes.append(echapper(detail)) + lignes.append("") + lignes.append( + echapper( + "Le veilleur tourne HORS de Prometheus (CronJob %s, ns tools) et pousse " + "en direct : ce message n'est pas passé par Alertmanager." % JOB_PUSHGATEWAY + ) + ) + texte = "\n".join(lignes) + + charge = urllib.parse.urlencode( + {"chat_id": CHAT_ID, "text": texte, "parse_mode": "HTML"} + ).encode("utf-8") + statut, corps = http( + "%s/bot%s/sendMessage" % (TELEGRAM_API.rstrip("/"), BOT_TOKEN), + donnees=charge, + entetes={"Content-Type": "application/x-www-form-urlencoded"}, + methode="POST", + ) + reponse = json.loads(corps) + if not reponse.get("ok"): + raise RuntimeError("Telegram a refusé : HTTP %s %s" % (statut, corps[:300])) + journal("Telegram a accepté : message_id=%s" % reponse["result"]["message_id"]) + return reponse["result"]["message_id"] + + +def echapper(texte): + return ( + str(texte).replace("&", "&").replace("<", "<").replace(">", ">") + ) + + +def pousser_le_battement(nb_alarmes): + """Auto-plainte, deuxième moitié : le veilleur laisse une trace de sa propre + exécution au Pushgateway. La règle `VeilleurMuet` (values.yaml) crie quand + ce battement vieillit. + + ⚠ Cette moitié-là est DANS le Prometheus surveillé : elle ne peut rien dire + d'un Prometheus mort. Elle couvre l'autre cas — un Prometheus en pleine + forme et un veilleur qui, lui, ne tourne plus (CronJob suspendu, image + introuvable, quota). Le cas « les deux à la fois » reste NON COUVERT : il + faudrait un second canal, indépendant de ce homelab.""" + corps = ( + "# TYPE veilleur_prometheus_derniere_reussite_timestamp_seconds gauge\n" + "veilleur_prometheus_derniere_reussite_timestamp_seconds %d\n" + "# TYPE veilleur_prometheus_alarmes gauge\n" + "veilleur_prometheus_alarmes %d\n" + ) % (int(time.time()), nb_alarmes) + http( + "%s/metrics/job/%s" % (PUSHGATEWAY_URL.rstrip("/"), JOB_PUSHGATEWAY), + donnees=corps.encode("utf-8"), + entetes={"Content-Type": "text/plain"}, + methode="PUT", + ) + journal("battement poussé au Pushgateway (alarmes=%d)" % nb_alarmes) + + +def principal(): + if not BOT_TOKEN or not CHAT_ID: + journal("ERREUR — BOT_TOKEN ou CHAT_ID manquant : le veilleur ne peut pas crier.") + return 1 + + alarmes = [] + for tentative in range(1, TENTATIVES + 1): + alarmes = observer() + if not alarmes: + journal("observation %d/%d : rien à signaler" % (tentative, TENTATIVES)) + break + journal( + "observation %d/%d : %d alarme(s) — %s" + % (tentative, TENTATIVES, len(alarmes), " | ".join(t for t, _ in alarmes)) + ) + if tentative < TENTATIVES: + journal("nouvelle observation dans %.0f s" % ENTRE_TENTATIVES_S) + time.sleep(ENTRE_TENTATIVES_S) + + if not alarmes: + # LE SILENCE EST LE COMPORTEMENT NOMINAL. Un veilleur qui parle tous les + # jours devient un veilleur qu'on n'ouvre plus. + journal("tout va bien — aucun message envoyé.") + if POUSSER_TEMOIN: + try: + pousser_le_battement(0) + except Exception as erreur: + journal("battement non poussé (%s) — sans conséquence sur l'alarme" % erreur) + return 0 + + try: + crier(alarmes) + except Exception as erreur: + # AUTO-PLAINTE, première moitié : sortie non nulle → le Job passe en + # `Failed` et il RESTE dans le cluster (failedJobsHistoryLimit), avec + # ces journaux. Aucun battement n'est poussé, donc `VeilleurMuet` + # finira par tirer si Prometheus, lui, va bien. + journal( + "ERREUR — le veilleur n'a PAS PU crier : %s: %s" % (type(erreur).__name__, erreur) + ) + for titre, detail in alarmes: + journal(" alarme non livrée — %s : %s" % (titre, detail.replace("\n", " "))) + return 2 + + if POUSSER_TEMOIN: + try: + pousser_le_battement(len(alarmes)) + except Exception as erreur: + journal("battement non poussé (%s) — l'alarme, elle, est partie" % erreur) + return 0 + + +if __name__ == "__main__": + socket.setdefaulttimeout(TIMEOUT_S) + sys.exit(principal())