Prometheus n'a rien enregistré pendant 3 jours, et rien ne pouvait le dire
Helm Charts / Library charts tool (pull_request) Skipped
Helm Charts / Application charts chart (pull_request) Skipped
Helm Charts / Application charts prometheus (pull_request) Failing after 7s
Helm Charts / Detect changed charts (pull_request) Successful in 19s
Helm Charts / Application charts crowdsec (pull_request) Skipped
Helm Charts / Application charts grafana (pull_request) Skipped
Helm Charts / Application charts hashicorp-vault (pull_request) Skipped
Helm Charts / Application charts minio (pull_request) Skipped
Helm Charts / Application charts pgbouncer (pull_request) Skipped
Helm Charts / Application charts pgcat (pull_request) Skipped
Helm Charts / Application charts redis (pull_request) Skipped
Helm Charts / Library charts tool (pull_request) Skipped
Helm Charts / Application charts chart (pull_request) Skipped
Helm Charts / Application charts prometheus (pull_request) Failing after 7s
Helm Charts / Detect changed charts (pull_request) Successful in 19s
Helm Charts / Application charts crowdsec (pull_request) Skipped
Helm Charts / Application charts grafana (pull_request) Skipped
Helm Charts / Application charts hashicorp-vault (pull_request) Skipped
Helm Charts / Application charts minio (pull_request) Skipped
Helm Charts / Application charts pgbouncer (pull_request) Skipped
Helm Charts / Application charts pgcat (pull_request) Skipped
Helm Charts / Application charts redis (pull_request) Skipped
Du 2026-09-04 02:41 au 2026-09-07 07:37, le WAL de Prometheus était sur un système de fichiers passé en lecture seule. Pendant 3 j 5 h il a continué de scruter ses 23 cibles et de JETER le résultat. Les 11 règles d'alerte du dépôt n'ont rien dit — elles vivent DANS ce Prometheus, elles évaluent des requêtes contre le TSDB qui ne recevait plus rien, et 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. ⚠⚠ AJOUTER UNE RÈGLE « PROMETHEUS VA MAL » NE CORRIGE RIEN : elle serait muette exactement quand il faudrait qu'elle parle. C'est le gate incapable d'échouer, celui qui rassure. Ce lot pose donc un guetteur HORS de Prometheus. Ce qui est livré ---------------- 1. `prometheus/veilleur/veilleur.py` + un CronJob `*/5` (templates/veilleur.yaml, rendu tel quel par ArgoCD comme vault-telegram.yaml). Il interroge Prometheus depuis dehors et POUSSE LUI-MÊME sur Telegram, sans passer par Alertmanager. Jeton et salon viennent du Secret `alertmanager-telegram` DÉJÀ synchronisé depuis Vault — aucun second chemin de secret, aucun jeton en clair. 2. Le témoin toujours allumé `Veilleur` (`expr: vector(1)`) et sa route vers un récepteur VIDE : il ne sonne jamais, il est là pour être CONSTATÉ dans /api/v2/alerts par le guetteur. ⚠ Il ne lit aucune série : il aurait continué de tirer pendant les 3 j 5 h. Il prouve la chaîne d'alerte, jamais le TSDB. 3. `VeilleurMuet`, l'auto-plainte : le guetteur pousse un battement au Pushgateway ; la règle crie quand il vieillit de plus de 20 min. ⚠⚠ LE PIÈGE, ÉCRIT DANS LE CODE À L'ENDROIT OÙ ON VOUDRA SIMPLIFIER : pendant la panne, /-/healthy rendait 200, /api/v1/targets annonçait 23 cibles presque toutes `up`, le pod était Running 2/2 avec 0 redémarrage, Longhorn se disait `healthy` — et `count(up)` ne rendait AUCUNE SÉRIE. Un veilleur branché sur /-/healthy n'aurait rien vu ; un veilleur qui lit `resultat[0]` sans regarder serait tombé dans la branche « rien à signaler ». LE VECTEUR VIDE EST L'ALARME. Et il l'est structurellement : `--query.lookback-delta` vaut 5 min, donc au-delà de 5 min de retard `up` ne rend PLUS RIEN — la branche « âge > seuil » ne peut mesurer qu'entre 0 et 5 min, jamais une panne de trois jours. La propriété gardée, et son arithmétique ---------------------------------------- « Si Prometheus cesse d'enregistrer, une notification arrive en ≤ 8 min 10 s. » seuil de fraîcheur 180 s (3 × `scrape_interval: 1m`) période du CronJob 300 s (*/5, pire cas : on vient de manquer un tour) tolérance au roulement 180 s (2 × 90 s — un redéploiement ne réveille personne) exécution ~7 s (mesuré) ------------------------------ au pire 487 s contre 4 620 minutes de silence réel. Les deux premiers termes ne s'additionnent pas : le seuil est absorbé par le vecteur vide dès la première observation. MESURÉ, deux fois, sur la planification RÉELLE et avec le cas (c) armé dans le CronJob déployé : du top de la planification au « Telegram a accepté », **187 s** (08:20:00 → 08:23:07, puis 08:30:02 → 08:33:09). Les 300 s d'attente du tour suivant ne sont PAS mesurées — elles dépendent de la phase à laquelle la panne commence ; elles sont bornées par le `schedule`. SABOTAGES — le banc unitaire (`python3 -m unittest discover -s prometheus/veilleur`) ------------------------------------------------------------------------------------ Il rejoue les réponses HTTP MESURÉES pendant l'incident. ⚠ Ce qu'il ne rejoue PAS : la couche ext4/Longhorn qui a causé la panne — il rejoue la SURFACE que la panne présentait, c'est-à-dire tout ce qu'un guetteur extérieur peut voir. | sabotage | code | ce qui rougit | ce qui reste vert | |---------------------------------------------------|------|----------------------------------|-------------------| | (aucun) | 0 | — | 8/8 | | S-A : on ignore le vecteur vide dans `observer()` | 1 | cas (c), auto-plainte | le silence nominal | | S-B : `age > -1` — il crie tout le temps | 1 | le silence nominal, la tolérance | les 6 alarmes | | S-C : route `Veilleur` → telegram au lieu de néant| 1 | l'assertion jq de la CI | — | | promtool `exp_alerts: []` sur le témoin | 1 | le test de règle | — | ⚠ La première tentative de S-A n'a PAS mordu comme prévu : elle a rougi sur une erreur de schéma YAML, pas sur l'assertion. Refaite pour être un vrai échec d'assertion. Un sabotage dont on ne vérifie pas qu'il mord au bon endroit rend un « rouge » qui ne prouve rien. ⚠⚠ ET LA GATE EST CÂBLÉE : elle tourne dans `.gitea/workflows/helmcharts.yaml`, dans le job qui construit déjà le chart prometheus, à chaque PR qui le touche. Une gate que la CI ne déclenche jamais est la cinquième forme du gate qui rassure. SABOTAGES — en vrai, sur le cluster (vrais Jobs, vrai Telegram) --------------------------------------------------------------- | # | mode | comment | verdict | code | délai | |---|---------------------------|------------------------------------------------|----------------------------------|------|---------| |L6 | nominal | rien | SE TAIT, 0 message | 0 | 18,7 s | |L1 | (a) Prometheus MORT | `scale deploy prometheus-server --replicas=0` | crie « injoignable » | 0 | 50 s | |L4 | (a) variante simple | URL vers un hôte inexistant | crie « injoignable » | 0 | 8,4 s | |L3 | (b) données périmées | requête rendant 277 500 s | crie « retarde », « 3 j 5 h » | 0 | 11,5 s | |L2 | (c) LE DÉFAUT RÉEL | sélecteur vide sur un Prometheus SAIN | crie « n'enregistre RIEN » | 0 | 31,5 s | |L5a| témoin présent | `ALERTE_TEMOIN` = une alerte vraiment active | SE TAIT | 0 | 8,6 s | |L5b| témoin absent | `ALERTE_TEMOIN` = un nom inexistant | crie « chaîne muette » | 0 | 8,4 s | |L7 | auto-plainte | Telegram injoignable + cas (c) | Job **Failed**, AUCUN battement | 2 | 12,6 s | Le message de L2 portait la contradiction elle-même : « 23 cibles actives, 21 annoncées `up` » en face de zéro série — le chiffre exact du matin. ⚠ CE QUE L1 NE REJOUE PAS : descendre le déploiement à 0 rend Prometheus injoignable, pas MENTEUR. Le défaut réel — répondre parfaitement en n'enregistrant rien — n'est PAS reproductible par un `scale` ; il l'est par L2, contre un Prometheus en pleine santé. Prometheus après les essais --------------------------- Remonté à 08:13:50Z, prêt à 08:14:08Z (18 s). Montage `rw`, 0 « input/output error » et 0 `level=ERROR` dans ses journaux, `count(up)` = 23 séries, fraîcheur du dernier point 0,94 s, `kadans_jobs_jobs` = 8 séries. ⚠ EFFET DE BORD ASSUMÉ ET NON RÉPARÉ : le pod a été replanifié de pi3 vers pi1. Le commentaire du groupe `homelab` de ce même fichier affirme « Prometheus vit sur pi3 et Alertmanager sur pi2 : cette chaîne d'alerte survit donc à la perte de pi1 ». Ce n'est plus vrai. Un `kubectl -n tools delete pod` le rejoue, sans garantie d'atterrissage. Le veilleur, lui, est un CronJob : il n'est lié à aucun nœud. Ce que ce lot ne couvre PAS --------------------------- - La CAUSE du passage en lecture seule reste inconnue (issue #36) : ce lot surveille le symptôme, pas le disque. - « Le veilleur en panne ET Prometheus en panne » : les deux moitiés de l'auto-plainte finissent sur Telegram, donc un Telegram cassé les emporte toutes les deux. Il faudrait un second canal, indépendant de ce homelab. - `prometheus/.helmignore` est ajouté au passage : l'étape de publication du chart fait `tar -X <chart>/.helmignore`, qui échouerait sur un chart qui n'en a pas. `grafana` et `redis` sont dans le même cas — pas traités ici. Refs: arcodange-org/tools#36 Remise en état -------------- Tous les objets d'essai ont été retirés du cluster (8 Jobs, le Job planifié, le CronJob, son ConfigMap, le groupe `veilleur-prometheus` du Pushgateway, et les trois fichiers écrits sur le volume Prometheus pour `promtool`). Le veilleur n'est donc PAS en service : c'est la fusion de cette PR qui le déploiera, par ArgoCD. Rien n'a été appliqué avec Tofu, aucun workflow Vault n'a été déclenché.
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