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>
This commit is contained in:
Daniel Allaire 2026-09-17 10:13:43 -04:00
parent 8ac9f53f05
commit 49ceb81a4d
12 changed files with 895 additions and 4 deletions

View file

@ -1,5 +1,29 @@
# CHANGELOG — Set-OPS
## 2026-09-17 (1) — Le materiel se regarde et se juge : hyperviseurs
Les hyperviseurs exposaient deja 68 temperatures, leurs ventilateurs, SMART et l'usure NVMe,
collectes par Prometheus depuis le 2026-09-10 — et regardes par personne.
- **`scripts/materiel.py`** : un catalogue de capteurs et de seuils, lu par Grafana ET Icinga.
Releve sur les trois cartes ASUS TUF X670E / Ryzen 9 7900X : `AUXTIN*` non cables, `PCH_*` a 0,
DDR5 et cartes reseau exposees seulement par le noyau 6.14 de vishnu, SSD Samsung qui portent
leur temperature dans l'attribut 190.
- **Prometheus** : chaque cible de la fabric porte `hote="asgard"`.
- **Grafana** : tableau « Set-OPS — Materiel » (27 panneaux) genere depuis le catalogue.
- **Icinga** : un hote par hyperviseur, juge par `up` ; service `materiel` (`check_materiel.py`).
TROUVE EN LE CONSTRUISANT : le disque `sda` de gandalf (WD Red Plus 10 To, OSD HDD `osd.4` de
Ceph, 26 225 h) porte 56 secteurs en attente, 17 irreparables et 2 realloues — stables sur une
semaine, maximum a vie 63 °C. Ceph `HEALTH_OK`. Avertissement dans Icinga ; a remplacer.
Deux fautes eprouvees avant d'etre laissees : jointures refusees apres le reetiquetage
(doublons de series — cote droit desormais agrege), et un 422 rapporte comme « Prometheus
injoignable » (la sonde dit maintenant ce que Prometheus refuse).
Mesure : asgard OK (6 capteurs), vishnu OK (13), gandalf AVERTISSEMENT (secteurs sda). Toutes
les requetes du tableau repondent. `make prouver` : CONFORME, 82 OK.
## 2026-09-16 (10) — Publier un service du site sur l'Internet : la redirection devient une capacite
Phase 2, quatrieme volet. Le devis du site sautait tout flux entrant `externe` (« rien d'autre

View file

@ -0,0 +1,118 @@
# Preuve de conformite — Set-OPS — 2026-09-17
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
> [`docs/audit/README.md`](README.md), et le registre trace :
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (82 OK · 0 echec · 0 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | } \| to_nice_json }}`. |
| P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK |
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 5 instance(s) verifiee(s) — instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : plan et inventaire applique coincident. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 42 groupes classes, aucun cycle, aucune arete en arriere ; playbooks/site.yml a jour. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 40 rôles, 119 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 13 hotes, 33 groupes (inventaire dechiffre et parse). |
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 33 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 30 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 6 instance(s) federee(s), aucun index en collision. |
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 14 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 4 tenant(s), 56 groupe(s), 104 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 13 hote(s) x 6 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 5 pool(s) Proxmox, 44 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 34 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 23, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 71 scripts expliques et atteignables, 131 cibles make documentees, 69 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (147 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 42 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 48 document(s) declarent leur lecteur (42 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 4 application(s) exigeant une base l'ont toutes (3 entree(s) au registre). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 8 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). |
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 42 role(s) serveur/client tous nommes, 42 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 96 page(s) de wiki toutes atteignables. |
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 67 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 5 edge(s) emettent un certificat portant les noms publies (instance-ci-1646753/production, OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 192 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 97 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (137 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 282 regles `pass`), tous non consignes et tous motives. |
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. |
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (82 preuves, 69 roles, 42 groupes). |
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `392da4d` (publie le 2026-09-14). |
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 52 champ(s) sur 6 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:8, serveurs:3 champ(s) lus par validateur). |
| P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s |
| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 41 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, serveur_ |
| P65 | Depots tiers : demandes au cache, jamais en HTTPS direct | — | ✅ OK | 4 depot(s) tiers relaye(s) par le cache, aucun role ne les vise en https:// ecrit en dur. |
| P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (6 exposition(s)). |
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 13 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. |
| P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. |
| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. |
| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 6 artefact(s) direct(s) tenus par le depot du site. |
| P71 | Pool du site : le genome ne nait pas chez un tenant | — | ✅ OK | `site-creer` nomme `--pool-site` ; le pool du genome ne peut plus etre celui d'un tenant. |
| P72 | Annuaire : aucun service ne se lie avec le compte du maitre | — | ✅ OK | 5 role(s) consultent l'annuaire, chacun avec SON compte de service ; seul `amorcage_acces` garde celui d'administration, et il provisionne au lieu de consommer. |
| P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 9 intrant(s) du locataire concordent avec ce que le site expose (1 non declare(s), donc derive(s) ou non utilise(s)). |
| P74 | Gabarit dore : une seule declaration, au plan du site | — | ✅ OK | Gabarit declare une seule fois : VMID 9006 « modeleSetOPS-minimal », precedent 99998. |
| P75 | Les parametres de clonage traversent les trois maillons | — | ✅ OK | 14 parametre(s) de clonage, tous emis par l'inventaire. |
| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 165 gabarits de role : tous se rendent. |
| P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. |
| P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). |
| P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ✅ OK | 6 ecosysteme(s) (instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0, SITE-Chezlepro) : pattes, edges, certificats, rechargemen |
| P80 | Remise au client : inscrite, nommee, et son second temps a l'heure | — | ✅ OK | Aucun ecosysteme remis a un client : rien a tenir. |
| P81 | La console dit sa portee, et ne sert pas un inventaire vide en silence | — | ✅ OK | Portee `poste` derivee des symlinks, source d'inventaire `instance`, et 14 route(s) POST exigent toutes un pouvoir. |
| P82 | DNS public : les zones publiees sont servies, signees avant d'etre exposees | — | ✅ OK | 2 zone(s) publique(s) declaree(s), toutes servies par le site ; 2 replication(s) declaree(s) des deux cotes ; transfert ouvert a la cle seule ; aucune expositio |
## Couverture des affirmations ✅ du registre
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
elles restent hors du harnais recurrent (rien d'executable a rejouer).
## Declarations d'intention (⚪ invérifiables localement — assumees)
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
comme declarations d'intention, non comme preuves :
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
contre une flotte vivante.
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
_Rapport genere le 2026-09-17._

View file

@ -249,3 +249,31 @@ que la sonde accepte en paramètre — jamais en substituant l'artefact que d'au
consomment. Quand c'est impossible, on le fait sur la machine la moins critique, on
l'annonce, et on vérifie l'état des consommateurs **après**, pas seulement celui de la
sonde.
## Le matériel : un catalogue, deux lecteurs (2026-09-17)
Les hyperviseurs et la frontière n'ont aucun rôle Set-OPS pour déclarer leurs capteurs, et
ils ne peuvent rien pousser. Prometheus les **tire** déjà ; on juge donc ce qu'il a récolté.
`scripts/materiel.py` est la seule source : familles de températures (processeur, carte
mère, NVMe, disques SATA, DDR5, cartes réseau, frontière) avec leurs seuils et leur raison,
et contrôles de santé (ventilateur arrêté, SMART en échec, secteurs défaillants ou **en
progression**, alerte et usure NVMe, relevés SMART périmés). Deux lecteurs :
| lecteur | ce qu'il en fait |
|---|---|
| Grafana — tableau « Set-OPS — Matériel » | tuiles colorées aux seuils, courbes avec seuils tracés, ventilateurs, puissance, usure et âge des disques |
| Icinga — hôte par équipement, service `materiel` | `check_materiel.py` lit Prometheus avec les mêmes seuils ; l'hôte est jugé par `up`, pas par un ping qu'aucun flux ne permet |
Règles apprises en le construisant :
- **Le matériel ment sur ses limites.** Le Super I/O annonce 125 °C « critique » pour la carte
mère : les seuils sont déclarés, avec leur raison.
- **Un connecteur vide n'est pas un ventilateur arrêté** : on ne crie que pour un ventilateur
qui a tourné dans les 24 h.
- **La progression, pas le compte.** Un disque aux secteurs défaillants stables est un
avertissement ; c'est un compte qui **monte** qui est critique. Une alarme toujours rouge
est une alarme qu'on cesse de lire.
- **Le côté droit d'une jointure PromQL est agrégé.** Un réétiquetage laisse cinq minutes
deux séries pour une même clé, et la requête est refusée.
- **Une requête refusée n'est pas un Prometheus injoignable** : la sonde rapporte l'erreur.

View file

@ -318,6 +318,23 @@
tableau_panneaux: "{{ item.1.panneaux }}"
notify: Redemarrer grafana
# --- LE MATERIEL (2026-09-17) -----------------------------------------------------------
#
# Les hyperviseurs n'ont aucun role Set-OPS pour declarer leurs capteurs : ils ne sont pas
# de la flotte. Le catalogue vient donc de l'inventaire du site (`scripts/materiel.py`), et
# c'est le meme que la sonde Icinga lit. Pas de prefixe `setops-role-` : la moisson des
# tableaux de roles orphelins ne doit pas le voir.
- name: Assembler le tableau du matériel
ansible.builtin.template:
src: tableau-materiel.json.j2
dest: "{{ serveur_grafana_dashboards_dir }}/setops-materiel.json"
owner: root
group: grafana
mode: "0640"
validate: "python3 -c \"import json,sys; json.load(open(sys.argv[1]))\" %s"
when: setops_materiel is defined
notify: Redemarrer grafana
# --- UN ROLE QU'ON RETIRE DOIT POUVOIR DEFAIRE CE QU'IL A FAIT ------------------------
#
# Meme lecon que les sondes orphelines de `client_sante`, et elle s'est deja payee ici :

View file

@ -0,0 +1,206 @@
{#- GENERE par Set-OPS (role serveur_grafana). Ne pas editer a la main.
LE TABLEAU DU MATERIEL — hyperviseurs et frontiere (2026-09-17).
Assemble depuis `scripts/materiel.py`, le MEME catalogue que la sonde Icinga lit : un
seuil change la-bas change la couleur ici et le verdict la-bas. Deux ecritures
divergeraient, et un graphe rouge cohabiterait avec une supervision verte.
Le JSON est construit comme une structure Jinja puis rendu d'un bloc par `tojson` :
aucune virgule a la main, donc aucun tableau que Grafana sauterait en silence.
-#}
{%- set cat = setops_materiel -%}
{%- set filtre = ' and on(hote) up{job=~"fabric|frontiere",hote=~"$hote"}' -%}
{%- set panneaux = [] -%}
{%- set y = namespace(v=0, id=0) -%}
{%- macro seuils(f) -%}
{{ {"mode": "absolute", "steps": [
{"color": "green", "value": none},
{"color": "orange", "value": f.avert},
{"color": "red", "value": f.crit}]} | tojson }}
{%- endmacro -%}
{#- ---- RANGEE : ETAT EN DIRECT ------------------------------------------------------ #}
{%- set y.id = y.id + 1 -%}
{%- set _ = panneaux.append({"type": "row", "id": y.id, "title": "État en direct", "collapsed": false,
"gridPos": {"h": 1, "w": 24, "x": 0, "y": y.v}}) -%}
{%- set y.v = y.v + 1 -%}
{%- set tuiles = cat.temperatures | selectattr("cle", "in", ["processeur", "carte_mere", "nvme", "disques"]) | list -%}
{%- for f in tuiles -%}
{%- set y.id = y.id + 1 -%}
{%- set _ = panneaux.append({
"type": "stat", "id": y.id,
"title": f.titre ~ " — maximum",
"description": f.raison,
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
"gridPos": {"h": 5, "w": 6, "x": loop.index0 * 6, "y": y.v},
"fieldConfig": {"defaults": {"unit": "celsius", "decimals": 0,
"thresholds": (seuils(f) | from_json)}, "overrides": []},
"options": {"colorMode": "background", "graphMode": "area", "justifyMode": "center",
"textMode": "value_and_name", "orientation": "vertical",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}},
"targets": [{"refId": "A", "datasource": {"type": "prometheus", "uid": "${ds_prom}"},
"expr": "max by (hote) ((" ~ f.expr_juge ~ ")" ~ filtre ~ ")",
"legendFormat": "{{hote}}"}]}) -%}
{%- endfor -%}
{%- set y.v = y.v + 5 -%}
{#- Les controles : une tuile chacun. Zero = vert ; sinon la couleur de SON niveau. #}
{%- set largeur = [24 // (cat.controles | length), 3] | max -%}
{%- for c in cat.controles -%}
{%- set y.id = y.id + 1 -%}
{%- set _ = panneaux.append({
"type": "stat", "id": y.id,
"title": c.titre,
"description": c.raison,
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
"gridPos": {"h": 4, "w": largeur, "x": (loop.index0 * largeur) % 24, "y": y.v + ((loop.index0 * largeur) // 24) * 4},
"fieldConfig": {"defaults": {"unit": "none", "decimals": 0,
"noValue": "0",
"thresholds": {"mode": "absolute", "steps": [
{"color": "green", "value": none},
{"color": ("red" if c.niveau == "crit" else "orange"), "value": 1}]}},
"overrides": []},
"options": {"colorMode": "background", "graphMode": "none", "justifyMode": "center",
"textMode": "value", "orientation": "auto",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}},
"targets": [{"refId": "A", "datasource": {"type": "prometheus", "uid": "${ds_prom}"},
"expr": "count(count by (hote, capteur) ((" ~ c.expr ~ ")" ~ filtre ~ ")) or vector(0)"}]}) -%}
{%- endfor -%}
{%- set y.v = y.v + (((cat.controles | length) * largeur + 23) // 24) * 4 -%}
{#- ---- RANGEE : TEMPERATURES -------------------------------------------------------- #}
{%- set y.id = y.id + 1 -%}
{%- set _ = panneaux.append({"type": "row", "id": y.id, "title": "Températures", "collapsed": false,
"gridPos": {"h": 1, "w": 24, "x": 0, "y": y.v}}) -%}
{%- set y.v = y.v + 1 -%}
{%- for f in cat.temperatures -%}
{%- set y.id = y.id + 1 -%}
{%- set _ = panneaux.append({
"type": "timeseries", "id": y.id,
"title": f.titre,
"description": f.raison ~ " — Avertissement " ~ f.avert ~ " °C, critique " ~ f.crit ~ " °C (verdict Icinga lisse sur " ~ f.jugee_sur ~ ").",
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
"gridPos": {"h": 9, "w": 12, "x": 0 if loop.index0 % 2 == 0 else 12, "y": y.v + (loop.index0 // 2) * 9},
"fieldConfig": {"defaults": {
"unit": "celsius", "decimals": 1,
"color": {"mode": "palette-classic"},
"thresholds": (seuils(f) | from_json),
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 8, "gradientMode": "opacity",
"showPoints": "never", "spanNulls": true,
"thresholdsStyle": {"mode": "dashed"}}},
"overrides": []},
"options": {"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["lastNotNull", "max"]},
"tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"refId": "A", "datasource": {"type": "prometheus", "uid": "${ds_prom}"},
"expr": "(" ~ f.expr ~ ")" ~ filtre,
"legendFormat": "{{hote}} · {{capteur}}"}]}) -%}
{%- endfor -%}
{%- set y.v = y.v + (((cat.temperatures | length) + 1) // 2) * 9 -%}
{#- ---- RANGEE : VENTILATEURS ET PUISSANCE ----------------------------------------------- #}
{%- set y.id = y.id + 1 -%}
{%- set _ = panneaux.append({"type": "row", "id": y.id, "title": "Ventilateurs et puissance", "collapsed": false,
"gridPos": {"h": 1, "w": 24, "x": 0, "y": y.v}}) -%}
{%- set y.v = y.v + 1 -%}
{%- set y.id = y.id + 1 -%}
{%- set _ = panneaux.append({
"type": "timeseries", "id": y.id, "title": "Ventilateurs",
"description": "Seuls les connecteurs qui ont tourne dans les dernieres 24 h : un connecteur vide lit 0 depuis toujours et n'apprend rien.",
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
"gridPos": {"h": 9, "w": 12, "x": 0, "y": y.v},
"fieldConfig": {"defaults": {"unit": "rotrpm", "decimals": 0, "color": {"mode": "palette-classic"},
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 5, "showPoints": "never", "spanNulls": true}},
"overrides": []},
"options": {"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["lastNotNull", "min"]},
"tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"refId": "A", "datasource": {"type": "prometheus", "uid": "${ds_prom}"},
"expr": "(node_hwmon_fan_rpm{job=~\"fabric|frontiere\"} and on(instance, chip, sensor) (max_over_time(node_hwmon_fan_rpm{job=~\"fabric|frontiere\"}[1d]) > 0))" ~ filtre,
"legendFormat": "{{hote}} · {{sensor}}"}]}) -%}
{%- set y.id = y.id + 1 -%}
{%- set _ = panneaux.append({
"type": "timeseries", "id": y.id, "title": "Puissance du processeur (PPT)",
"description": "Puissance du boitier processeur annoncee par le pilote amdgpu (APU integre). Elle suit la charge : une temperature qui monte SANS puissance qui monte parle du refroidissement.",
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
"gridPos": {"h": 9, "w": 12, "x": 12, "y": y.v},
"fieldConfig": {"defaults": {"unit": "watt", "decimals": 1, "color": {"mode": "palette-classic"},
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 15, "gradientMode": "opacity", "showPoints": "never", "spanNulls": true}},
"overrides": []},
"options": {"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"]},
"tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"refId": "A", "datasource": {"type": "prometheus", "uid": "${ds_prom}"},
"expr": "(node_hwmon_power_watt{job=~\"fabric|frontiere\"} * on(instance, chip, sensor) group_left(label) group by (instance, chip, sensor, label) (node_hwmon_sensor_label{job=~\"fabric|frontiere\",label=\"PPT\"}))" ~ filtre,
"legendFormat": "{{hote}}"}]}) -%}
{%- set y.v = y.v + 9 -%}
{#- ---- RANGEE : STOCKAGE ------------------------------------------------------------ #}
{%- set y.id = y.id + 1 -%}
{%- set _ = panneaux.append({"type": "row", "id": y.id, "title": "Stockage", "collapsed": false,
"gridPos": {"h": 1, "w": 24, "x": 0, "y": y.v}}) -%}
{%- set y.v = y.v + 1 -%}
{%- set y.id = y.id + 1 -%}
{%- set _ = panneaux.append({
"type": "bargauge", "id": y.id, "title": "Usure des NVMe",
"description": "Part de l'endurance annoncee deja consommee. Avertissement a 80 % : a commander, pas encore a remplacer.",
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
"gridPos": {"h": 9, "w": 8, "x": 0, "y": y.v},
"fieldConfig": {"defaults": {"unit": "percentunit", "min": 0, "max": 1, "decimals": 0,
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": none}, {"color": "orange", "value": 0.8}, {"color": "red", "value": 0.9}]}},
"overrides": []},
"options": {"displayMode": "gradient", "orientation": "horizontal", "showUnfilled": true, "valueMode": "color",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}},
"targets": [{"refId": "A", "datasource": {"type": "prometheus", "uid": "${ds_prom}"},
"expr": "nvme_percentage_used_ratio{job=~\"fabric|frontiere\"}" ~ filtre,
"legendFormat": "{{hote}} · {{device}}"}]}) -%}
{%- set y.id = y.id + 1 -%}
{%- set _ = panneaux.append({
"type": "bargauge", "id": y.id, "title": "Secteurs défaillants",
"description": "En attente + irreparables + realloues, par disque SATA. Un compte stable se surveille ; un compte qui MONTE se remplace (controle « secteurs en progression »).",
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
"gridPos": {"h": 9, "w": 8, "x": 8, "y": y.v},
"fieldConfig": {"defaults": {"unit": "none", "min": 0, "decimals": 0,
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": none}, {"color": "orange", "value": 1}, {"color": "red", "value": 100}]}},
"overrides": []},
"options": {"displayMode": "gradient", "orientation": "horizontal", "showUnfilled": true, "valueMode": "color",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}},
"targets": [{"refId": "A", "datasource": {"type": "prometheus", "uid": "${ds_prom}"},
"expr": "(sum by (hote, disk) (smartmon_current_pending_sector_raw_value{job=~\"fabric|frontiere\"} or smartmon_offline_uncorrectable_raw_value{job=~\"fabric|frontiere\"} or smartmon_reallocated_sector_ct_raw_value{job=~\"fabric|frontiere\"}))" ~ filtre,
"legendFormat": "{{hote}} · {{disk}}"}]}) -%}
{%- set y.id = y.id + 1 -%}
{%- set _ = panneaux.append({
"type": "bargauge", "id": y.id, "title": "Heures de fonctionnement",
"description": "Age reel des disques. Un disque mecanique au-dela de 40 000 heures (4,5 ans) entre dans la zone ou les pannes se multiplient.",
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
"gridPos": {"h": 9, "w": 8, "x": 16, "y": y.v},
"fieldConfig": {"defaults": {"unit": "h", "min": 0, "decimals": 0,
"thresholds": {"mode": "absolute", "steps": [{"color": "blue", "value": none}, {"color": "orange", "value": 40000}]}},
"overrides": []},
"options": {"displayMode": "gradient", "orientation": "horizontal", "showUnfilled": true, "valueMode": "color",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}},
"targets": [{"refId": "A", "datasource": {"type": "prometheus", "uid": "${ds_prom}"},
"expr": "(label_replace(smartmon_power_on_hours_raw_value{job=~\"fabric|frontiere\",type=\"sat\"}, \"dispositif\", \"$1\", \"disk\", \"/dev/(.*)\") or label_replace(nvme_power_on_hours_total{job=~\"fabric|frontiere\"}, \"dispositif\", \"$1\", \"device\", \"(.*)\"))" ~ filtre,
"legendFormat": "{{hote}} · {{dispositif}}"}]}) -%}
{{ {
"uid": "setops-materiel",
"title": "Set-OPS — Matériel",
"description": "Hyperviseurs et frontiere : temperatures, ventilateurs, puissance, disques. Meme catalogue et memes seuils que le service Icinga « materiel » (scripts/materiel.py).",
"tags": ["set-ops", "derive", "materiel", "fabric"],
"timezone": "browser",
"refresh": "1m",
"time": {"from": "now-24h", "to": "now"},
"schemaVersion": 39,
"version": 1,
"graphTooltip": 1,
"templating": {"list": [
{"name": "ds_prom", "label": "Source Prometheus", "type": "datasource", "query": "prometheus", "hide": 2, "current": {}},
{"name": "hote", "label": "Équipement", "type": "query",
"datasource": {"type": "prometheus", "uid": "${ds_prom}"},
"query": {"query": "label_values(up{job=~\"fabric|frontiere\"}, hote)", "refId": "hote"},
"definition": "label_values(up{job=~\"fabric|frontiere\"}, hote)",
"multi": true, "includeAll": true, "current": {"selected": true, "text": ["All"], "value": ["$__all"]},
"refresh": 2, "sort": 1}
]},
"panels": panneaux
} | tojson }}

View file

@ -117,6 +117,12 @@ serveur_icinga_hotes_supervises: >-
serveur_icinga_hotes_conf: "/etc/icinga2/conf.d/setops-hotes.conf"
serveur_icinga_sante_conf: "/etc/icinga2/conf.d/setops-sante.conf"
serveur_icinga_sondes_conf: "/etc/icinga2/conf.d/setops-sondes.conf"
# LE MATERIEL (2026-09-17) : catalogue et hotes viennent de l'inventaire du site
# (`setops_materiel`, `setops_materiel_hotes`) ; absents, rien n'est pose.
serveur_icinga_materiel_conf: "/etc/icinga2/conf.d/setops-materiel.conf"
serveur_icinga_materiel_catalogue: "/etc/icinga2/setops-materiel.json"
serveur_icinga_materiel_sonde: "/usr/local/lib/setops/icinga/check_materiel.py"
serveur_icinga_materiel_prometheus: "http://127.0.0.1:9090"
# Rempli a l'execution depuis les `meta/supervision.yml` des roles.
serveur_icinga_sondes: {}

View file

@ -0,0 +1,150 @@
#!/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())

View file

@ -367,6 +367,47 @@
mode: "0640"
notify: Redemarrer icinga2
# --- LE MATERIEL (2026-09-17) ------------------------------------------------------------
# Hyperviseurs et frontiere : Icinga juge ce que Prometheus a tire, avec le catalogue du
# tableau Grafana. Voir `scripts/materiel.py`.
- name: Assurer le repertoire des controles actifs Set-OPS
ansible.builtin.file:
path: "{{ serveur_icinga_materiel_sonde | dirname }}"
state: directory
owner: root
group: root
mode: "0755"
when: setops_materiel_hotes is defined
- name: Deposer le controle du materiel
ansible.builtin.copy:
src: check_materiel.py
dest: "{{ serveur_icinga_materiel_sonde }}"
owner: root
group: root
mode: "0755"
when: setops_materiel_hotes is defined
- name: Deposer le catalogue des capteurs
ansible.builtin.copy:
content: "{{ setops_materiel | to_nice_json }}\n"
dest: "{{ serveur_icinga_materiel_catalogue }}"
owner: root
group: nagios
mode: "0640"
validate: "python3 -c \"import json,sys; json.load(open(sys.argv[1]))\" %s"
when: setops_materiel_hotes is defined
- name: Deployer les objets de supervision du materiel
ansible.builtin.template:
src: setops-materiel.conf.j2
dest: "{{ serveur_icinga_materiel_conf }}"
owner: root
group: nagios
mode: "0640"
when: setops_materiel_hotes is defined
notify: Redemarrer icinga2
- name: Valider la configuration Icinga 2 avant de la rendre vivante
ansible.builtin.command:
cmd: icinga2 daemon -C

View file

@ -0,0 +1,54 @@
/*
* Gere par Set-OPS (role serveur_icinga). Ne pas editer a la main.
*
* LE MATERIEL — hyperviseurs et frontiere (2026-09-17).
*
* Ces equipements n'accueillent aucun porteur de sante : ils ne peuvent rien pousser. Mais
* Prometheus les TIRE deja. Le controle lit donc ce qu'il a recolte, avec le catalogue que
* le tableau Grafana « Materiel » affiche — `scripts/materiel.py`, une source, deux lecteurs.
*
* L'HOTE EST JUGE PAR PROMETHEUS, PAS PAR UN PING. Un `hostalive` depuis cette machine vers
* la grappe de controle exigerait un flux ICMP qu'aucune couche ne declare ; il dirait
* « eteint » d'une machine en parfaite sante. Le seul chemin qui existe est celui de la
* scrutation : c'est lui qu'on interroge.
*/
object CheckCommand "setops-materiel" {
command = [ "{{ serveur_icinga_materiel_sonde }}" ]
arguments = {
"--hote" = "$setops_materiel_hote$"
"--catalogue" = "{{ serveur_icinga_materiel_catalogue }}"
"--prometheus" = "{{ serveur_icinga_materiel_prometheus }}"
}
}
object CheckCommand "setops-materiel-joignable" {
command = [ "{{ serveur_icinga_materiel_sonde }}", "--joignabilite" ]
arguments = {
"--hote" = "$setops_materiel_hote$"
"--catalogue" = "{{ serveur_icinga_materiel_catalogue }}"
"--prometheus" = "{{ serveur_icinga_materiel_prometheus }}"
}
}
{% for h in setops_materiel_hotes | default([]) %}
object Host "{{ h.nom }}" {
check_command = "setops-materiel-joignable"
address = "{{ h.adresse }}"
check_interval = 1m
retry_interval = 30s
vars.role = "{{ h.role }}"
vars.setops_materiel_hote = "{{ h.nom }}"
}
object Service "materiel" {
host_name = "{{ h.nom }}"
check_command = "setops-materiel"
check_interval = 5m
retry_interval = 1m
{# Trois lectures avant de crier : le verdict est deja lisse (moyennes de 5 a 15 min), on
ne veut pas d'une alerte sur un redemarrage de Prometheus. #}
max_check_attempts = 3
vars.setops_materiel_hote = "{{ h.nom }}"
}
{% endfor %}

View file

@ -56,5 +56,15 @@ scrape_configs:
- job_name: {{ job.job }}
static_configs:
{% if job.hotes | default({}) %}
{# UNE CIBLE NOMMEE PAR BLOC : l'etiquette `hote` s'attache a un `static_config`, pas a
une adresse. Les series disent alors « asgard », pas « 192.168.11.41:9100 ». #}
{% for cible in job.cibles %}
- targets: {{ [cible] | to_json }}
labels:
hote: {{ job.hotes[cible] | default(cible) | to_json }}
{% endfor %}
{% else %}
- targets: {{ job.cibles | to_json }}
{% endif %}
{% endfor %}

223
scripts/materiel.py Normal file
View file

@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""Le catalogue des capteurs materiels — une source, deux lecteurs : Grafana et Icinga.
POURQUOI UN CATALOGUE (2026-09-17). Les hyperviseurs exposaient deja 68 temperatures, leurs
ventilateurs, leurs disques SMART et l'usure de leurs NVMe — collectes par Prometheus depuis
le 2026-09-10, regardes par personne. Le tableau de bord et la sonde doivent lire les MEMES
capteurs avec les MEMES seuils : ecrits deux fois, ils divergeraient, et un graphe rouge
cohabiterait avec une supervision verte.
CE QUE LE MATERIEL DIT DE LUI-MEME, ET POURQUOI ON NE LE CROIT PAS. Les puces annoncent
leurs propres limites (`temp_max`, `temp_crit`). Sur ces cartes ASUS elles valent 80 et 125 °C
pour la carte mere — des valeurs d'usine du Super I/O, pas des limites du systeme. Les seuils
sont donc DECLARES ici, avec leur raison.
CE QU'ON IGNORE, ET POURQUOI (releve du 2026-09-17 sur asgard, gandalf, vishnu) :
- `AUXTIN0..5` du nct6799 : entrees non cablees (-62, 0, 12 °C) ;
- `PCH_*` : lisent 0 sur ces cartes ;
- `TSI0_TEMP` et `amdgpu edge` : le meme silicium que `Tctl`, vu autrement ;
- la puce Wi-Fi : sans interet pour un hyperviseur ;
- les disques iSCSI (`type="scsi"`) : des volumes distants, dont le materiel n'est pas ici.
Les familles sans capteur sur un hote (DDR5 et cartes reseau n'existent que sur le noyau 6.14
de vishnu) ne rendent simplement aucune serie : ce n'est ni une panne ni un oubli.
"""
from __future__ import annotations
# Les jobs Prometheus qui portent du materiel. `fabric` : les hyperviseurs ; `frontiere` :
# le boitier OPNsense, quand son exportateur est en service.
JOBS = 'job=~"fabric|frontiere"'
# LE COTE DROIT D'UNE JOINTURE EST TOUJOURS AGREGE (eprouve le 2026-09-17). Le jour ou l'on a
# ajoute l'etiquette `hote` aux cibles, Prometheus a garde cinq minutes la serie sans elle ET
# la serie avec : deux series pour une meme cle de jointure, et la requete refusee (« many-to-
# many matching not allowed »). Le tableau se vidait, la sonde rendait INCONNU. `group by` /
# `max by` ramene la cle a une seule serie, quel que soit l'historique des etiquettes.
def _hwmon(chip_name: str, etiquettes: str | None = None) -> str:
"""Temperatures hwmon d'une puce, avec le libelle du capteur quand la puce en donne un."""
expr = f"node_hwmon_temp_celsius{{{JOBS}}}"
if etiquettes:
expr = (f"({expr} * on(instance, chip, sensor) group_left(label) "
f'group by (instance, chip, sensor, label) '
f'(node_hwmon_sensor_label{{{JOBS},label=~"{etiquettes}"}}))')
return (f"({expr} * on(instance, chip) group_left(chip_name) "
f'group by (instance, chip, chip_name) '
f'(node_hwmon_chip_names{{{JOBS},chip_name=~"{chip_name}"}}))')
# UNE FAMILLE = une question qu'on se pose devant un equipement chaud.
# expr : les series (au moins les etiquettes `hote` et `capteur`)
# avert/crit: °C ; `None` = regardee, jamais jugee
# jugee_sur : la fenetre de lissage du verdict — un pic d'une seconde n'est pas une panne
TEMPERATURES = [
{
"cle": "processeur",
"titre": "Processeur",
"expr": f'label_replace({_hwmon("k10temp", "Tctl|Tccd1|Tccd2")}, "capteur", "$1", "label", "(.*)")',
"juge": 'capteur="Tctl"',
# Le verdict ne regarde que Tctl : les Tccd sont des sondes de coeur, plus bruitees.
"expr_juge": f'label_replace({_hwmon("k10temp", "Tctl")}, "capteur", "$1", "label", "(.*)")',
"avert": 90, "crit": 95, "jugee_sur": "5m",
"raison": ("Ryzen 9 7900X : il est CONCU pour monter a 95 °C sous charge soutenue et s'y "
"brider. Un Tctl a 95 pendant cinq minutes veut dire qu'il bride — la machine "
"rend moins que ce qu'on croit. Au-dessus, c'est le refroidissement."),
},
{
"cle": "carte_mere",
"titre": "Carte mère",
"expr": (f'label_replace({_hwmon("nct6799", "SYSTIN|CPUTIN")}, "capteur", "$1", "label", '
f'"(.*)")'),
"juge": 'capteur="SYSTIN"',
"expr_juge": f'label_replace({_hwmon("nct6799", "SYSTIN")}, "capteur", "$1", "label", "(.*)")',
"avert": 50, "crit": 60, "jugee_sur": "10m",
"raison": ("SYSTIN est l'air du boitier. Il monte avec la piece, pas avec la charge : un "
"SYSTIN qui grimpe sur les trois machines a la fois parle de la salle, pas "
"d'une machine."),
},
{
"cle": "nvme",
"titre": "NVMe",
"expr": (f'label_replace({_hwmon("nvme", "Composite")}, "capteur", "$1", "chip", '
f'"nvme_(.*)")'),
"juge": "",
"avert": 70, "crit": 80, "jugee_sur": "5m",
"raison": ("WD_BLACK SN850X : il ralentit ses ecritures vers 80-85 °C pour se proteger. "
"Ceph et les VM le vivent comme une latence, sans aucune erreur."),
},
{
"cle": "disques",
"titre": "Disques SATA",
# LES SSD SAMSUNG NE PORTENT PAS L'ATTRIBUT 194 : leur temperature est la 190
# (« airflow »). Sans le repli, les deux 850 disparaissaient du tableau sans un mot.
"expr": (f'label_replace((smartmon_temperature_celsius_raw_value{{{JOBS},type="sat"}} or '
f'smartmon_airflow_temperature_cel_raw_value{{{JOBS},type="sat"}}), '
f'"capteur", "$1", "disk", "/dev/(.*)")'),
"juge": "",
"avert": 50, "crit": 60, "jugee_sur": "15m",
"raison": ("WD Red Plus 10 To (OSD HDD de Ceph) et SSD Samsung 850 : la vie d'un disque "
"mecanique chute au-dessus de 50 °C, bien avant sa limite de 65. Celui de "
"gandalf a deja vu 63 °C (maximum a vie, releve SMART du 2026-09-17)."),
},
{
"cle": "memoire",
"titre": "Mémoire DDR5",
"expr": (f'label_replace({_hwmon("spd5118")}, "capteur", "barrette $1", "chip", '
f'"i2c_(.*)")'),
"juge": "",
"avert": 70, "crit": 80, "jugee_sur": "5m",
"raison": ("Capteur JEDEC des barrettes (noyau 6.14, vishnu seulement). La DDR5 "
"commence a corriger des erreurs au-dela de 85 °C."),
},
{
"cle": "reseau",
"titre": "Cartes réseau",
"expr": (f'label_replace({_hwmon("r8169.*")}, "capteur", "$1", "chip_name", '
f'"(.*)")'),
"juge": "",
"avert": 95, "crit": 110, "jugee_sur": "5m",
"raison": "Realtek RTL8125 (noyau 6.14, vishnu seulement) ; limite annoncee 120 °C.",
},
{
"cle": "frontiere",
"titre": "Frontière (cœurs)",
"expr": (f'label_replace(node_cpu_temperature_celsius{{{JOBS}}}, "capteur", "cœur $1", '
f'"cpu", "(.*)")'),
"juge": "",
"avert": 80, "crit": 90, "jugee_sur": "5m",
"raison": ("Intel i3-5020U, Tjunction 100 °C. A 90, il bride — et c'est tout le trafic du "
"site qui ralentit."),
},
]
# CE QUI NE SE MESURE PAS EN DEGRES. Chaque controle rend une expression dont TOUTE serie
# presente est un defaut (etiquettes `hote` et `capteur`). Vide = sain.
CONTROLES = [
{
"cle": "ventilateur_arrete",
"titre": "Ventilateur arrêté",
"niveau": "crit",
# UN CONNECTEUR VIDE N'EST PAS UN VENTILATEUR ARRETE. fan4, fan6 et fan7 lisent 0
# depuis toujours : on ne crie que pour un ventilateur qui A TOURNE dans les 24 h.
"expr": (f'label_replace((node_hwmon_fan_rpm{{{JOBS}}} == 0) and on(instance, chip, sensor) '
f'(max_over_time(node_hwmon_fan_rpm{{{JOBS}}}[1d]) > 300), "capteur", "$1", '
f'"sensor", "(.*)")'),
"raison": "Un ventilateur qui tournait hier et plus maintenant : la chaleur suit dans l'heure.",
},
{
"cle": "smart_echec",
"titre": "SMART : disque en échec",
"niveau": "crit",
"expr": (f'label_replace(smartmon_device_smart_healthy{{{JOBS},type=~"sat|nvme"}} == 0, '
f'"capteur", "$1", "disk", "/dev/(.*)")'),
"raison": "Le disque declare lui-meme qu'il va lacher. Il faut le remplacer, pas l'observer.",
},
{
"cle": "secteurs_degradation",
"titre": "Disque : secteurs en progression",
"niveau": "crit",
# LA PROGRESSION, PAS LE COMPTE. Le disque de gandalf porte 56 secteurs en attente,
# stables depuis au moins une semaine, Ceph `HEALTH_OK` (releve du 2026-09-17). Un
# verdict critique sur le compte serait rouge pour toujours — et une alarme toujours
# rouge est une alarme qu'on cesse de lire. Ce qui presse, c'est qu'il AUGMENTE.
"expr": (f'label_replace((delta(smartmon_current_pending_sector_raw_value{{{JOBS}}}[1d]) > 0) or '
f'(delta(smartmon_reallocated_sector_ct_raw_value{{{JOBS}}}[1d]) > 0) or '
f'(delta(smartmon_offline_uncorrectable_raw_value{{{JOBS}}}[1d]) > 0), '
f'"capteur", "$1", "disk", "/dev/(.*)")'),
"raison": "Le disque perd des secteurs EN CE MOMENT : sa degradation s'accelere, le remplacer.",
},
{
"cle": "secteurs_defaillants",
"titre": "Disque : secteurs défaillants",
"niveau": "avert",
"expr": (f'label_replace((smartmon_current_pending_sector_raw_value{{{JOBS}}} > 0) or '
f'(smartmon_offline_uncorrectable_raw_value{{{JOBS}}} > 0) or '
f'(smartmon_reallocated_sector_ct_raw_value{{{JOBS}}} > 0), "capteur", "$1", '
f'"disk", "/dev/(.*)")'),
"raison": ("Le disque a des secteurs illisibles ou deja remplaces. Stable, il tient ; "
"a remplacer a la prochaine occasion."),
},
{
"cle": "nvme_alerte",
"titre": "NVMe : alerte du contrôleur",
"niveau": "crit",
"expr": (f'label_replace((nvme_critical_warning_total{{{JOBS}}} > 0) or '
f'(nvme_available_spare_ratio{{{JOBS}}} < on(instance, device) group_left() '
f'max by (instance, device) (nvme_available_spare_threshold_ratio{{{JOBS}}})), '
f'"capteur", "$1", "device", "(.*)")'),
"raison": "Le controleur NVMe signale un avertissement critique ou a epuise sa reserve.",
},
{
"cle": "nvme_usure",
"titre": "NVMe : usure > 80 %",
"niveau": "avert",
"expr": (f'label_replace(nvme_percentage_used_ratio{{{JOBS}}} > 0.8, "capteur", "$1", '
f'"device", "(.*)")'),
"raison": "L'endurance annoncee est presque consommee : a commander, pas encore a remplacer.",
},
{
"cle": "donnees_perimees",
"titre": "Relevés SMART périmés",
"niveau": "avert",
# UNE SONDE MUETTE N'EST PAS UNE SONDE SAINE. Les releves SMART et NVMe viennent de
# minuteries (15 min) : si elles s'arretent, toutes les regles ci-dessus restent
# vertes sur des chiffres figes.
"expr": (f'label_replace(time() - node_textfile_mtime_seconds{{{JOBS},'
f'file=~".*(smartmon|nvme)[.]prom"}} > 3600, "capteur", "$1", "file", '
f'".*/(.*)[.]prom")'),
"raison": "Les releves SMART/NVMe n'ont pas ete rafraichis depuis une heure : les verdicts ci-dessus sont figes.",
},
]
def catalogue() -> dict:
"""Ce que les roles recoivent (Grafana et Icinga). `expr_juge` vaut `expr` par defaut."""
temperatures = [{**f, "expr_juge": f.get("expr_juge") or f["expr"]} for f in TEMPERATURES]
for f in temperatures:
f.pop("juge", None)
return {"temperatures": temperatures, "controles": CONTROLES}
if __name__ == "__main__":
import json
print(json.dumps(catalogue(), ensure_ascii=False, indent=1))

View file

@ -31,6 +31,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import yaml # noqa: E402
import underlay as U # noqa: E402
import materiel as _materiel # noqa: E402
from inventory_rules import expositions_des_applications # noqa: E402
from inventory_rules import integrations_universelles # noqa: E402
@ -530,9 +531,10 @@ def inventaire() -> dict:
# `serveur_prometheus_cibles_supplementaires` est le point d'extension prevu par le
# role. On y met la fabric sous son PROPRE job : elle n'est pas la flotte, et un
# tableau qui les melange ferait croire a un parc homogene.
_fabric_metriques = sorted(
f"{h['ip']}:9100" for h in U.hotes(u)
if str(h.get("role")) == "hyperviseur" and str(h.get("ip", "")).startswith("192.168.11."))
_fabric_nommes = {
f"{h['ip']}:9100": str(h.get("nom")) for h in U.hotes(u)
if str(h.get("role")) == "hyperviseur" and str(h.get("ip", "")).startswith("192.168.11.")}
_fabric_metriques = sorted(_fabric_nommes)
# CE QUE LA SUPERVISION IRA VOIR : chaque patte de la frontiere, nommee par la zone
# qu'elle sert. Les commutateurs restent dehors — D-48 les tient hors flotte, et rien
@ -819,9 +821,21 @@ def inventaire() -> dict:
# une interface eteinte coupe une zone pendant que les autres vont bien.
**({"serveur_icinga_fabric": _fabric_surveillee}
if (_fabric_surveillee and "serveur_icinga" in _groupes_de(nom)) else {}),
# LE MATERIEL : un catalogue, deux lecteurs (tableau Grafana, verdict Icinga).
# Voir `scripts/materiel.py` — les seuils vivent la, une fois.
**({"setops_materiel": _materiel.catalogue(),
"setops_materiel_hotes": [
{"nom": _n, "adresse": _c.rsplit(":", 1)[0], "job": "fabric",
"role": "hyperviseur"}
for _c, _n in sorted(_fabric_nommes.items(), key=lambda x: x[1])]}
if (_fabric_nommes and ({"serveur_grafana", "serveur_icinga"}
& set(_groupes_de(nom)))) else {}),
# La fabric, en job separe — voir `_fabric_metriques` plus haut.
**({"serveur_prometheus_cibles_supplementaires":
[{"job": "fabric", "cibles": _fabric_metriques}]}
# `hotes` : chaque serie porte `hote="asgard"`. Sans lui, un tableau du
# materiel legende « 192.168.11.43:9100 » — et c'est a l'exploitant de se
# souvenir quelle machine chauffe.
[{"job": "fabric", "cibles": _fabric_metriques, "hotes": _fabric_nommes}]}
if (_fabric_metriques and "serveur_prometheus" in _groupes_de(nom)) else {}),
**communes,
# En dernier : ce qu'une machine declare d'elle-meme prime sur ce que le