observabilite : le site cesse de ne rien voir de lui-meme

D-87 disait que l hebergeur n a pas le droit de voir les journaux de ses
locataires. La decision avait une face cachee : a force de refuser de voir
ceux des autres, le site s etait prive des siens. Ses sept machines
n expediaient nulle part.

Il prend donc sa propre pile : prometheus, loki et grafana sur site-mon-01,
sans aucun lien avec ceux d un tenant. L exemption qui bloquait portait sa
propre condition de levee, ecrite cinq jours plus tot dans le plan.

Grafana au site n a pas de SSO, et le role l ignorait : il reclamait l IdP
avant de regarder s il en voulait un. L interrupteur existait, il n etait
pas honore. Le role refuse desormais SSO eteint ET formulaire local eteint
- la combinaison deploie un Grafana en sante ou personne ne peut entrer -
et le reglage du formulaire sort du if du SSO, ou il disparaissait en
laissant le defaut amont decider en silence.

Deux manques se cachaient l un l autre dans le devis de la frontiere, et
Prometheus voyait 1 cible sur 7 :

  - le devis derivait les groupes d une machine du site de applications.yml
    seul, et ne voyait donc aucune integration universelle - alors que
    client_metrique ouvre un port d ecoute ;
  - une sortie vers un role du site visait !SETOPS_INTERNES, qui exclut
    precisement la machine nommee. Le devis autorisait a expedier les
    journaux du site a n importe quel Loki du monde, et a nul autre endroit
    qu a celui-la. Neuf flux dans ce cas.

Et deux declarations justes ne font qu une regle : appliquer_opnsense pose
tout en direction: in (D-61).

Les deux agents se supervisent enfin eux-memes. La sonde des journaux ne
demande pas si Alloy tourne, elle lit ce qu il a du JETER. Elle a fait ses
preuves le jour meme : Alloy actif, /-/ready a 200, et cinquante lignes
perdues sur six machines.

Mesure : metriques 7/7 vert, journaux 1/7 - les six autres attendent la
regle de frontiere, et le disent. prouver 64 OK, ansible-lint 0 defaut.

Reste a la main de l exploitant : make frontiere-appliquer CONFIRMER=true,
et le secret vault_grafana_admin a deposer dans underlay.vault.yml.

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-10 04:43:01 -04:00
parent 5880b22d5a
commit f2c580235d
14 changed files with 492 additions and 23 deletions

View file

