grafana : les panneaux declares deviennent des tableaux

La moitie qui manquait. Les roles declaraient leurs panneaux, Prometheus en
derivait ses cibles, les expressions repondaient, et rien ne les assemblait.

serveur_grafana lit les memes meta/metriques.yml que serveur_prometheus et en
assemble un tableau par role — la raison du panneau devient sa description dans
Grafana. Moisson des tableaux orphelins, prefixe setops-role- pour ne jamais
toucher un tableau ecrit a la main, JSON valide avant d etre pose (Grafana ne
tombe pas sur un tableau illisible : il le saute en silence).

client_metrique declare quatre panneaux sans exportateur — le cas symetrique.
Celui qui compte : la memoire disponible, depuis que le ballon est actif.

Eprouve sur le site : les deux tableaux charges dans le stockage unifie, un
temoin orphelin retire, les quatre panneaux de flotte a 10 series chacun.

Un panneau etait faux — /var/lib/lxcfs rapporte toujours zero octet libre et un
min() en meurt. Corrige en liste blanche : un montage inconnu manque au graphe,
ce qui se voit, au lieu de l ecraser.

P77 garde la table des unites : une unite inventee retombe sur short, lisible et
fausse. 76 OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
This commit is contained in:
Daniel Allaire 2026-09-14 16:32:30 -04:00
parent baa9d12054
commit 311f33d6f6
10 changed files with 522 additions and 5 deletions

View file

@ -188,7 +188,7 @@ Si `ansible-lint` n’est pas disponible, le signaler clairement. Ne pas invente
## Écrire, puis relire (D-68)
`--syntax-check` et `ansible-lint` prouvent que le dépôt est cohérent **avec lui-même**.
C'est aussi ce que font les 76 preuves de `make prouver` : elles lisent le dépôt, sans le
C'est aussi ce que font les 77 preuves de `make prouver` : elles lisent le dépôt, sans le
moindre appel réseau. **Aucune ne demande au système déployé s'il ressemble à ce que le
dépôt annonce.**

View file

