Un veilleur hors de Prometheus, pour que son silence cesse de ressembler au bon fonctionnement (#39)
Helm Charts / Detect changed charts (push) Successful in 10s
Helm Charts / Library charts tool (push) Skipped
Helm Charts / Application charts grafana (push) Skipped
Helm Charts / Application charts hashicorp-vault (push) Skipped
Helm Charts / Application charts minio (push) Skipped
Helm Charts / Application charts pgbouncer (push) Skipped
Helm Charts / Application charts pgcat (push) Skipped
Helm Charts / Application charts redis (push) Skipped
Helm Charts / Application charts chart (push) Skipped
Helm Charts / Application charts crowdsec (push) Skipped
Helm Charts / Application charts prometheus (push) Failing after 50s

Un CronJob toutes les 5 minutes interroge Prometheus depuis dehors et pousse
lui-même sur Telegram. Il couvre les trois morts, dont celle qui est arrivée :
répondre parfaitement en n'enregistrant rien.

Éprouvé en vrai sur le cluster : 187 s entre la panne armée et le message
accepté par Telegram, mesuré deux fois. Contre 4620 minutes de silence réel.

Ferme #36.

Co-Authored-By: Claude Opus 5 <[email protected]>
Co-authored-by: Gabriel Radureau <[email protected]>
This commit was merged in pull request #39.
This commit is contained in:
2026-09-07 10:55:16 +02:00
committed by arcodange
co-authored by Claude Opus 5
parent 249ff00d3a
commit dc48d48c1b
6 changed files with 905 additions and 0 deletions
+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é-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())