@ -1,5 +1,104 @@
# CHANGELOG — Set-OPS
## 2026-09-10 (5) — Le site prend sa propre pile d'observabilite
Suite directe de D-87. La decision disait : *l'hebergeur n'a pas le droit de voir les
journaux de ses locataires.* Elle avait une face cachee — **a force de refuser de voir
ceux des autres, le site s'etait prive des siens.** Ses sept machines n'expediaient nulle
part.
Le remede n'est pas d'assouplir la frontiere, c'est de donner au site **sa** pile :
`prometheus`, `loki` et `grafana` sur `site-mon-01`, pour les machines du site, sans
aucun lien avec ceux d'un tenant.
### Ce qui bloquait etait deja documente, dans le plan lui-meme
`10-intrants.yml` exemptait toutes les machines du site de `client_metrique` et
`client_journal`. La prose de l'exemption portait sa propre condition de levee :
> *« Le jour ou le site prend un Loki et un Prometheus, on retire ces deux lignes. »*
Ce jour-la etant venu, l'exemption s'est refermee toute seule. Elle aura tenu cinq jours.
### Grafana au site n'a pas de SSO, et le role l'ignorait
Le site n'a ni Keycloak ni `domaines.yml` — l'identite ne monte pas dans le site, c'est
D-87. `serveur_grafana` reclamait pourtant l'IdP **avant** de regarder s'il en voulait un :
le deploiement echouait sur un registre absent, pour deriver une URL qu'aucun gabarit
n'allait ecrire. L'interrupteur `serveur_grafana_oidc_actif` existait, il n'etait pas honore.
Trois corrections, dont deux depassent le site :
- `resoudre_idp` n'est appele que si le SSO est actif ;
- le role **refuse** SSO eteint *et* formulaire local eteint — la combinaison deploie un
Grafana en parfaite sante ou personne ne peut entrer ;
- `GF_AUTH_DISABLE_LOGIN_FORM` sort du `{% if %}` du SSO. Il y disparaissait quand le SSO
etait eteint, et c'est le defaut amont qui decidait en silence. *Un reglage
d'authentification qu'aucun fichier n'ecrit est un reglage que personne ne peut relire.*
### Deux manques se cachaient l'un l'autre dans le devis de la frontiere
Prometheus voyait **1 cible sur 7**. Les regles d'hote etaient justes ; c'est le devis
OPNsense qui avait deux trous, et le second masquait le premier.
**1. Le devis ne connaissait pas les integrations universelles.** Il derivait les groupes
d'une machine du site de `applications.yml` seul — qui declare les *services*. Il ne voyait
donc ni `client_metrique`, ni `client_journal`, ni `client_pki`, ni `client_sante`, alors
que ces groupes portent des flux et **ouvrent des ports d'ecoute**. La source est desormais
l'inventaire du site, seule autorite sur ce qu'une machine porte vraiment.
**2. Une sortie vers un role du site visait « l'exterieur ».** Symetrique du correctif du
2026-09-02, qui n'avait traite que l'entree. `!SETOPS_INTERNES` est la bonne destination
quand le pair est lointain ; quand il **nomme un role du site**, elle dit exactement
l'inverse du flux declare — elle exclut la seule machine visee :
client_journal -> !SETOPS_INTERNES port 3100
Le devis autorisait a expedier les journaux du site a n'importe quel Loki du monde, et a
nul autre endroit qu'a celui-la. Neuf flux etaient dans ce cas : PKI, sante, resolveur,
sauvegarde, courriel de la forge, base d'Icinga.
**Et les deux declarations ne font qu'une regle.** `appliquer_opnsense` pose tout en
`direction: in` (D-61) : deux regles qui ne different que par leur `sens` sont le meme
filtre pose deux fois. Dedoublonnage sur ce que la frontiere applique vraiment — la forme
`ingress` gagne, pour ne pas retirer-puis-recreer des regles deja justes.
Devis : **49 regles a creer, 5 a retirer** — les cinq etant exactement les sorties trop
larges dont la jumelle etroite existe deja.
### Les deux agents se supervisent enfin eux-memes
`client_metrique` et `client_journal` etaient parmi les groupes sans sonde. Ils en ont une.
`metriques` interroge **l'endroit que Prometheus interroge**, pas le gestionnaire de
services : un node_exporter actif mais muet est vert pour systemd. Elle declare aussi les
dix collecteurs qui cherchent du materiel qu'une VM n'a pas (`zfs`, `mdadm`, `infiniband`…)
— ils echouent identiquement sur les sept machines, et *une sonde rouge partout est une
sonde qu'on cesse de lire.*
`journaux` ne demande pas si Alloy tourne : elle lit ce qu'il a du **jeter**, et le compare
au passage precedent — ce qui augmente est une perte en cours, ce qui stagne est une
cicatrice.
Elle a fait ses preuves le jour meme. Sur six machines :
Alloy: active (running) /-/ready: 200 dropped_entries_total: 50
Trois indicateurs verts, cinquante lignes perdues. La regle de frontiere manquait encore.
### Etat mesure
| | |
|---|---|
| `metriques` | **7/7 vert** |
| `journaux` | **1/7** — les six autres attendent la regle de frontiere, et le disent |
| cibles Prometheus | 2/8 pour la meme raison |
| `make prouver` | 64 OK, 0 echec |
| `ansible-lint` | 0 defaut, profil `production` |
**Reste a la main de l'exploitant** : `make frontiere-appliquer CONFIRMER=true`, et le
secret `vault_grafana_admin` a deposer dans `underlay.vault.yml`.
## 2026-09-10 (4) — Ce que l'hebergeur n'a pas le droit de VOIR (D-87)
Question posee : *« quels roles ne dois-je pas embarquer dans le site ? »*

View file

@ -55,14 +55,14 @@
| 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 : 54 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 118 regle(s) du site. |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 151 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 89 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 (121 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 183 regles `pass`), tous non consignes et tous motives. |
| 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 216 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 — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |
@ -76,7 +76,7 @@
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 47 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:5, 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 | 20 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_pki/certificat, serveur_artefacts/cache-apt, serveur_artefacts/cache-apt-vol |
| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 22 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, serveur_ |
## Couverture des affirmations ✅ du registre

View file

