Un veilleur HORS de Prometheus — le silence de trois jours devient du bruit en ≤ 8 min #39

Merged
arcodange merged 3 commits from arcodange/veilleur-prometheus into main 2026-09-07 10:55:17 +02:00
6 changed files with 847 additions and 0 deletions
Showing only changes of commit 88e0f5e862 - Show all commits
+25
View File
@@ -193,6 +193,31 @@ jobs:
}} }}
run: helm template $chart --debug run: helm template $chart --debug
# ⚠⚠ UNE GATE QUI N'EST CÂBLÉE NULLE PART N'EXISTE PAS (tools#36). Le banc
# du veilleur — celui qui rejoue les trois modes de la panne du 4 au 7
# septembre — tourne donc ICI, dans le job qui construit déjà le chart
# prometheus, à chaque PR qui le touche. Stdlib Python seule : rien à
# installer, rien à télécharger.
- 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
- name: publish ${{ matrix.chart }} helm chart - name: publish ${{ matrix.chart }} helm chart
if: ${{ contains(fromJSON('["","push"]'), github.event_name) && github.ref == 'refs/heads/main' }} if: ${{ contains(fromJSON('["","push"]'), github.event_name) && github.ref == 'refs/heads/main' }}
run: | run: |
+23
View File
@@ -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/
+108
View File
@@ -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
+70
View File
@@ -1265,6 +1265,64 @@ prometheus: &prometheus_config
annotations: annotations:
summary: "Homelab — cert {{ $labels.namespace }}/{{ $labels.name }} expire dans {{ $value | humanizeDuration }}" 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." 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: # groups:
# - name: Instances # - name: Instances
# rules: # rules:
@@ -1345,7 +1403,19 @@ prometheus: &prometheus_config
group_interval: 5m group_interval: 5m
repeat_interval: 3h repeat_interval: 3h
receiver: telegram 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: receivers:
# Récepteur vide = trou noir. C'est la forme prévue par Alertmanager.
- name: neant
- name: telegram - name: telegram
telegram_configs: telegram_configs:
- bot_token_file: /etc/alertmanager/telegram/BOT_TOKEN - bot_token_file: /etc/alertmanager/telegram/BOT_TOKEN
+216
View File
@@ -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()
+405
View File
@@ -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("<b>%s</b>" % 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
)
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é- 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())