diff --git a/AGENTS.md b/AGENTS.md index a0d3ffa..fa93e04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.** diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d5dc0d..a1b6edb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/audit/preuve-2026-09-14.md b/docs/audit/preuve-2026-09-14.md index fe178ac..0bed428 100644 --- a/docs/audit/preuve-2026-09-14.md +++ b/docs/audit/preuve-2026-09-14.md @@ -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 diff --git a/docs/devis-services.md b/docs/devis-services.md index 56df2a8..53e585d 100644 --- a/docs/devis-services.md +++ b/docs/devis-services.md @@ -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. diff --git a/docs/supervision-conception.md b/docs/supervision-conception.md index 5fb596d..293dee3 100644 --- a/docs/supervision-conception.md +++ b/docs/supervision-conception.md @@ -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 diff --git a/roles/client_metrique/meta/metriques.yml b/roles/client_metrique/meta/metriques.yml new file mode 100644 index 0000000..9fad35f --- /dev/null +++ b/roles/client_metrique/meta/metriques.yml @@ -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. diff --git a/roles/serveur_grafana/defaults/main.yml b/roles/serveur_grafana/defaults/main.yml index 39ead6c..ba0f1af 100644 --- a/roles/serveur_grafana/defaults/main.yml +++ b/roles/serveur_grafana/defaults/main.yml @@ -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-" diff --git a/roles/serveur_grafana/tasks/main.yml b/roles/serveur_grafana/tasks/main.yml index 82d3498..9b8c87d 100644 --- a/roles/serveur_grafana/tasks/main.yml +++ b/roles/serveur_grafana/tasks/main.yml @@ -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 diff --git a/roles/serveur_grafana/templates/tableau-role.json.j2 b/roles/serveur_grafana/templates/tableau-role.json.j2 new file mode 100644 index 0000000..44d6849 --- /dev/null +++ b/roles/serveur_grafana/templates/tableau-role.json.j2 @@ -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 %} + ] +} diff --git a/scripts/prouver.py b/scripts/prouver.py index b559723..7504981 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -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}, ]