Set-OPS-Public/roles/serveur_icinga/files/check_materiel.py
Daniel Allaire 49ceb81a4d materiel : un catalogue de capteurs, un tableau Grafana, un verdict Icinga
Les hyperviseurs exposaient temperatures, ventilateurs, SMART et usure NVMe sans que rien
les regarde. scripts/materiel.py porte capteurs et seuils ; Grafana en tire le tableau
Materiel, Icinga un service materiel par hyperviseur. Premier verdict : le disque sda de
gandalf (OSD Ceph) a 56 secteurs en attente, stables.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 10:13:43 -04:00

150 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""Verdict materiel d'un equipement — Icinga le lance, Prometheus lui repond.
GERE par Set-OPS (role serveur_icinga). Ne pas editer a la main.
POURQUOI UN CONTROLE ACTIF QUI LIT PROMETHEUS (2026-09-17). Les hyperviseurs ne peuvent rien
pousser (route par defaut gelee, D-57) et la frontiere n'accueille aucun agent. Prometheus,
lui, les TIRE deja. On juge donc ce qu'il a recolte, avec le catalogue que le tableau
Grafana affiche (`scripts/materiel.py`) : memes capteurs, memes seuils.
UNE MESURE QUI ECHOUE N'EST PAS UN MATERIEL SAIN. Prometheus injoignable, ou aucune serie
pour l'equipement : UNKNOWN, jamais OK.
Usage :
check_materiel.py --hote asgard --catalogue /etc/icinga2/setops-materiel.json
check_materiel.py --hote asgard --catalogue ... --joignabilite # controle d'hote
"""
from __future__ import annotations
import argparse
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
OK, AVERT, CRIT, INCONNU = 0, 1, 2, 3
ETATS = {OK: "OK", AVERT: "AVERTISSEMENT", CRIT: "CRITIQUE", INCONNU: "INCONNU"}
JOBS = 'job=~"fabric|frontiere"'
class Muette(Exception):
pass
def requete(prom: str, expr: str) -> list:
url = prom.rstrip("/") + "/api/v1/query?" + urllib.parse.urlencode({"query": expr})
try:
with urllib.request.urlopen(url, timeout=15) as r:
rep = json.load(r)
except urllib.error.HTTPError as exc:
# UNE REQUETE REFUSEE N'EST PAS UN PROMETHEUS INJOIGNABLE (eprouve le 2026-09-17 : un
# 422 a d'abord ete rapporte comme une panne de Prometheus). On dit ce qu'il repond.
try:
corps = json.load(exc)
except ValueError:
corps = {}
raise Muette(f"Prometheus refuse une requete ({exc.code}) : "
f"{corps.get('error', exc.reason)}") from exc
except (OSError, ValueError) as exc:
raise Muette(f"Prometheus injoignable ({prom}) : {exc}") from exc
if rep.get("status") != "success":
raise Muette(f"requete refusee par Prometheus : {rep.get('error', '?')}")
return rep["data"]["result"]
def joignabilite(prom: str, hote: str) -> int:
series = requete(prom, f'up{{{JOBS},hote="{hote}"}}')
if not series:
print(f"INCONNU — Prometheus ne connait aucune cible nommee « {hote} ».")
return INCONNU
if all(float(s["value"][1]) == 1 for s in series):
print(f"OK — {hote} repond a Prometheus.")
return OK
print(f"CRITIQUE — l'exportateur de {hote} ne repond plus a Prometheus : equipement "
f"eteint, injoignable, ou exportateur arrete.")
return CRIT
def verdict(prom: str, hote: str, cat: dict) -> int:
code = joignabilite_silencieuse(prom, hote)
if code is not None:
return code
etat = OK
lignes, perf, resume = [], [], []
for f in cat["temperatures"]:
expr = (f'max by (hote, capteur) (avg_over_time(({f["expr_juge"]})'
f'[{f["jugee_sur"]}:1m]))')
mesures = sorted(
(s["metric"].get("capteur", "?"), float(s["value"][1]))
for s in requete(prom, expr) if s["metric"].get("hote") == hote)
if not mesures:
continue # famille sans capteur sur cet equipement : ni panne ni oubli
pire = max(v for _, v in mesures)
niveau = CRIT if pire >= f["crit"] else AVERT if pire >= f["avert"] else OK
etat = max(etat, niveau)
detail = ", ".join(f"{c} {v:.0f} °C" for c, v in mesures)
lignes.append(f"[{ETATS[niveau]}] {f['titre']} : {detail} "
f"(seuils {f['avert']}/{f['crit']} °C, moyenne {f['jugee_sur']})")
if niveau != OK:
resume.append(f"{f['titre']} {pire:.0f} °C")
for c, v in mesures:
perf.append(f"'{f['cle']} {c}'={v:.1f};{f['avert']};{f['crit']}")
for c in cat["controles"]:
defauts = sorted({s["metric"].get("capteur", "?")
for s in requete(prom, c["expr"]) if s["metric"].get("hote") == hote})
if not defauts:
continue
niveau = CRIT if c["niveau"] == "crit" else AVERT
etat = max(etat, niveau)
lignes.append(f"[{ETATS[niveau]}] {c['titre']} : {', '.join(defauts)} — {c['raison']}")
resume.append(f"{c['titre']} ({', '.join(defauts)})")
if not perf:
print(f"INCONNU — aucun capteur de temperature relu pour {hote} : le catalogue ne "
f"correspond a rien sur cet equipement.")
return INCONNU
tete = "; ".join(resume) if resume else f"{len(perf)} capteur(s) dans les seuils, aucun defaut disque ni ventilateur"
print(f"{ETATS[etat]} — {tete} | {' '.join(perf)}")
print("\n".join(lignes))
return etat
def joignabilite_silencieuse(prom: str, hote: str) -> int | None:
series = requete(prom, f'up{{{JOBS},hote="{hote}"}}')
if not series:
print(f"INCONNU — Prometheus ne connait aucune cible nommee « {hote} ».")
return INCONNU
if not any(float(s["value"][1]) == 1 for s in series):
print(f"INCONNU — l'exportateur de {hote} ne repond pas : aucun releve a juger "
f"(le controle d'hote le dit).")
return INCONNU
return None
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--hote", required=True)
ap.add_argument("--catalogue", required=True)
ap.add_argument("--prometheus", default="http://127.0.0.1:9090")
ap.add_argument("--joignabilite", action="store_true")
a = ap.parse_args()
try:
if a.joignabilite:
return joignabilite(a.prometheus, a.hote)
with open(a.catalogue, encoding="utf-8") as fh:
cat = json.load(fh)
return verdict(a.prometheus, a.hote, cat)
except Muette as exc:
print(f"INCONNU — {exc}")
return INCONNU
except (OSError, ValueError, KeyError) as exc:
print(f"INCONNU — catalogue illisible ou incomplet : {exc}")
return INCONNU
if __name__ == "__main__":
sys.exit(main())