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]>
406 lines
16 KiB
Python
406 lines
16 KiB
Python
#!/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("&", "&").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())
|