@ -33,3 +33,11 @@ client_journal_loki_ca: "/etc/step/certs/root_ca.crt"
# qu'aucun controle ne peut donc voir la collision.
client_journal_http_port: 12346
client_journal_http_adresse: "127.0.0.1"
# --- Sonde de supervision (voir meta/supervision.yml) ---
# L'interface de diagnostic d'Alloy, la ou il tient le compte de ce qu'il a expedie et de
# ce qu'il a jete. Locale : personne d'autre n'a a la joindre.
client_journal_sonde_url: >-
http://{{ client_journal_http_adresse }}:{{ client_journal_http_port }}
# La memoire d'un passage a l'autre : sans elle, un compteur cumulatif ne dit pas QUAND.
client_journal_sonde_etat: "/var/lib/setops/journaux-jetees"

View file

@ -0,0 +1,25 @@
---
# Supervision derivee du role. Voir docs/supervision-conception.md.
#
# CE QUE CETTE SONDE MESURE : les journaux SORTENT-ILS ? Pas « Alloy tourne », pas
# « le port de Loki repond » — un connect() ne prouve rien, et cette lecon a deja ete
# payee ici. Alloy tient lui-meme le compte de ce qu'il a du JETER : c'est la seule
# mesure qui distingue « rien a envoyer » de « rien n'est parti ».
#
# CE QUE CA A DEJA ATTRAPE, le jour meme ou la sonde a ete ecrite (2026-09-10) : les six
# machines du site hors de la zone de Loki affichaient
# loki_write_dropped_entries_total{reason="ingester_error"} 50
# pendant que leur Alloy etait `active (running)` et que son `/-/ready` rendait 200. La
# frontiere n'avait pas encore la regle. Trois indicateurs verts, cinquante lignes de
# journal perdues.
#
# POURQUOI UNE MEMOIRE D'UN PASSAGE A L'AUTRE. Un compteur cumulatif ne dit pas QUAND.
# Une machine qui a perdu cinquante lignes il y a un mois et plus rien depuis n'est pas
# en panne — la reparer une seconde fois n'a pas de sens. On compare donc au passage
# precedent : ce qui AUGMENTE est une perte en cours, ce qui stagne est une cicatrice.
sondes:
- nom: journaux
ttl: 5400
raison: >-
Les journaux de cette machine partent-ils vraiment vers Loki ? Alloy peut tourner,
se declarer pret, et jeter chaque ligne faute de pouvoir joindre le collecteur.

View file

@ -91,3 +91,33 @@
name: "{{ client_journal_service }}"
enabled: true
state: started
# LA SONDE VIT AVEC LE ROLE QU'ELLE MESURE (docs/supervision-conception.md). Le porteur
# `client_sante` fait tourner tout ce qui vit dans ce repertoire ; il n'a pas a savoir ce
# que celle-ci mesure.
- name: Assurer le repertoire des sondes de supervision
ansible.builtin.file:
path: /usr/local/lib/setops/sondes
state: directory
owner: root
group: root
mode: "0755"
# La sonde compare le compteur de lignes jetees a celui du passage precedent : il lui
# faut un endroit ou s'en souvenir. `/var/lib`, parce que ce n'est pas un cache — le
# perdre ferait passer une cicatrice ancienne pour une perte en cours.
- name: Assurer le repertoire d etat des sondes
ansible.builtin.file:
path: /var/lib/setops
state: directory
owner: root
group: root
mode: "0750"
- name: Deposer la sonde d expedition des journaux
ansible.builtin.template:
src: sonde-journaux.sh.j2
dest: /usr/local/lib/setops/sondes/journaux.sh
owner: root
group: root
mode: "0750"

View file

