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 # 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) ## 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 ? »* 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. | | 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). | | 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 | | 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. | | 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). | | 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 | | 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). | | 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. | | 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). | | 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 | | 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. | | 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 | | 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 | | 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). | | 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 | | 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 ## 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. # qu'aucun controle ne peut donc voir la collision.
client_journal_http_port: 12346 client_journal_http_port: 12346
client_journal_http_adresse: "127.0.0.1" 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 }}" name: "{{ client_journal_service }}"
enabled: true enabled: true
state: started 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_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_cert: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.crt"
client_metrique_tls_source_cle: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.key" 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 }}" name: "{{ client_metrique_service }}"
enabled: true enabled: true
state: started 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 # 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). # 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) - name: Resoudre le fournisseur d'identite (role partage)
ansible.builtin.include_role: ansible.builtin.include_role:
name: resoudre_idp name: resoudre_idp
vars: vars:
resoudre_idp_realm: "{{ serveur_grafana_oidc_realm }}" resoudre_idp_realm: "{{ serveur_grafana_oidc_realm }}"
when: serveur_grafana_oidc_actif | bool
- name: Exiger le mot de passe admin Grafana (Vault) - name: Exiger le mot de passe admin Grafana (Vault)
ansible.builtin.assert: ansible.builtin.assert:
that: 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_SERVER_ROOT_URL={{ serveur_grafana_root_url }}
Environment=GF_SECURITY_ADMIN_PASSWORD={{ serveur_grafana_admin_password }} Environment=GF_SECURITY_ADMIN_PASSWORD={{ serveur_grafana_admin_password }}
Environment=GF_USERS_ALLOW_SIGN_UP=false Environment=GF_USERS_ALLOW_SIGN_UP=false
{% if serveur_grafana_oidc_actif %} {# Le formulaire local est offert, ou il ne l'est pas — et le gabarit le DIT dans
{# Masque le formulaire local. `grafana-cli admin reset-admin-password` les deux cas. Cette ligne vivait sous le `if` du SSO : SSO eteint, elle
reste utilisable sur l'hote : le compte n'est pas supprime, il n'est disparaissait, et c'est le defaut de Grafana qui decidait en silence. Un
plus offert au reseau. #} 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' }} 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_ENABLED=true
Environment=GF_AUTH_GENERIC_OAUTH_NAME={{ serveur_grafana_oidc_nom }} Environment=GF_AUTH_GENERIC_OAUTH_NAME={{ serveur_grafana_oidc_nom }}
Environment=GF_AUTH_GENERIC_OAUTH_CLIENT_ID={{ serveur_grafana_oidc_client_id }} 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. # pour ne pas reecrire trente appels.
_lire_plan_site = underlay_mod.lire_plan_site _lire_plan_site = underlay_mod.lire_plan_site
_machines_du_plan_site = underlay_mod.machines_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]: def _ports_du_plan_site() -> dict[str, str]:
@ -847,22 +887,43 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
file=sys.stderr) file=sys.stderr)
continue continue
_p = [_reel] _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: for _src, _itf in _portees:
regles.append({ for _dst in _dests:
"sens": "in" if _entrant else "out", regles.append({
"interface": _itf, "sens": "in" if _entrant else "out",
"protocole": _fl.get("protocole", "tcp"), "interface": _itf,
"source": _src if _entrant else _a, "protocole": _fl.get("protocole", "tcp"),
"destination": _a if _entrant else ( "source": _src if _entrant else _a,
"SETOPS_RUNNERS_TENANTS" if "serveur_ops_tenant" in _pairs_fl "destination": _dst,
else "SETOPS_FABRIC" if "fabric" in _pairs_fl "ports": _p,
else "!SETOPS_INTERNES"), "chiffrement": _fl.get("chiffrement"),
"ports": _p, "role": _role,
"chiffrement": _fl.get("chiffrement"), "tenant": "SITE",
"role": _role, "raison": _fl.get("raison", ""),
"tenant": "SITE", })
"raison": _fl.get("raison", ""),
})
transit = transit_underlay() transit = transit_underlay()
saut = prochain_saut(transit) 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"), "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 : # 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. # elle sert au prochain saut des routes ET au commentaire qui l'explique.
_ip_sortie = adresse_noeud_de_sortie(underlay_mod.charger(), transit) _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 # 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. # de quoi faire SORTIR une alerte, en son propre nom.
"serveur_postfix_mode": "relais", "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 # 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 # 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 # ne repond pas : mieux vaut un deploiement qui s'arrete qu'une sauvegarde