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
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:
@@ -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()
|
||||
@@ -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("&", "&").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())
|
||||
Reference in New Issue
Block a user