@ -0,0 +1,52 @@
#!/bin/bash
# GENERE par Set-OPS (role client_journal). Ne pas editer a la main.
#
# SONDE « journaux » — les lignes SORTENT-ELLES vers Loki ?
#
# Contrat : docs/supervision-conception.md (API des greffons Nagios) — une ligne, 0/1/2.
# Mise en defaut PAR PARAMETRE : `client_journal_sonde_url` (port ferme).
set -uo pipefail
URL={{ client_journal_sonde_url }}
ETAT={{ client_journal_sonde_etat }}
corps=$(curl -s --max-time 10 "${URL}/metrics" 2>/dev/null)
if [[ -z "${corps}" ]]; then
echo "Alloy ne repond pas sur ${URL} : impossible de savoir si les journaux partent."
exit 2
fi
# CE QU'ALLOY A DU JETER. On somme tous les motifs : `ingester_error` (le collecteur
# refuse ou reste injoignable), `rate_limited`, `queue_is_full`… La distinction interesse
# le diagnostic, pas la question « des lignes sont-elles perdues ? ».
jetees=$(printf '%s\n' "${corps}" \
| awk '/^loki_write_dropped_entries_total\{/ {s += $2} END {printf "%d", s+0}')
envoyees=$(printf '%s\n' "${corps}" \
| awk '/^loki_write_sent_entries_total\{/ {s += $2} END {printf "%d", s+0}')
# LA MEMOIRE D'UN PASSAGE : sans elle, un compteur cumulatif ne dit pas QUAND.
precedent=0
[[ -r "${ETAT}" ]] && precedent=$(cat "${ETAT}" 2>/dev/null | tr -cd '0-9')
: "${precedent:=0}"
printf '%s' "${jetees}" > "${ETAT}" 2>/dev/null || true
perf="envoyees=${envoyees} jetees=${jetees}"
nouvelles=$(( jetees - precedent ))
if (( nouvelles > 0 )); then
echo "PERTE EN COURS : ${nouvelles} ligne(s) de journal jetees depuis le dernier passage (${jetees} au total). | ${perf}"
exit 2
fi
# UN COMPTEUR QUI RECULE veut dire qu'Alloy a redemarre — ses compteurs repartent de
# zero. Ce n'est pas une perte, c'est une remise a zero, et la dire pour ce qu'elle est
# evite de chercher une panne la ou il y a eu un redemarrage.
if (( nouvelles < 0 )); then
echo "Alloy a redemarre (compteurs remis a zero) ; ${jetees} ligne(s) jetees depuis. | ${perf}"
exit 1
fi
if (( jetees > 0 )); then
echo "Journaux expedies ; ${jetees} ligne(s) perdues par le passe, plus rien depuis. | ${perf}"
exit 1
fi
echo "Journaux expedies vers Loki, aucune ligne perdue. | ${perf}"
exit 0

View file

@ -42,3 +42,28 @@ client_metrique_tls_dir: "/etc/prometheus-node-exporter/tls"
client_metrique_web_config: "/etc/prometheus-node-exporter/web-config.yml"
client_metrique_tls_source_cert: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.crt"
client_metrique_tls_source_cle: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.key"
# --- Sonde de supervision (voir meta/supervision.yml) ---
# L'ENDROIT QUE PROMETHEUS INTERROGE, vu depuis la machine elle-meme. Le port se derive
# de l'adresse d'ecoute declaree : ecrire `9100` ici le ferait mentir le jour ou on la
# change, et une sonde qui vise le mauvais port rapporte une panne qui n'existe pas.
client_metrique_sonde_schema: "{{ 'https' if client_metrique_tls_actif | bool else 'http' }}"
client_metrique_sonde_port: "{{ client_metrique_adresse_ecoute | regex_replace('^.*:', '') }}"
client_metrique_sonde_url: >-
{{ client_metrique_sonde_schema }}://127.0.0.1:{{ client_metrique_sonde_port }}/metrics
# LES COLLECTEURS QUI CHERCHENT DU MATERIEL ABSENT D'UNE VM (mesure du 2026-09-10 : les
# dix memes echouent sur les sept machines du site). Leur echec n'est pas une panne, et
# une sonde rouge partout est une sonde qu'on cesse de lire. Sur du METAL, vider cette
# liste : `mdadm` ou `bonding` qui tombe y voudrait dire quelque chose.
client_metrique_collecteurs_sans_objet:
- bonding
- fibrechannel
- infiniband
- ipvs
- mdadm
- nfs
- nfsd
- pressure
- tapestats
- zfs

View file

