Un veilleur HORS de Prometheus — le silence de trois jours devient du bruit en ≤ 8 min #39
@@ -193,6 +193,31 @@ jobs:
|
|||||||
}}
|
}}
|
||||||
run: helm template $chart --debug
|
run: helm template $chart --debug
|
||||||
|
|
||||||
|
# ⚠⚠ UNE GATE QUI N'EST CÂBLÉE NULLE PART N'EXISTE PAS (tools#36). Le banc
|
||||||
|
# du veilleur — celui qui rejoue les trois modes de la panne du 4 au 7
|
||||||
|
# septembre — tourne donc ICI, dans le job qui construit déjà le chart
|
||||||
|
# prometheus, à chaque PR qui le touche. Stdlib Python seule : rien à
|
||||||
|
# installer, rien à télécharger.
|
||||||
|
- name: Banc du veilleur (chart prometheus)
|
||||||
|
if: >-
|
||||||
|
${{
|
||||||
|
matrix.chart == 'prometheus'
|
||||||
|
&& (
|
||||||
|
contains(fromJSON('["","pull_request"]'), github.event_name)
|
||||||
|
|| github.ref != 'refs/heads/main'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python3 -m unittest discover -s prometheus/veilleur -v
|
||||||
|
|
||||||
|
# Le témoin toujours allumé ne doit JAMAIS partir sur Telegram : sans
|
||||||
|
# sa route vers le récepteur vide il sonnerait toutes les 3 h, et un
|
||||||
|
# témoin qui sonne est un témoin qu'on coupe.
|
||||||
|
yq -o=json eval '.prometheus.alertmanager.config' prometheus/values.yaml > /tmp/am.json
|
||||||
|
jq -e '[.route.routes[] | select(.matchers[0] == "alertname=\"Veilleur\"") | .receiver] == ["neant"]' /tmp/am.json
|
||||||
|
jq -e '[.receivers[] | select(.name == "neant") | keys] == [["name"]]' /tmp/am.json
|
||||||
|
|
||||||
- name: publish ${{ matrix.chart }} helm chart
|
- name: publish ${{ matrix.chart }} helm chart
|
||||||
if: ${{ contains(fromJSON('["","push"]'), github.event_name) && github.ref == 'refs/heads/main' }}
|
if: ${{ contains(fromJSON('["","push"]'), github.event_name) && github.ref == 'refs/heads/main' }}
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Patterns to ignore when building packages.
|
||||||
|
# This supports shell glob matching, relative path matching, and
|
||||||
|
# negation (prefixed with !). Only one pattern per line.
|
||||||
|
.DS_Store
|
||||||
|
# Common VCS dirs
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.bzr/
|
||||||
|
.bzrignore
|
||||||
|
.hg/
|
||||||
|
.hgignore
|
||||||
|
.svn/
|
||||||
|
# Common backup files
|
||||||
|
*.swp
|
||||||
|
*.bak
|
||||||
|
*.tmp
|
||||||
|
*.orig
|
||||||
|
*~
|
||||||
|
# Various IDEs
|
||||||
|
.project
|
||||||
|
.idea/
|
||||||
|
*.tmproj
|
||||||
|
.vscode/
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Le veilleur — le guetteur qui vit HORS du chemin d'alerte de Prometheus.
|
||||||
|
#
|
||||||
|
# arcodange-org/tools#36 : du 2026-09-04 02:41 au 2026-09-07 07:37, Prometheus
|
||||||
|
# n'a rien enregistré (WAL en lecture seule) et RIEN N'A PRÉVENU — les 11 règles
|
||||||
|
# d'alerte vivent dans le Prometheus en panne, et sans échantillon frais une
|
||||||
|
# règle ne se déclenche pas : elle se tait.
|
||||||
|
#
|
||||||
|
# ⚠ C'est pour ça que ce guetteur n'est PAS une règle Prometheus de plus. Il
|
||||||
|
# interroge Prometheus depuis l'extérieur et pousse LUI-MÊME sur Telegram, sans
|
||||||
|
# passer par Alertmanager. Le déplacer dans `serverFiles.alerting_rules.yml`
|
||||||
|
# reconstruirait exactement le silence qu'il existe pour supprimer.
|
||||||
|
#
|
||||||
|
# NB: ce chart prometheus est en mode `tool.kind: SubChart`, donc les templates
|
||||||
|
# helm-chart*.yaml ne rendent rien ; ce fichier, lui, est rendu tel quel et
|
||||||
|
# appliqué par ArgoCD (app `prometheus`, destination namespace `tools`), comme
|
||||||
|
# vault-telegram.yaml.
|
||||||
|
#
|
||||||
|
# La propriété gardée, et son arithmétique :
|
||||||
|
# « si Prometheus cesse d'enregistrer, une notification arrive en ≤ 12 min »
|
||||||
|
# seuil de fraîcheur 180 s (3 × `scrape_interval: 1m`)
|
||||||
|
# + période du CronJob 300 s (*/5 — le pire cas est de le manquer d'un rien)
|
||||||
|
# + tolérance au roulement 180 s (2 × 90 s : un redéploiement de Prometheus ne
|
||||||
|
# doit pas réveiller le fondateur)
|
||||||
|
# + exécution ~5 s
|
||||||
|
# = 665 s, soit 11 min 5 s au pire. En face : 4 620 minutes de silence réel.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: veilleur-prometheus
|
||||||
|
namespace: tools
|
||||||
|
data:
|
||||||
|
veilleur.py: |
|
||||||
|
{{ .Files.Get "veilleur/veilleur.py" | indent 4 }}
|
||||||
|
---
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: CronJob
|
||||||
|
metadata:
|
||||||
|
name: veilleur-prometheus
|
||||||
|
namespace: tools
|
||||||
|
spec:
|
||||||
|
schedule: "*/5 * * * *"
|
||||||
|
# Un guetteur qui se double dérange deux fois pour une seule panne.
|
||||||
|
concurrencyPolicy: Forbid
|
||||||
|
# Si le contrôleur a pris du retard (nœud saturé), on saute le tour plutôt que
|
||||||
|
# d'en rejouer une pile d'un coup.
|
||||||
|
startingDeadlineSeconds: 120
|
||||||
|
successfulJobsHistoryLimit: 1
|
||||||
|
# ⚠ On garde les échecs : un Job `Failed` qui reste dans le cluster EST
|
||||||
|
# l'auto-plainte du veilleur quand il n'a pas pu joindre Telegram.
|
||||||
|
failedJobsHistoryLimit: 5
|
||||||
|
jobTemplate:
|
||||||
|
spec:
|
||||||
|
# 3 observations × 90 s + marge.
|
||||||
|
activeDeadlineSeconds: 420
|
||||||
|
# ⚠ Pas de reprise : une reprise renverrait le même message Telegram. Le
|
||||||
|
# tour suivant (5 min) est la reprise.
|
||||||
|
backoffLimit: 0
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: veilleur-prometheus
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 65534
|
||||||
|
runAsGroup: 65534
|
||||||
|
containers:
|
||||||
|
- name: veilleur
|
||||||
|
image: python:3.13-alpine
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
command: ["python3", "/veilleur/veilleur.py"]
|
||||||
|
env:
|
||||||
|
# Le jeton et le salon viennent du MÊME Secret que celui
|
||||||
|
# d'Alertmanager (`alertmanager-telegram`, synchronisé depuis
|
||||||
|
# Vault kvv2/prospection/telegram par le VaultStaticSecret de
|
||||||
|
# templates/vault-telegram.yaml). Un second chemin de secret
|
||||||
|
# serait un second chemin à réparer.
|
||||||
|
- name: BOT_TOKEN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: alertmanager-telegram
|
||||||
|
key: BOT_TOKEN
|
||||||
|
- name: CHAT_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: alertmanager-telegram
|
||||||
|
key: CHAT_ID
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 10m
|
||||||
|
memory: 32Mi
|
||||||
|
limits:
|
||||||
|
memory: 96Mi
|
||||||
|
volumeMounts:
|
||||||
|
- name: veilleur
|
||||||
|
mountPath: /veilleur
|
||||||
|
readOnly: true
|
||||||
|
volumes:
|
||||||
|
- name: veilleur
|
||||||
|
configMap:
|
||||||
|
name: veilleur-prometheus
|
||||||
@@ -1265,6 +1265,64 @@ prometheus: &prometheus_config
|
|||||||
annotations:
|
annotations:
|
||||||
summary: "Homelab — cert {{ $labels.namespace }}/{{ $labels.name }} expire dans {{ $value | humanizeDuration }}"
|
summary: "Homelab — cert {{ $labels.namespace }}/{{ $labels.name }} expire dans {{ $value | humanizeDuration }}"
|
||||||
description: "Le renouvellement automatique (cert-manager → step-issuer → step-ca) est en échec. Vérifier : kubectl get certificaterequest -A, logs step-issuer (résolution DNS de ssl-ca.arcodange.lab), santé de step-ca sur pi1:8443."
|
description: "Le renouvellement automatique (cert-manager → step-issuer → step-ca) est en échec. Vérifier : kubectl get certificaterequest -A, logs step-issuer (résolution DNS de ssl-ca.arcodange.lab), santé de step-ca sur pi1:8443."
|
||||||
|
# Le veilleur (arcodange-org/tools#36). Du 2026-09-04 02:41 au
|
||||||
|
# 2026-09-07 07:37, Prometheus n'a rien enregistré et les 11 règles
|
||||||
|
# ci-dessus se sont TUES — sans échantillon frais, une règle ne se
|
||||||
|
# déclenche pas. Sur Telegram, ce silence est indiscernable du bon
|
||||||
|
# fonctionnement.
|
||||||
|
#
|
||||||
|
# ⚠⚠ CES DEUX RÈGLES NE SONT PAS LE REMÈDE : elles vivent dans le
|
||||||
|
# Prometheus surveillé, donc elles sont muettes exactement quand il
|
||||||
|
# faudrait qu'elles parlent. Le remède est le CronJob
|
||||||
|
# `veilleur-prometheus` (templates/veilleur.yaml), qui interroge
|
||||||
|
# Prometheus depuis l'EXTÉRIEUR et pousse lui-même sur Telegram.
|
||||||
|
# Ces deux règles-ci sont ce que le CronJob observe et ce qui le
|
||||||
|
# complète — rien de plus.
|
||||||
|
- name: veilleur
|
||||||
|
rules:
|
||||||
|
- alert: Veilleur
|
||||||
|
# Témoin toujours allumé (dead man's switch). Il ne dit rien de
|
||||||
|
# l'état du système : il est TOUJOURS vrai. C'est son ABSENCE
|
||||||
|
# dans Alertmanager qui est le signal, et c'est le CronJob
|
||||||
|
# `veilleur-prometheus` qui la constate depuis dehors.
|
||||||
|
#
|
||||||
|
# ⚠ `vector(1)` ne lit AUCUNE série : ce témoin aurait continué
|
||||||
|
# de tirer pendant les 3 j 5 h de panne. Il prouve que la chaîne
|
||||||
|
# évaluation → Alertmanager vit, jamais que le TSDB enregistre.
|
||||||
|
# C'est le contrôle de fraîcheur du CronJob qui garde ça.
|
||||||
|
#
|
||||||
|
# Il ne part PAS sur Telegram : la route `veilleur` d'Alertmanager
|
||||||
|
# l'envoie au récepteur vide (cf. alertmanager.config plus bas).
|
||||||
|
# Un témoin qui sonne toutes les 3 h serait un témoin qu'on coupe.
|
||||||
|
expr: vector(1)
|
||||||
|
labels:
|
||||||
|
severity: none
|
||||||
|
app: veilleur
|
||||||
|
annotations:
|
||||||
|
summary: "Témoin toujours allumé — c'est son absence qui est le signal"
|
||||||
|
description: "Si cette alerte disparaît d'Alertmanager, l'évaluation des règles de Prometheus ou sa livraison vers Alertmanager est arrêtée : AUCUNE alerte ne partirait plus."
|
||||||
|
- alert: VeilleurMuet
|
||||||
|
# L'auto-plainte du veilleur, deuxième moitié : il pousse un
|
||||||
|
# battement au Pushgateway à chaque exécution menée à son terme.
|
||||||
|
# 1200 s = 4 tours de CronJob (*/5).
|
||||||
|
#
|
||||||
|
# ⚠ Cette règle est DANS le Prometheus surveillé : elle ne peut
|
||||||
|
# rien dire d'un Prometheus mort — ce n'est pas son travail, c'est
|
||||||
|
# celui du CronJob. Elle couvre le cas inverse : Prometheus en
|
||||||
|
# pleine forme et le veilleur, lui, à l'arrêt (CronJob suspendu,
|
||||||
|
# image introuvable, Telegram injoignable). Le cas « les deux à la
|
||||||
|
# fois » reste NON COUVERT : il faudrait un second canal,
|
||||||
|
# indépendant de ce homelab.
|
||||||
|
expr: >-
|
||||||
|
time() - veilleur_prometheus_derniere_reussite_timestamp_seconds > 1200
|
||||||
|
or absent(veilleur_prometheus_derniere_reussite_timestamp_seconds)
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
app: veilleur
|
||||||
|
annotations:
|
||||||
|
summary: "Veilleur — le guetteur extérieur de Prometheus ne rapporte plus"
|
||||||
|
description: "Le CronJob veilleur-prometheus (ns tools) n'a pas mené une exécution à son terme depuis plus de 20 min. Tant qu'il est muet, une panne d'enregistrement de Prometheus redeviendrait invisible. Vérifier : kubectl -n tools get cronjob,jobs -l app.kubernetes.io/name=veilleur-prometheus puis les journaux du dernier Job."
|
||||||
# groups:
|
# groups:
|
||||||
# - name: Instances
|
# - name: Instances
|
||||||
# rules:
|
# rules:
|
||||||
@@ -1345,7 +1403,19 @@ prometheus: &prometheus_config
|
|||||||
group_interval: 5m
|
group_interval: 5m
|
||||||
repeat_interval: 3h
|
repeat_interval: 3h
|
||||||
receiver: telegram
|
receiver: telegram
|
||||||
|
routes:
|
||||||
|
# Le témoin toujours allumé ne dérange personne : il est là pour être
|
||||||
|
# CONSTATÉ par le CronJob veilleur-prometheus via /api/v2/alerts, pas
|
||||||
|
# pour être lu. Sans cette route il sonnerait toutes les 3 h, et un
|
||||||
|
# témoin qui sonne est un témoin qu'on finit par couper.
|
||||||
|
- matchers:
|
||||||
|
- alertname="Veilleur"
|
||||||
|
receiver: neant
|
||||||
|
group_wait: 0s
|
||||||
|
repeat_interval: 8760h
|
||||||
receivers:
|
receivers:
|
||||||
|
# Récepteur vide = trou noir. C'est la forme prévue par Alertmanager.
|
||||||
|
- name: neant
|
||||||
- name: telegram
|
- name: telegram
|
||||||
telegram_configs:
|
telegram_configs:
|
||||||
- bot_token_file: /etc/alertmanager/telegram/BOT_TOKEN
|
- bot_token_file: /etc/alertmanager/telegram/BOT_TOKEN
|
||||||
|
|||||||
@@ -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