@ -1,5 +1,96 @@
# CHANGELOG — Set-OPS
## 2026-09-14 (15) — Le generateur de tableaux : les panneaux declares deviennent des tableaux
Les roles declaraient leurs panneaux dans `meta/metriques.yml`, Prometheus en derivait deja
ses cibles, les expressions repondaient — et RIEN ne les assemblait. Un panneau declare que
personne ne peut regarder n'est pas une observabilite, c'est une intention.
### Ce qui a ete construit
`serveur_grafana` lit desormais les memes `meta/metriques.yml` que `serveur_prometheus`, et
en assemble **un tableau par role**. Exactement le patron du voisin : le role declare, le
moteur derive.
meta/metriques.yml --exportateur--> cible de scrutation (serveur_prometheus)
--panneaux-----> tableau Grafana (serveur_grafana)
UN TABLEAU PAR ROLE, pas un grand. La question qu'on se pose est « comment va
PostgreSQL », pas « comment va la flotte » — celle-la est deja repondue par Icinga. Le
role est l'unite qui declare, donc l'unite qui s'affiche.
LA `raison` DEVIENT LA DESCRIPTION DU PANNEAU. C'est le seul champ qui ne produit aucun
pixel, et le plus important : Grafana l'affiche au survol du titre. Sans elle, celui qui
regarde six mois plus tard voit une courbe sans savoir ce qu'elle annonce.
LA MOISSON, comme pour les sondes orphelines. Un tableau qu'aucun role ne declare plus est
retire. Un tableau orphelin est moins grave qu'une sonde orpheline — il n'echoue pas, il
MENT : il affiche « aucune donnee » pour un service disparu, et celui qui regarde croit a
une panne. Le prefixe `setops-role-` protege `journaux-flotte.json`, ecrit a la main.
LE JSON EST VALIDE AVANT D'ETRE POSE. Grafana n'echoue pas sur un tableau illisible : il
le SAUTE, en silence. Sans `validate:`, une expression mal repliee ferait perdre un tableau
sans qu'aucune tache ne devienne rouge.
### Un second rôle qui déclare, et il donne des données tout de suite
`client_metrique` declare **quatre panneaux sans exportateur** — le cas symetrique de
PostgreSQL. Le job `node` est universel et ecrit une fois dans `prometheus.yml.j2` ; le
deriver le ferait exister deux fois.
Le panneau qui compte : **memoire disponible en part du total**. Depuis que le ballon est
actif, l'hyperviseur reprend de la memoire a une VM qui n'en a pas besoin, 100 Mio par
cycle. La VM ne voit pas son plafond bouger, elle voit sa marge fondre. Aucun verdict ne
peut se poser la-dessus : il n'y a pas de seuil juste, c'est la PENTE qui dit si le
plancher a ete pris trop bas.
### Eprouve sur le site, de bout en bout
| | |
|---|---|
| fichiers assembles | `setops-role-serveur_postgresql.json`, `setops-role-client_metrique.json` |
| Grafana les a-t-il charges ? | oui — `setops-postgresql` et `setops-client-metrique` dans le stockage unifie |
| moisson | un `setops-role-serveur_fantome.json` pose a la main a ete retire, les deux autres conserves |
| les panneaux repondent-ils ? | les 4 de `client_metrique` : 10 series chacun, donnees reelles |
**Le journal ne prouvait rien.** `finished to provision dashboards` s'ecrit aussi quand
Grafana saute un fichier. La verification est allee lire ce que Grafana a VRAIMENT
enregistre — et Grafana 13 range les tableaux dans le **stockage unifie** (table
`resource`), plus dans la table `dashboard`, qui est vide et le restera. Une premiere
lecture y a vu « zero tableau » ; c'etait la mauvaise table.
### Un panneau faux, trouve en le regardant
« Espace libre — le point de montage le plus serre » rendait **0 octet** pour les trois
hyperviseurs. Le coupable : `/var/lib/lxcfs`, un systeme FUSE virtuel qui rapporte toujours
zero, et qui n'est pas en lecture seule — aucun filtre malin ne l'ecartait.
**Un `min()` est impitoyable** : un seul montage pathologique rend le panneau inutile pour
toujours, et sa courbe plate ressemble a un disque plein. La pire sorte de faux — lisible,
alarmant, et faux.
Corrige en **liste blanche** (`ext4|xfs|btrfs|zfs`) plutot qu'en liste noire. Une liste
noire suit ce qui existe, donc prend du retard. Une liste blanche ignore par defaut : un
montage inconnu MANQUE au graphe, ce qui se voit, au lieu de l'ecraser, ce qui ne se voit
pas. Apres correction : min 28 Go, max 401 Go.
### La garde
**P77** exige de chaque panneau un titre, une expression, une unite et une raison — et que
l'unite figure dans la table de traduction de `serveur_grafana`. C'est encore une liste qui
en suit une autre : une unite inventee ne casse rien, le panneau retombe sur `short`, et un
graphe d'octets gradue en unites brutes reste parfaitement lisible et parfaitement faux.
Eprouvee dans les deux sens.
`docs/supervision-conception.md` porte desormais la moitie « metriques » a cote de la
moitie « sondes ».
### Ce qui reste
Le tableau de PostgreSQL est assemble, charge, et **vide** : l'exportateur exige
`vault_pg_exportateur` dans la voute du SITE, qui n'y est pas. Ajouter un secret a cette
voute-la n'est pas un geste a poser sans le dire.
## 2026-09-14 (14) — L'echappatoire du wiki etait documentee cinq fois et n'existait pas
En publiant les huit fiches neuves, `make wiki-publier` a refuse : l'ecosysteme monte est

View file

@ -7,7 +7,7 @@
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (75 OK · 0 echec · 1 saute)
- **Verdict** : ✅ CONFORME (76 OK · 0 echec · 1 saute)
## Preuves
@ -69,7 +69,7 @@
| 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 (76 preuves, 68 roles, 41 groupes). |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (77 preuves, 68 roles, 41 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 `aac74f6` (publie le 2026-09-14). |
@ -88,7 +88,8 @@
| P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 6 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 | 151 gabarits de role : tous se rendent. |
| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 152 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. |
## Couverture des affirmations ✅ du registre

View file

@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met
## Le trou qu'il comble
`scripts/prouver.py` porte 76 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
`scripts/prouver.py` porte 77 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est
cohérent **avec lui-même** — que les handlers existent, que les intrants ont un
propriétaire, que rien n'est codé en dur.

View file

@ -108,6 +108,62 @@ Une **métrique à seuil** — durée de collecte, volume de journaux, taux d'oc
appartient à Prometheus et Grafana. Icinga répond à une seule question : *est-ce cassé ?*
Mélanger les deux rendrait les deux moins lisibles.
## L'autre moitié : `meta/metriques.yml`
Cette phrase appelle son pendant, et il porte le même patron — **le rôle déclare, le
moteur dérive**.
| fichier | ce qu'il produit | la question |
| --- | --- | --- |
| `meta/supervision.yml` | une **sonde** → un verdict, avec un TTL | *est-ce cassé ?* |
| `meta/metriques.yml` | un **exportateur** → une série ; des **panneaux** → un tableau | *depuis quand, et vers où ?* |
**Deux fichiers, deux consommateurs.** `serveur_prometheus` lit l'`exportateur` pour en
tirer sa cible de scrutation ; `serveur_grafana` lit les `panneaux` pour en assembler un
tableau par rôle. Les deux moitiés sont indépendantes : un rôle peut déclarer un
exportateur sans panneau (des séries collectées, regardées ailleurs), et des panneaux sans
exportateur (`client_metrique` — le job `node` est universel, écrit une fois, et le
dériver le ferait exister deux fois).
**Le flux n'est pas dérivé d'ici, et c'est voulu.** Le port de l'exportateur doit s'ouvrir
depuis l'observatoire, et c'est `meta/flux.yml` qui le déclare — là où vivent déjà tous les
flux du rôle. Deux fichiers pour un même fait finiraient par diverger.
### Le critère d'un panneau
*Une série a sa place si elle **précède** un verdict, ou si elle n'en aura **jamais**.*
Ce qui bascule d'un coup appartient à Icinga ; ce qui dérive lentement n'a que le graphe
pour se faire voir. Une base qui passe de 20 à 60 connexions en trois semaines n'a rien
cassé — elle annonce la date où elle cassera.
### La `raison` devient la description du panneau
C'est le seul champ qui ne produit aucun pixel de graphe, et c'est le plus important.
Grafana l'affiche au survol du titre. Sans elle, celui qui regarde six mois plus tard voit
une courbe sans savoir ce qu'elle annonce.
### Agréger, toujours
node_exporter publie une série par interface, par point de montage, par cœur : **339
séries** pour le seul trafic réseau du site, mesuré le 2026-09-14. Un panneau qui les
montre toutes ne montre rien. Et se méfier de `min()` / `max()` : un seul montage
pathologique — `/var/lib/lxcfs`, qui rapporte toujours zéro — suffit à rendre un panneau
faux **pour toujours**, avec une courbe plate parfaitement lisible. Filtrer par **liste
blanche** : un montage inconnu manque au graphe, ce qui se voit, au lieu de l'écraser, ce
qui ne se voit pas.
### Ce que les gardes tiennent
**P77** exige de chaque panneau un titre, une expression, une unité et une raison — et que
l'unité figure dans la table de traduction de `serveur_grafana`. Une unité inventée ne
casse rien : le panneau retombe sur `short`, et un graphe d'octets gradué en unités brutes
reste parfaitement lisible et parfaitement faux.
Ce que P77 **ne** dit pas : si l'expression répond. Aucune lecture statique ne le dira. Un
panneau se prouve comme une sonde — en le regardant rendre des données, et en le notant au
CHANGELOG.
## Chaque sonde vient avec son contrôle négatif
Une sonde qu'on n'a jamais vue échouer n'est pas une sonde, c'est une habitude. Dix-neuf

View file

@ -0,0 +1,85 @@
---
# Metriques derivees du role. Le pendant de `meta/supervision.yml`, et son contraire.
#
# `supervision.yml` -> une SONDE rend un VERDICT avec un TTL. « est-ce casse ? »
# `metriques.yml` -> un EXPORTATEUR expose une SERIE. « depuis quand,
# et vers ou ? »
#
# PAS D'`exportateur:` ICI, ET C'EST VOULU. Ce role EST celui qui pose node_exporter, et
# le job `node` est ecrit en dur dans `prometheus.yml.j2` parce qu'il est UNIVERSEL : il
# vise toute la flotte, pas les porteurs d'un role. Le deriver d'ici le ferait exister
# deux fois, et deux ecritures d'un meme fait finissent par diverger.
#
# Ce fichier n'apporte donc que des `panneaux` — la moitie que personne ne portait. C'est
# le cas symetrique de celui que le moteur sait deja traiter : un role peut declarer un
# exportateur sans panneau, il peut declarer des panneaux sans exportateur.
#
# LE CRITERE, LE MEME QUE PARTOUT : une serie a sa place ici si elle PRECEDE un verdict ou
# si elle n'en aura jamais. Ce qui bascule d'un coup appartient a Icinga ; ce qui derive
# lentement n'a que le graphe pour se faire voir.
#
# TOUT EST AGREGE PAR `instance`. node_exporter publie une serie par interface, par point
# de montage, par coeur : 339 series pour le seul trafic reseau du site, mesure le
# 2026-09-14. Un panneau qui les montre toutes ne montre rien.
panneaux:
- titre: "Mémoire disponible (part du total)"
expr: >-
sum by (instance) (node_memory_MemAvailable_bytes)
/ clamp_min(sum by (instance) (node_memory_MemTotal_bytes), 1)
unite: ratio
raison: >-
CELUI-LA COMPTE DEPUIS QUE LE BALLON EST ACTIF. L'hyperviseur reprend de la memoire
a une VM qui n'en a pas besoin — silencieusement, 100 Mio par cycle. La VM ne voit
pas son plafond bouger : elle voit sa marge fondre. Aucun verdict ne peut se poser
la-dessus, parce qu'il n'y a pas de seuil juste : c'est la PENTE qui dit si le
plancher a ete pris trop bas.
- titre: "Espace libre — le point de montage le plus serré"
# UNE LISTE BLANCHE DE SYSTEMES DE FICHIERS REELS, ET PAS UNE LISTE NOIRE.
#
# La premiere ecriture excluait `tmpfs|devtmpfs|overlay|squashfs`. Mesure du
# 2026-09-14 sur la flotte : le panneau rendait **0 octet libre** pour les trois
# hyperviseurs. Le coupable etait `/var/lib/lxcfs`, un systeme FUSE virtuel qui
# rapporte toujours zero — et qui n'est pas en lecture seule, donc aucun filtre
# malin ne l'ecartait.
#
# UN `min()` EST IMPITOYABLE : un seul montage pathologique suffit a rendre le
# panneau inutile POUR TOUJOURS, et sa courbe plate ressemble a un disque plein.
# C'est la pire sorte de faux : lisible, alarmant, et faux.
#
# Une liste noire suit ce qui existe — elle prend donc du retard des qu'un systeme de
# fichiers nouveau apparait. Une liste blanche, elle, ignore par defaut : un montage
# inconnu manque au graphe, ce qui se voit, au lieu de l'ecraser, ce qui ne se voit
# pas. Mesure du 2026-09-14 : la flotte ne monte que ext4, vfat, tmpfs, fuse et
# fuse.lxcfs.
expr: >-
min by (instance) (node_filesystem_avail_bytes{fstype=~"ext4|xfs|btrfs|zfs"})
unite: octets
raison: >-
La sonde `sante` crie quand un seuil est franchi. Ce panneau dit ce qu'elle ne peut
pas dire : DANS COMBIEN DE TEMPS. Un disque qui perd 2 Go par semaine n'a rien
casse — il annonce la date ou il cassera, et c'est la seule chose qu'on ne peut pas
mesurer apres coup.
- titre: "Charge par cœur"
expr: >-
node_load1
/ on(instance) group_left count by (instance) (node_cpu_seconds_total{mode="idle"})
unite: nombre
raison: >-
NORMALISEE, sinon elle ne se compare pas : une charge de 4 est confortable sur huit
coeurs et desastreuse sur deux. Au-dessus de 1, la machine attend plus qu'elle ne
travaille. C'est le contexte des trois autres — une memoire qui fond a charge
CONSTANTE ne dit pas la meme chose qu'une memoire qui fond parce que le travail a
double.
- titre: "Trafic réseau entrant"
expr: >-
sum by (instance) (rate(node_network_receive_bytes_total{device!="lo"}[5m]))
unite: octets_par_seconde
raison: >-
Il n'aura JAMAIS de verdict, et c'est pourquoi il est la. Aucun seuil de trafic
n'est juste : ce qui se lit, c'est le changement de FORME — un plateau la ou il y
avait des vagues, un creux aux heures ouvrables. Une sauvegarde qui ne part plus et
un flux qu'on vient de fermer se ressemblent ici, et ne se voient nulle part
ailleurs.

View file

@ -77,3 +77,31 @@ serveur_grafana_sonde_url: "http://127.0.0.1:{{ serveur_grafana_port }}"
# DEGRADE, JAMAIS DEVINE : pas de cache d'amorcage declare, pas de reecriture.
serveur_grafana_depot_schema: >-
{{ 'http' if (artefacts_amorcage | default('') | string | length > 0) else 'https' }}
# --- LES TABLEAUX DERIVES DES ROLES (2026-09-14) --------------------------------------
#
# TRADUCTION DES UNITES : ce qu'un role ECRIT dans son `meta/metriques.yml` vers ce que
# Grafana COMPREND. Un role parle sa langue — « octets », « connexions » — et n'a pas a
# connaitre la nomenclature de Grafana ; c'est le moteur qui traduit, comme partout ici.
#
# UNE UNITE ABSENTE DE CETTE TABLE retombe sur `short`, et le graphe reste lisible. Mais
# elle ne passe pas inapercue : P77 refuse une unite que cette table ne connait pas.
serveur_grafana_unites:
octets: bytes
octets_par_seconde: Bps
secondes: s
millisecondes: ms
ratio: percentunit
pourcent: percent
connexions: short
requetes: short
messages: short
tps: ops
operations_par_seconde: ops
nombre: short
# LE PREFIXE DES TABLEAUX DERIVES. Il sert a la MOISSON : tout fichier qui le porte et
# qu'aucun role ne declare plus est retire. Sans ce marqueur on ne saurait pas distinguer
# un tableau derive d'un tableau ecrit a la main (`journaux-flotte.json`), et on effacerait
# le travail de quelqu'un.
serveur_grafana_tableaux_prefixe: "setops-role-"

View file

@ -245,6 +245,109 @@
directory_mode: "0750"
notify: Redemarrer grafana
# --- UN TABLEAU PAR ROLE QUI DECLARE DES PANNEAUX (2026-09-14) -------------------------
#
# LA MOITIE QUI MANQUAIT. Les roles declaraient leurs panneaux dans `meta/metriques.yml`,
# Prometheus en derivait deja ses cibles, les expressions repondaient — et RIEN ne les
# assemblait. Un panneau declare que personne ne peut regarder n'est pas une
# observabilite, c'est une intention.
#
# EXACTEMENT LE PATRON DE `serveur_prometheus`, qui lit les memes fichiers pour en tirer
# ses cibles de scrutation. Les deux moities de `metriques.yml` trouvent ainsi leur
# consommateur : l'`exportateur` chez Prometheus, les `panneaux` ici.
#
# `run_once` ET `delegate_to: localhost` : les declarations vivent dans le DEPOT, sur le
# controleur — pas sur la machine. On les lit une fois, on les rend pour tout le monde.
- name: Relever les metriques que les roles declarent
ansible.builtin.find:
paths: "{{ role_path }}/.."
patterns: metriques.yml
recurse: true
depth: 3
delegate_to: localhost
become: false
run_once: true
check_mode: false
register: serveur_grafana_metas
- name: Lire chaque declaration de metriques
ansible.builtin.slurp:
src: "{{ item.path }}"
delegate_to: localhost
become: false
run_once: true
check_mode: false
loop: "{{ serveur_grafana_metas.files }}"
loop_control:
label: "{{ item.path | dirname | dirname | basename }}"
register: serveur_grafana_declarations
# CE QUI SERA POSE, CALCULE UNE FOIS. On ne garde que les roles qui declarent vraiment des
# `panneaux` : un `metriques.yml` peut n'avoir qu'un `exportateur` — il aura une cible
# Prometheus et aucun tableau, et c'est un etat legitime, pas un oubli.
- name: Retenir les roles qui ont des panneaux a montrer
ansible.builtin.set_fact:
serveur_grafana_tableaux: >-
{{ serveur_grafana_declarations.results
| map(attribute='item.path') | map('dirname') | map('dirname') | map('basename')
| zip(serveur_grafana_declarations.results
| map(attribute='content') | map('b64decode') | map('from_yaml'))
| selectattr(1, 'mapping')
| selectattr('1.panneaux', 'defined')
| rejectattr('1.panneaux', 'none')
| list }}
run_once: true
- name: Assembler le tableau de chaque role
ansible.builtin.template:
src: tableau-role.json.j2
dest: "{{ serveur_grafana_dashboards_dir }}/{{ serveur_grafana_tableaux_prefixe }}{{ item.0 }}.json"
owner: root
group: grafana
mode: "0640"
# LE JSON EST VALIDE AVANT D'ETRE POSE. Grafana n'echoue pas sur un tableau illisible :
# il le SAUTE, en silence, et le tableau disparait simplement de la liste. Sans cette
# validation, une expression PromQL mal repliee ferait perdre un tableau sans qu'aucune
# tache ne devienne rouge.
validate: "python3 -c \"import json,sys; json.load(open(sys.argv[1]))\" %s"
loop: "{{ serveur_grafana_tableaux }}"
loop_control:
label: "{{ item.0 }} ({{ item.1.panneaux | length }} panneau(x))"
vars:
tableau_role: "{{ item.0 }}"
tableau_panneaux: "{{ item.1.panneaux }}"
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 :
# quand un ecosysteme cesse de declarer un service, plus rien ne retire ce qu'on avait pose
# pour lui. Un tableau orphelin est moins grave qu'une sonde orpheline — il n'echoue pas,
# il MENT : il affiche « aucune donnee » pour un service qui n'existe plus, et celui qui
# regarde croit a une panne.
#
# LE PREFIXE EST CE QUI PROTEGE `journaux-flotte.json` : il n'en porte pas, donc la moisson
# ne le voit pas. On n'efface jamais un tableau ecrit a la main.
- name: Relever les tableaux derives reellement poses
ansible.builtin.find:
paths: "{{ serveur_grafana_dashboards_dir }}"
patterns: "{{ serveur_grafana_tableaux_prefixe }}*.json"
register: serveur_grafana_poses
- name: Retirer les tableaux qu'aucun role ne declare plus
ansible.builtin.file:
path: "{{ item.path }}"
state: absent
loop: "{{ serveur_grafana_poses.files }}"
loop_control:
label: "{{ item.path | basename }}"
when: >-
(item.path | basename
| regex_replace('^' ~ serveur_grafana_tableaux_prefixe, '')
| regex_replace('\.json$', ''))
not in (serveur_grafana_tableaux | map(attribute=0) | list)
notify: Redemarrer grafana
- name: Assurer le repertoire de drop-in systemd
ansible.builtin.file:
path: /etc/systemd/system/grafana-server.service.d

View file

@ -0,0 +1,77 @@
{#- GENERE par Set-OPS (role serveur_grafana). Ne pas editer a la main.
UN TABLEAU PAR ROLE, ASSEMBLE DEPUIS SON `meta/metriques.yml`.
C'EST LA MOITIE QUI MANQUAIT. Les roles declaraient leurs panneaux, Prometheus
collectait les series, les expressions repondaient — et rien ne les assemblait. Un
panneau declare que personne ne peut regarder n'est pas une observabilite, c'est une
intention.
POURQUOI UN TABLEAU PAR ROLE ET PAS UN GRAND. Parce que la question qu'on se pose est
« comment va PostgreSQL », pas « comment va la flotte » — celle-la est deja repondue
par Icinga, et par le tableau des journaux. Un tableau par role suit la meme ligne de
partage que tout le reste ici : le role est l'unite qui declare, donc l'unite qui
s'affiche.
LA `raison` DEVIENT LA DESCRIPTION DU PANNEAU. Elle est le seul champ obligatoire qui
ne produit aucun pixel de graphe, et c'est le plus important : sans elle, celui qui
regarde a six mois de distance voit une courbe sans savoir ce qu'elle annonce. Grafana
l'affiche au survol du titre.
-#}
{%- set roles_unites = serveur_grafana_unites -%}
{
"uid": {{ ("setops-" ~ tableau_role | replace("serveur_", "") | replace("_", "-")) | truncate(40, true, "") | tojson }},
"title": {{ ("Set-OPS — " ~ tableau_role | replace("serveur_", "") | replace("_", " ")) | tojson }},
"tags": ["set-ops", "derive", {{ tableau_role | tojson }}],
"timezone": "browser",
"refresh": "1m",
"time": { "from": "now-6h", "to": "now" },
"schemaVersion": 39,
"version": 1,
"templating": {
"list": [
{
"name": "ds_prom",
"label": "Source Prometheus",
"type": "datasource",
"query": "prometheus",
"hide": 2,
"current": {}
}
]
},
"panels": [
{%- for p in tableau_panneaux %}
{
"id": {{ loop.index }},
"type": "timeseries",
"title": {{ p.titre | tojson }},
{#- LA RAISON, TELLE QUELLE. On ne la resume pas : c'est le texte que le role a
ecrit pour expliquer ce que la courbe annonce. #}
"description": {{ (p.raison | default("") | trim) | tojson }},
"datasource": { "type": "prometheus", "uid": "${ds_prom}" },
{#- DEUX PAR RANGEE. Une expression PromQL rend souvent plusieurs series ; a pleine
largeur on ne compare rien, a un quart de largeur on ne lit rien. #}
"gridPos": { "h": 8, "w": 12, "x": {{ 0 if loop.index0 % 2 == 0 else 12 }}, "y": {{ (loop.index0 // 2) * 8 }} },
"fieldConfig": {
"defaults": {
{#- L'UNITE VIENT DU ROLE, TRADUITE EN UNITE GRAFANA. Un `unite:` inconnu
retombe sur `short` — et P77 refuse de laisser ce cas passer inapercu. #}
"unit": {{ roles_unites.get(p.unite | default("") | string, "short") | tojson }},
"custom": { "drawStyle": "line", "fillOpacity": 10, "showPoints": "never" }
}
},
"options": { "legend": { "displayMode": "list", "placement": "bottom" } },
"targets": [
{
"refId": "A",
"datasource": { "type": "prometheus", "uid": "${ds_prom}" },
{#- L'EXPRESSION EST CELLE DU ROLE, non reecrite. Repliee sur une ligne parce
que `>-` en YAML laisse des retours que JSON refuse. #}
"expr": {{ (p.expr | trim | replace("\n", " ")) | regex_replace("\\s+", " ") | tojson }}
}
]
}{{ "," if not loop.last else "" }}
{%- endfor %}
]
}

View file

@ -2105,6 +2105,80 @@ def preuve_carte_dit_vrai() -> tuple[bool, str]:
f"{len(_carte_chiffres_mesures())} chiffres correspondent a la mesure.")
def preuve_panneaux_assembles() -> tuple[bool, str]:
"""Chaque panneau declare porte de quoi etre assemble, et son unite est traduisible.
LES DEUX MOITIES DE `meta/metriques.yml` ONT CHACUNE LEUR CONSOMMATEUR : l'
`exportateur` devient une cible de scrutation chez `serveur_prometheus`, les
`panneaux` deviennent un tableau chez `serveur_grafana`. P64 fait ce travail pour les
sondes ; celle-ci le fait pour les series.
CE QU'ELLE EXIGE DE CHAQUE PANNEAU. Un `titre`, une `expr`, une `unite`, et une
`raison`. La `raison` est le seul champ qui ne produit aucun pixel, et c'est le plus
important : elle devient la description du panneau dans Grafana. Sans elle, celui qui
regarde a six mois de distance voit une courbe sans savoir ce qu'elle annonce.
ET SURTOUT : L'UNITE DOIT ETRE TRADUISIBLE. `serveur_grafana` tient une table qui
traduit la langue des roles — « octets », « connexions » — vers celle de Grafana.
C'est UNE LISTE QUI EN SUIT UNE AUTRE, et une liste qui suit une autre prend du
retard : un role qui invente une unite ne casse rien, son panneau retombe simplement
sur `short`. Un graphe d'octets gradue en unites brutes reste parfaitement lisible et
parfaitement faux.
CE QU'ELLE NE FAIT PAS. Elle ne dit pas si l'expression REPOND — aucune lecture
statique ne le dira, il faut un Prometheus qui a des donnees. C'est au controle de
chaque panneau, trace au CHANGELOG, comme le controle negatif d'une sonde.
"""
roles = RACINE / "roles"
table = roles / "serveur_grafana" / "defaults" / "main.yml"
try:
unites = set((yaml.safe_load(table.read_text(encoding="utf-8")) or {})
.get("serveur_grafana_unites") or {})
except (OSError, yaml.YAMLError) as e:
return False, f"Table des unites de serveur_grafana illisible ({e})."
if not unites:
return False, ("`serveur_grafana_unites` est vide : aucun panneau ne pourrait "
"etre gradue.")
fautes, mesures = [], []
for meta in sorted(roles.glob("*/meta/metriques.yml")):
role = meta.parent.parent.name
try:
data = yaml.safe_load(meta.read_text(encoding="utf-8")) or {}
except yaml.YAMLError as e:
fautes.append(f"{role} : `meta/metriques.yml` illisible ({e})")
continue
panneaux = data.get("panneaux")
expo = data.get("exportateur")
if not panneaux and not expo:
fautes.append(f"{role} : `meta/metriques.yml` ne declare ni exportateur "
f"ni panneau — personne ne le lira")
continue
for p in (panneaux or []):
titre = p.get("titre") or "(sans titre)"
for champ in ("titre", "expr", "unite", "raison"):
if not str(p.get(champ) or "").strip():
fautes.append(f"{role}/{titre} : `{champ}` manquant"
+ (" — un panneau dont personne ne sait ce qu'il "
"annonce n'est pas une observabilite"
if champ == "raison" else ""))
unite = str(p.get("unite") or "").strip()
if unite and unite not in unites:
fautes.append(f"{role}/{titre} : unite « {unite} » absente de "
f"`serveur_grafana_unites` — le panneau retomberait sur "
f"`short`, lisible et faux")
mesures.append(f"{role}/{titre}")
if fautes:
return False, "Panneaux declares :\n - " + "\n - ".join(fautes)
if not mesures:
return True, ("Aucun panneau declare — rien a assembler "
"(cf. docs/supervision-conception.md).")
return True, (f"{len(mesures)} panneau(x) declare(s) dans "
f"{len({m.split('/')[0] for m in mesures})} role(s), tous avec titre, "
f"expression, raison et une unite que la table sait traduire.")
def preuve_gabarits_shell_rendent() -> tuple[bool, str]:
"""Aucun gabarit shell ne contient de sequence que Jinja lirait autrement.
@ -3875,6 +3949,8 @@ PREUVES: list[dict] = [
"refs": [], "func": preuve_parametres_clone_traversent},
{"id": "P76", "titre": "Tout gabarit de role se rend vraiment",
"refs": [], "func": preuve_gabarits_shell_rendent},
{"id": "P77", "titre": "Panneaux declares : assemblables, et gradues",
"refs": [], "func": preuve_panneaux_assembles},
]