@ -0,0 +1,26 @@
---
# Supervision derivee du role. Voir docs/supervision-conception.md.
#
# CE QUE CETTE SONDE MESURE. La question utile n'est pas « node_exporter tourne-t-il ? »
# — un service actif qui ne rend aucune metrique est vert pour systemd et muet pour
# Prometheus. C'est « cette machine RAPPORTE-T-ELLE ? », posee la ou vit la verite :
# a l'endroit meme que Prometheus interroge.
#
# CE QU'ON NE MESURE PAS ICI : que Prometheus l'ait effectivement recu. Ca, c'est la
# sonde `collecte`, chez le serveur — et c'est le bon partage. Une machine ne peut pas
# temoigner de ce qui arrive a l'autre bout ; elle temoigne de ce qu'elle offre.
#
# LE MATERIEL ABSENT N'EST PAS UNE PANNE (mesure du 2026-09-10). Dix collecteurs
# echouent identiquement sur les sept machines du site — `zfs`, `mdadm`, `infiniband`,
# `bonding`… Ils cherchent du materiel qu'une VM n'a pas. Une sonde qui crierait la
# serait rouge partout, tout le temps, et on apprendrait a ne plus la lire. Ils sont
# donc DECLARES sans objet, dans une liste qu'une machine de metal peut vider.
sondes:
- nom: metriques
# Six fois la periode du porteur (15 min) : cinq passages peuvent manquer avant
# qu'Icinga ne perime. On ne crie pas pour un hoquet, on crie pour un silence.
ttl: 5400
raison: >-
Cette machine offre-t-elle vraiment ses metriques ? Un node_exporter actif mais
muet — ou dont un collecteur ATTENDU s'est mis a echouer — laisse Prometheus
collecter du vide sans que rien ne le dise.

View file

@ -121,3 +121,22 @@
name: "{{ client_metrique_service }}"
enabled: true
state: started
# LA SONDE VIT AVEC LE ROLE QU'ELLE MESURE (docs/supervision-conception.md). Le porteur
# `client_sante` fait tourner tout ce qui vit dans ce repertoire ; il n'a pas a savoir ce
# que celle-ci mesure.
- name: Assurer le repertoire des sondes de supervision
ansible.builtin.file:
path: /usr/local/lib/setops/sondes
state: directory
owner: root
group: root
mode: "0755"
- name: Deposer la sonde des metriques servies
ansible.builtin.template:
src: sonde-metriques.sh.j2
dest: /usr/local/lib/setops/sondes/metriques.sh
owner: root
group: root
mode: "0750"

View file

@ -0,0 +1,47 @@
#!/bin/bash
# GENERE par Set-OPS (role client_metrique). Ne pas editer a la main.
#
# SONDE « metriques » — cette machine offre-t-elle vraiment ses metriques ?
#
# Contrat : docs/supervision-conception.md (API des greffons Nagios) — une ligne, 0/1/2.
# Mise en defaut PAR PARAMETRE : `client_metrique_sonde_url` (port ferme, mauvais schema).
set -uo pipefail
URL={{ client_metrique_sonde_url }}
# Les collecteurs qui cherchent du materiel qu'une VM n'a pas. Vider cette liste sur du
# metal : leur echec y voudrait dire quelque chose.
SANS_OBJET="{{ client_metrique_collecteurs_sans_objet | join(' ') }}"
# ON INTERROGE L'ENDROIT QUE PROMETHEUS INTERROGE, pas le gestionnaire de services.
# `-k` parce que le certificat est celui de l'hote, emis par notre AC : le valider ici
# reviendrait a mesurer la PKI, ce que fait deja la sonde `certificat`.
corps=$(curl -sk --max-time 10 "${URL}" 2>/dev/null)
if [[ -z "${corps}" ]]; then
echo "node_exporter ne rend rien sur ${URL}."
exit 2
fi
lignes=$(printf '%s\n' "${corps}" | grep -c '^node_')
if (( lignes == 0 )); then
echo "node_exporter repond sur ${URL} mais n'expose AUCUNE metrique node_."
exit 2
fi
# LES COLLECTEURS EN ECHEC, moins ceux qu'on a declares sans objet.
en_echec=$(printf '%s\n' "${corps}" \
| sed -n 's/^node_scrape_collector_success{collector="\([a-z_]*\)"} 0$/\1/p' | sort)
inattendus=""
for c in ${en_echec}; do
case " ${SANS_OBJET} " in
*" ${c} "*) : ;;
*) inattendus="${inattendus}${inattendus:+, }${c}" ;;
esac
done
perf="lignes=${lignes} collecteurs_en_echec=$(printf '%s\n' "${en_echec}" | grep -c .)"
if [[ -n "${inattendus}" ]]; then
echo "Metriques servies (${lignes} lignes) mais collecteur(s) ATTENDU(S) en echec : ${inattendus} | ${perf}"
exit 1
fi
echo "Metriques servies : ${lignes} lignes node_, aucun collecteur attendu en echec. | ${perf}"
exit 0

