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:
parent
5880b22d5a
commit
f2c580235d
14 changed files with 492 additions and 23 deletions
99
CHANGELOG.md
99
CHANGELOG.md
|
|
@ -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 ? »*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
25
roles/client_journal/meta/supervision.yml
Normal file
25
roles/client_journal/meta/supervision.yml
Normal 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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
52
roles/client_journal/templates/sonde-journaux.sh.j2
Normal file
52
roles/client_journal/templates/sonde-journaux.sh.j2
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
26
roles/client_metrique/meta/supervision.yml
Normal file
26
roles/client_metrique/meta/supervision.yml
Normal 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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
47
roles/client_metrique/templates/sonde-metriques.sh.j2
Normal file
47
roles/client_metrique/templates/sonde-metriques.sh.j2
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue