From 49ceb81a4dd775ac23eeb28977d8eddffcd577ba Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Thu, 17 Sep 2026 10:13:43 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 24 ++ docs/audit/preuve-2026-09-17.md | 118 +++++++++ docs/supervision-conception.md | 28 +++ roles/serveur_grafana/tasks/main.yml | 17 ++ .../templates/tableau-materiel.json.j2 | 206 ++++++++++++++++ roles/serveur_icinga/defaults/main.yml | 6 + roles/serveur_icinga/files/check_materiel.py | 150 ++++++++++++ roles/serveur_icinga/tasks/main.yml | 41 ++++ .../templates/setops-materiel.conf.j2 | 54 +++++ .../templates/prometheus.yml.j2 | 10 + scripts/materiel.py | 223 ++++++++++++++++++ scripts/site_inventaire.py | 22 +- 12 files changed, 895 insertions(+), 4 deletions(-) create mode 100644 docs/audit/preuve-2026-09-17.md create mode 100644 roles/serveur_grafana/templates/tableau-materiel.json.j2 create mode 100644 roles/serveur_icinga/files/check_materiel.py create mode 100644 roles/serveur_icinga/templates/setops-materiel.conf.j2 create mode 100644 scripts/materiel.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3856c48..3aa063b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/audit/preuve-2026-09-17.md b/docs/audit/preuve-2026-09-17.md new file mode 100644 index 0000000..d052e20 --- /dev/null +++ b/docs/audit/preuve-2026-09-17.md @@ -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._ diff --git a/docs/supervision-conception.md b/docs/supervision-conception.md index 293dee3..bda6ff0 100644 --- a/docs/supervision-conception.md +++ b/docs/supervision-conception.md @@ -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. diff --git a/roles/serveur_grafana/tasks/main.yml b/roles/serveur_grafana/tasks/main.yml index 9b8c87d..42f8422 100644 --- a/roles/serveur_grafana/tasks/main.yml +++ b/roles/serveur_grafana/tasks/main.yml @@ -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 : diff --git a/roles/serveur_grafana/templates/tableau-materiel.json.j2 b/roles/serveur_grafana/templates/tableau-materiel.json.j2 new file mode 100644 index 0000000..6a53de9 --- /dev/null +++ b/roles/serveur_grafana/templates/tableau-materiel.json.j2 @@ -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 }} diff --git a/roles/serveur_icinga/defaults/main.yml b/roles/serveur_icinga/defaults/main.yml index 6940684..abb1ff0 100644 --- a/roles/serveur_icinga/defaults/main.yml +++ b/roles/serveur_icinga/defaults/main.yml @@ -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: {} diff --git a/roles/serveur_icinga/files/check_materiel.py b/roles/serveur_icinga/files/check_materiel.py new file mode 100644 index 0000000..6711f5a --- /dev/null +++ b/roles/serveur_icinga/files/check_materiel.py @@ -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()) diff --git a/roles/serveur_icinga/tasks/main.yml b/roles/serveur_icinga/tasks/main.yml index a07a6bb..6448fb9 100644 --- a/roles/serveur_icinga/tasks/main.yml +++ b/roles/serveur_icinga/tasks/main.yml @@ -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 diff --git a/roles/serveur_icinga/templates/setops-materiel.conf.j2 b/roles/serveur_icinga/templates/setops-materiel.conf.j2 new file mode 100644 index 0000000..a55c0d3 --- /dev/null +++ b/roles/serveur_icinga/templates/setops-materiel.conf.j2 @@ -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 %} diff --git a/roles/serveur_prometheus/templates/prometheus.yml.j2 b/roles/serveur_prometheus/templates/prometheus.yml.j2 index fdc2d70..b5c0064 100644 --- a/roles/serveur_prometheus/templates/prometheus.yml.j2 +++ b/roles/serveur_prometheus/templates/prometheus.yml.j2 @@ -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 %} diff --git a/scripts/materiel.py b/scripts/materiel.py new file mode 100644 index 0000000..0ef5d59 --- /dev/null +++ b/scripts/materiel.py @@ -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)) diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index 82b889f..fff7620 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -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