View file

@ -1,12 +1,30 @@
---
# UNE PORTE AU MOINS. Sans SSO et sans formulaire local, Grafana demarre, se porte
# bien, et personne n'entre. Les deux reglages sont independants et se sont deja
# retrouves a `false` ensemble : la combinaison se refuse ici, pas apres le
# deploiement, quand plus aucun chemin ne mene a la console.
- name: Exiger au moins un chemin de connexion
ansible.builtin.assert:
that:
- (serveur_grafana_oidc_actif | bool) or (serveur_grafana_connexion_locale | bool)
fail_msg: >-
Ni SSO (`serveur_grafana_oidc_actif`) ni formulaire local
(`serveur_grafana_connexion_locale`) : Grafana n'aurait aucune porte d'entree.
# L'URL PUBLIQUE de l'IdP se derive de l'exposition declaree au plan, elle ne se
# fabrique pas : `keycloak.<domaine>` n'est publie nulle part (voir resoudre_idp).
#
# ON NE RESOUT QUE CE QU'ON UTILISE. Le role reclamait l'IdP AVANT de regarder s'il
# en voulait un : au site, qui n'a ni Keycloak ni `domaines.yml` (D-87 : l'identite
# ne monte pas), le deploiement echouait sur un registre absent — pour derive une URL
# qu'aucun gabarit n'allait ecrire. L'interrupteur existait, il n'etait pas honore.
- name: Resoudre le fournisseur d'identite (role partage)
ansible.builtin.include_role:
name: resoudre_idp
vars:
resoudre_idp_realm: "{{ serveur_grafana_oidc_realm }}"
when: serveur_grafana_oidc_actif | bool
- name: Exiger le mot de passe admin Grafana (Vault)
ansible.builtin.assert:
that:

View file

@ -11,11 +11,15 @@ Environment=GF_SERVER_DOMAIN={{ serveur_grafana_hostname }}
Environment=GF_SERVER_ROOT_URL={{ serveur_grafana_root_url }}
Environment=GF_SECURITY_ADMIN_PASSWORD={{ serveur_grafana_admin_password }}
Environment=GF_USERS_ALLOW_SIGN_UP=false
{% if serveur_grafana_oidc_actif %}
{# Masque le formulaire local. `grafana-cli admin reset-admin-password`
reste utilisable sur l'hote : le compte n'est pas supprime, il n'est
plus offert au reseau. #}
{# Le formulaire local est offert, ou il ne l'est pas — et le gabarit le DIT dans
les deux cas. Cette ligne vivait sous le `if` du SSO : SSO eteint, elle
disparaissait, et c'est le defaut de Grafana qui decidait en silence. Un
reglage d'authentification qu'aucun fichier n'ecrit est un reglage que
personne ne peut relire.
Masquer le formulaire ne supprime pas le compte : `grafana-cli admin
reset-admin-password` reste utilisable sur l'hote. #}
Environment=GF_AUTH_DISABLE_LOGIN_FORM={{ 'false' if serveur_grafana_connexion_locale | bool else 'true' }}
{% if serveur_grafana_oidc_actif %}
Environment=GF_AUTH_GENERIC_OAUTH_ENABLED=true
Environment=GF_AUTH_GENERIC_OAUTH_NAME={{ serveur_grafana_oidc_nom }}
Environment=GF_AUTH_GENERIC_OAUTH_CLIENT_ID={{ serveur_grafana_oidc_client_id }}

View file

@ -327,7 +327,47 @@ def _instance_active() -> tuple[str, dict] | tuple[None, None]:
# pour ne pas reecrire trente appels.
_lire_plan_site = underlay_mod.lire_plan_site
_machines_du_plan_site = underlay_mod.machines_site
_services_du_plan_site = underlay_mod.services_site
def _services_du_plan_site() -> dict[str, list[str]]:
"""{machine du site: [groupes qu'elle porte]} — tels que l'INVENTAIRE les donne.
LA SOURCE EST L'INVENTAIRE, PAS `applications.yml` SEUL (2026-09-10).
`underlay.services_site()` lit le registre des applications : il voit `serveur_loki`,
`serveur_icinga`, `serveur_forgejo` — ce qu'une machine du site heberge COMME SERVICE.
Il ne voit pas les INTEGRATIONS UNIVERSELLES, que `site_inventaire` ajoute a toutes les
machines sans qu'aucune application ne les declare : `client_metrique`, `client_journal`,
`client_pki`, `client_sante`.
Ces groupes-la portent pourtant des flux, et des flux qui ouvrent des PORTS D'ECOUTE :
`client_metrique` declare `ingress 9100`. Invisible au devis, la regle d'entree n'etait
jamais emise — et comme `serveur_prometheus` declare l'`egress` en face, sa regle de
sortie tombait sur `!SETOPS_INTERNES`. Le devis autorisait donc Prometheus a scruter le
monde entier sur 9100 SAUF les six machines qu'il devait scruter.
Meme forme que le defaut du 2026-09-02 sur `serveur_icinga` : les deux declarations
etaient justes, c'est leur RENCONTRE au site qui manquait.
Ne retient que les groupes qui sont de vrais roles — un groupe de commodite
(`site`, la flotte, le socle) n'a pas de flux et n'a pas a produire d'alias.
"""
try:
import site_inventaire # noqa: PLC0415 — le site peut ne pas etre monte
inv = site_inventaire.inventaire()
except Exception:
return underlay_mod.services_site()
hotes = set((inv.get("_meta") or {}).get("hostvars") or {})
if not hotes:
return underlay_mod.services_site()
out: dict[str, list[str]] = {h: [] for h in hotes}
for groupe, corps in inv.items():
if groupe == "_meta" or not isinstance(corps, dict):
continue
if not (RACINE / "roles" / str(groupe)).is_dir():
continue
for h in (corps.get("hosts") or []):
if h in out:
out[h].append(str(groupe))
return {h: sorted(set(g)) for h, g in out.items()}
def _ports_du_plan_site() -> dict[str, str]:
@ -847,22 +887,43 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
file=sys.stderr)
continue
_p = [_reel]
# UNE SORTIE VERS UN ROLE DU SITE VISE CE ROLE, PAS « L'EXTERIEUR ».
#
# Symetrique du correctif du 2026-09-02, qui n'avait traite que l'entree.
# `!SETOPS_INTERNES` est la forme de « vers Internet » : c'est le bon
# rendu quand le pair est lointain (un depot Debian, un serveur NTP). Quand
# le pair NOMME UN ROLE DU SITE, cette destination dit exactement l'inverse
# de ce que le flux declare — elle exclut la seule machine visee.
#
# Mesure du 2026-09-10 : `client_journal` sortait vers `!SETOPS_INTERNES`
# sur 3100. Le Loki du site etant interne, la regle de sortie ne le couvrait
# pas ; seule la regle d'ENTREE, emise en face, faisait passer les journaux.
# Le devis autorisait donc a expedier les journaux du site a n'importe quel
# Loki du monde, et a nul autre endroit qu'a celui-la.
if _entrant:
_dests = [_a]
elif "serveur_ops_tenant" in _pairs_fl:
_dests = ["SETOPS_RUNNERS_TENANTS"]
elif "fabric" in _pairs_fl:
_dests = ["SETOPS_FABRIC"]
else:
_internes = sorted(set(_pairs_fl) & set(_roles_site))
_dests = ([nom_alias(_x, "SITE") for _x in _internes]
if _internes else ["!SETOPS_INTERNES"])
for _src, _itf in _portees:
regles.append({
"sens": "in" if _entrant else "out",
"interface": _itf,
"protocole": _fl.get("protocole", "tcp"),
"source": _src if _entrant else _a,
"destination": _a if _entrant else (
"SETOPS_RUNNERS_TENANTS" if "serveur_ops_tenant" in _pairs_fl
else "SETOPS_FABRIC" if "fabric" in _pairs_fl
else "!SETOPS_INTERNES"),
"ports": _p,
"chiffrement": _fl.get("chiffrement"),
"role": _role,
"tenant": "SITE",
"raison": _fl.get("raison", ""),
})
for _dst in _dests:
regles.append({
"sens": "in" if _entrant else "out",
"interface": _itf,
"protocole": _fl.get("protocole", "tcp"),
"source": _src if _entrant else _a,
"destination": _dst,
"ports": _p,
"chiffrement": _fl.get("chiffrement"),
"role": _role,
"tenant": "SITE",
"raison": _fl.get("raison", ""),
})
transit = transit_underlay()
saut = prochain_saut(transit)
@ -968,6 +1029,48 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"raison": str(s.get("motif") or "bruit de journal, deja refuse par defaut"),
})
# DEUX DECLARATIONS JUSTES NE FONT QU'UNE REGLE (2026-09-10).
#
# Un flux interne au site est declare DES DEUX COTES : `client_journal` sort vers
# Loki, `serveur_loki` accepte les journaux. C'est voulu — chaque role dit ce qui le
# concerne, sans savoir qui est en face. Depuis que les deux resolvent la meme paire
# d'alias, ils produisent le meme quintuplet, et la frontiere se retrouvait avec deux
# regles identiques : le boitier les applique toutes les deux, l'exploitant se
# demande laquelle compte, et la suivante qu'on retire ne change rien.
#
# LE `sens` N'EST PAS DANS LA CLE, et c'est le coeur de l'affaire : `appliquer_opnsense`
# pose TOUTES les regles en `direction: in` (D-61 — le devis raisonne en ARRIVEE sur
# l'interface). Deux regles qui ne different que par leur `sens` sont donc, sur le
# boitier, le meme filtre pose deux fois. Seul leur marqueur differait.
#
# On dedoublonne sur ce que la frontiere applique VRAIMENT : interface, protocole,
# source, destination, ports, sequence. Pas sur la raison, qui differe selon le role
# qui l'a dite — la premiere rencontree la garde, et c'est la seule chose qu'on perd.
# C'EST LA FORME `ingress` QUI SURVIT, et ce n'est pas indifferent : le marqueur pose
# sur le boitier contient le `sens`. Laisser gagner l'un ou l'autre au hasard de
# l'ordre alphabetique des roles ferait RETIRER puis RECREER des regles deja justes —
# `serveur_forgejo -> serveur_postgresql` disparaissait pour renaitre a l'identique
# sous un autre marqueur. Une reconciliation qui remue ce qui va bien devient une
# reconciliation qu'on n'ose plus lancer.
def _cle_regle(r: dict) -> tuple:
return (r.get("interface"), r.get("protocole"),
r.get("source"), r.get("destination"),
tuple(r.get("ports") or ()), r.get("sequence"),
r.get("action"), bool(r.get("journaliser")))
_entrantes = {_cle_regle(_r) for _r in regles if _r.get("sens") == "in"}
_vues: set[tuple] = set()
_uniques = []
for _r in regles:
_cle = _cle_regle(_r)
if _cle in _vues:
continue
if _cle in _entrantes and _r.get("sens") != "in":
continue
_vues.add(_cle)
_uniques.append(_r)
regles = _uniques
# Adresse du noeud de sortie primaire sur le lien de frontiere, calculee une fois :
# elle sert au prochain saut des routes ET au commentaire qui l'explique.
_ip_sortie = adresse_noeud_de_sortie(underlay_mod.charger(), transit)

View file

@ -412,6 +412,19 @@ def inventaire() -> dict:
# n'a ni l'un ni l'autre, et n'heberge aucune boite — il lui faut seulement
# de quoi faire SORTIR une alerte, en son propre nom.
"serveur_postfix_mode": "relais",
# LE GRAFANA DU SITE N'A PAS DE SSO — pas par oubli, par D-87.
#
# Le SSO d'un tenant supposerait que le site emprunte l'identite d'un
# ecosysteme qu'il doit pouvoir survivre ; un Keycloak DU site ferait
# remonter l'identite dans le site, exactement ce que D-87 refuse. Reste
# le compte local, que le site atteint par `sudo` sur l'hote — et il faut
# alors REOUVRIR le formulaire, que le mode SSO ferme.
#
# Les deux lignes vont ensemble : eteindre l'une sans allumer l'autre
# laisse un Grafana sans aucune porte. Le role refuse desormais cette
# combinaison, plutot que de la deployer.
"serveur_grafana_oidc_actif": False,
"serveur_grafana_connexion_locale": True,
# Le depot du site, pour les machines du site qui y deposent. Vide s'il n'y
# en a pas — `client_backup` refuse alors, plutot que de viser un nom qui
# ne repond pas : mieux vaut un deploiement qui s'arrete qu'une sauvegarde