le site surveille enfin sa fabric

La supervision du site voyait ses sept VM et rien d autre. Au depart, depuis
site-mon-01, les trois hyperviseurs, les neuf pattes de la frontiere et sa
PROPRE passerelle par defaut rendaient tous 100 pourcent de perte au ping.

  16 hotes UP sur 16       dont 9 pattes de frontiere en controle ACTIF
  11 cibles Prometheus     dont 3 hyperviseurs, job fabric separe

LE PARTAGE. Les hyperviseurs portent node_exporter - D-48 l autorise - et
exposent 1005 unites systemd avec leur etat, soit ce que la sonde sante
mesurait, plus la charge et le disque. Ils n entrent PAS dans le socle :
leur appliquer serveur_durci reecrirait le pare-feu, le SSH et les sysctl
de la machine qui tient tout le reste. La frontiere, elle, n accueille
aucun agent : controle actif, une entree par PATTE, parce qu une interface
eteinte coupe une zone pendant que les autres vont bien.

ON TIRE, ON NE POUSSE PAS. J avais propose du passif et il avait ete
valide ; la mesure a dit non. Un hyperviseur envoie vers un routeur qui ne
connait pas les reseaux du site, et sa route par defaut est GELEE (D-57).
Le porteur de sante y expirait en 20 s.

LE VRAI DEFAUT ETAIT UNE LISTE QUI N A PAS SUIVI. Le mecanisme de routage
existait deja sur vmbr0, avec un commentaire du 2026-08-26 tenant
exactement le raisonnement qu on venait de refaire. Sa liste s arretait a
10.0.34.0/24 quand le site en declare six : les zones sauvegarde et
supervision sont nees, les routes n ont pas suivi. Le symptome ne
ressemblait pas a une route manquante - il ressemblait a un pare-feu, puis
a un probleme de reseau chez l exploitant.

make routes-fabric-etat compare desormais TROIS choses : zones declarees,
routes declarees dans /etc/network/interfaces, routes vivantes dans le
noyau. Le cas le plus traitre est vivante mais non declaree : tout
fonctionne, la supervision est verte, et la panne attend la prochaine
maintenance. Eprouvee dans les deux sens.

Deux defauts trouves en construisant. bifrost-2 est un nom RESERVE, pas un
boitier - l underlay le disait en prose, illisible par le moteur ; il porte
desormais etat: reserve. Et un service passif n existe que pour un hote qui
peut POUSSER : l appartenance a un groupe sert deux choses qui ne
coincident pas toujours, a qui l on deploie et de qui l on attend un
rapport.

Les commutateurs restent dehors, choix de l exploitant, coherent avec D-48.

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 18:50:43 -04:00
parent 009325ee51
commit 5abf20102d
13 changed files with 475 additions and 10 deletions

View file

@ -1,5 +1,80 @@
# CHANGELOG — Set-OPS
## 2026-09-10 (14) — Le site surveille enfin sa fabric
La supervision du site voyait ses sept VM et rien d'autre. Mesure de depart, depuis
`site-mon-01` : les trois hyperviseurs, les neuf pattes de la frontiere et **sa propre
passerelle par defaut** rendaient tous 100 % de perte au ping.
16 hotes UP / 16 dont 9 pattes de frontiere en controle ACTIF
11 cibles Prometheus dont 3 hyperviseurs, job `fabric` separe
### Le partage : ce qui peut porter un agent, et ce qui ne le peut pas
**Les hyperviseurs** portent `node_exporter` — D-48 l'autorise. Chacun expose 6 600 a
7 900 lignes de metriques, dont **1 005 unites systemd avec leur etat** : soit exactement
ce que la sonde `sante` mesurait, plus la charge, le disque et l'horloge.
**Ils n'entrent PAS dans le socle**, et c'est le point delicat. Un hyperviseur Proxmox
n'est pas une VM de la flotte : lui appliquer `serveur_durci` reecrirait son pare-feu, son
SSH et ses sysctl — sur la machine qui tient tout le reste. Ils vivent dans leur propre
groupe, hors de `GROUPE_SOCLE` et de `hotes_actifs`.
**La frontiere** n'accueille aucun agent : controle ACTIF, une entree par PATTE. La
frontiere est un seul boitier, mais chaque zone depend de SON interface — une interface
eteinte coupe une zone pendant que les autres vont bien, et on en a deja vu (les routes
creees `disabled` le 2026-09-02). Un ping vers une seule adresse dirait « la frontiere est
debout » et manquerait ce cas.
### On TIRE, on ne pousse pas — et c'est la route gelee qui le decide
J'avais propose du passif, et il avait ete valide. **La mesure a dit non** :
asgard -> 10.0.36.11 via 192.168.11.254 (le routeur du site)
route par defaut via 192.168.11.254 GELEE (D-57)
Un hyperviseur envoie vers un routeur qui ne connait pas les reseaux du site : le porteur
de sante y expirait en 20 s. Le remede evident — router 10.0.0.0/8 par la frontiere —
touche la route par defaut d'une machine EN SERVICE, ce que D-57 interdit. On tire donc,
dans le sens que la frontiere route deja.
### Le vrai defaut : une liste qui n'a pas suivi
Le mecanisme de routage EXISTAIT sur `vmbr0`, avec un commentaire du 2026-08-26 tenant
exactement le raisonnement qu'on venait de refaire. Sa liste s'arretait a `10.0.34.0/24` :
le site declare 6 zones (31 -> 36)
les hyperviseurs 4 routes (31 -> 34)
Les zones `sauvegarde` (35) et `supervision` (36) sont nees, **les routes n'ont pas suivi**.
Le symptome ne ressemblait pas a une route manquante : il ressemblait a un pare-feu, puis a
un probleme de reseau chez l'exploitant.
**`make routes-fabric-etat`** compare desormais TROIS choses : les zones declarees, les
routes declarees dans `/etc/network/interfaces`, et les routes vivantes dans le noyau. Le
cas le plus traitre est le troisieme — *vivante mais non declaree* : tout fonctionne, la
supervision est verte, et la panne attend la prochaine maintenance. Une garde qui ne
comparerait que le vivant ne le verrait jamais. Eprouvee dans les deux sens.
### Deux defauts trouves en construisant
**`bifrost-2` est un nom reserve, pas un boitier.** L'underlay le disait — « pour que les
noms soient reserves » — mais en PROSE, illisible par le moteur. Le surveiller aurait donne
deux CRITICAL permanents pour un equipement absent. Il porte desormais `etat: reserve` ; le
jour ou le second boitier arrive, retirer la cle le fait entrer dans la supervision.
**Un service passif n'existe que pour un hote qui peut POUSSER.** Les hyperviseurs sont
dans `client_metrique` — c'est par ce groupe qu'on leur deploie node_exporter — et la
derivation en tirait `asgard!metriques`. Icinga refusait la configuration ENTIERE. Meme en
definissant l'hote, le service serait reste UNKNOWN pour toujours. *L'appartenance a un
groupe sert deux choses qui ne coincident pas toujours : a qui l'on deploie, et de qui l'on
attend un rapport.* La seconde se lit sur `client_sante`, et nulle part ailleurs.
### Les commutateurs restent dehors
Choix de l'exploitant, coherent avec D-48 : ils sont hors flotte, et rien ne les rend
interrogeables sans leur ouvrir un acces qu'on ne veut pas leur ouvrir.
## 2026-09-10 (13) — D-88 : le noeud du gabarit, point unique de la REPRODUCTION
Question de l'exploitant : *« le modele vit sur vishnu, les clones sont sur asgard — qu'

View file

@ -1071,6 +1071,14 @@ site-appliquer: ## Applique un role aux machines du site — GROUPE=<serveur_ops
#
# A LA DEMANDE, pas dans `make prouver` : ce controle exige le cluster, que le harnais ne
# suppose pas joignable. Meme nature que `genome-etat` et `underlay-plan`.
.PHONY: routes-fabric-etat
routes-fabric-etat: ansible-runtime ## Les hyperviseurs routent-ils toutes les zones du SITE ? — ne corrige rien
@# UNE LISTE QUI DOIT SUIVRE UNE AUTRE LISTE PREND DU RETARD. Les routes specifiques
@# de `vmbr0` s'arretaient a 10.0.34.0/24 quand le site en declarait six : les zones
@# `sauvegarde` et `supervision` etaient nees sans que les routes suivent. Le symptome
@# ne ressemblait pas a une route manquante — il ressemblait a un pare-feu.
python3 scripts/routes_fabric_etat.py
.PHONY: gabarit-etat
gabarit-etat: ## Le gabarit porte-t-il ce que le SITE declare ? — ne corrige rien, regarde
python3 scripts/gabarit_etat.py

View file

@ -21,7 +21,7 @@
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 41 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 39 rôles, 103 flux, schéma + matrice OK. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 39 rôles, 105 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
@ -43,7 +43,7 @@
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 34 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 32 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 21, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 58 scripts expliques et atteignables, 115 cibles make documentees, 67 roles avec README. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 59 scripts expliques et atteignables, 116 cibles make documentees, 67 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 38 exigence(s) de role, toutes satisfaites (132 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (36 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 44 document(s) declarent leur lecteur (36 genere(s) exempte(s)). |
@ -53,16 +53,16 @@
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 40 role(s) serveur/client tous nommes, 41 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. |
| 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 : 55 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, 151 regle(s) du site. |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 153 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 213 regles `pass`), tous non consignes et tous motives. |
| 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 (123 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 215 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 |

View file

@ -51,6 +51,7 @@
| `serveur_icinga` | ingress | 5665 | tcp | serveur_debian | tls-requis | Rapport passif de sante de chaque noeud (unites systemd en echec). |
| `serveur_icinga` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Base relationnelle du moteur Icinga (verify-full). |
| `serveur_icinga` | egress | echo-request | icmp | serveur_debian | n-a | La supervision verifie que ses hotes repondent (hostalive) : sans ce flux, elle les tient tous pour morts et supprime leurs notifications. |
| `serveur_icinga` | egress | echo-request | icmp | fabric | n-a | La supervision verifie que la frontiere sert encore chaque zone. Aucun agent ne peut vivre sur un pare-feu : le controle ACTIF est le seul chemin, et il n'existait pas. |
| `serveur_icingaweb2` | ingress | 8080 | tcp | edge | clair | Interface web servie via l'edge (TLS terminé à l'edge ; SSO possible via oauth2-proxy). |
| `serveur_icingaweb2` | egress | 636 | tcp | serveur_openldap | tls-requis | Authentification des utilisateurs sur l'annuaire (LDAPS). |
| `serveur_icingaweb2` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Lecture d'IcingaDB (base relationnelle, verify-full). |
@ -96,6 +97,7 @@
| `serveur_powerdns` | egress | 53 | tcp | externe | clair | Repli TCP de la résolution sortante du serveur autoritatif (réponses dépassant la taille UDP). |
| `serveur_prometheus` | ingress | 9090 | tcp | localhost | clair | Console Prometheus consommée en local par Grafana co-localisé (pas d'exposition inter-nœud). |
| `serveur_prometheus` | egress | 9100 | tcp | client_metrique | tls | Scrape des node_exporter (HTTPS via cert step-ca) sur chaque nœud instrumenté. |
| `serveur_prometheus` | egress | 9100 | tcp | fabric | clair | Scrape des hyperviseurs : la machine qui PORTE les VM etait invisible de la supervision qui les surveille. Elle ne peut pas pousser (route par defaut gelee, D-57), donc on la tire. |
| `serveur_redis` | ingress | 6379 | tcp | localhost | clair | Cache/verrous consommés uniquement par l'application co-localisée (ex. Nextcloud). Aucune exposition inter-nœud. |
| `serveur_resolveur` | ingress | 53 | udp | flotte | clair | Toute la flotte du tenant résout ici — et nulle part ailleurs. |
| `serveur_resolveur` | ingress | 53 | tcp | flotte | clair | Réponses longues et bascule TCP, obligatoires en DNS. |
@ -112,8 +114,8 @@
## Synthèse chiffrement
- **clair** : 37 flux
- **n-a** : 5 flux
- **clair** : 38 flux
- **n-a** : 6 flux
- **ssh** : 8 flux
- **starttls** : 6 flux
- **tls** : 10 flux

View file

@ -4,6 +4,21 @@
# echoue chaque heure apprend a ignorer le rouge.
client_sante_icinga_hote: "{{ (groups['serveur_icinga'] | default([]) | first) | default('') }}"
# L'ADRESSE DU TEMOIN, PAR SON NOM — SAUF QUAND LE NOM N'EXISTE PAS (2026-09-10).
#
# Le porteur visait `<hote>.<domaine>`, ce qui suppose le plancher `/etc/hosts` pose par
# le socle. Les HYPERVISEURS ne l'ont pas, et ne doivent pas l'avoir : Proxmox se sert de
# `/etc/hosts` pour l'identite de noeud du cluster, et la reecrire est un risque qu'on ne
# prend pas pour une commodite de supervision.
#
# Mesure : `curl: (6) Could not resolve host: site-mon-01.genese.internal` sur les trois
# hyperviseurs, alors que le porteur etait correctement installe et tournait.
#
# On DERIVE donc l'URL, ce qui laisse une machine sans resolveur la surcharger par une
# ADRESSE. Une seule chose change, et elle se declare la ou le nom ne marche pas.
client_sante_icinga_url: >-
https://{{ client_sante_icinga_hote }}.{{ domaine_interne }}:5665
# LE MEME COMPTE D'API QUE LE RAPPORT DE SAUVEGARDE, et c'est un choix.
#
# Un second compte serait plus pur — un secret par usage. Il exigerait une CLEF DE VOUTE

View file

@ -24,7 +24,7 @@
# ce qui n'a alerte personne.
set -uo pipefail
API="https://{{ client_sante_icinga_hote }}.{{ domaine_interne }}:5665"
API="{{ client_sante_icinga_url }}"
TTL={{ client_sante_ttl_icinga }}
MOI="{{ inventory_hostname }}"
MOTDEPASSE="$(cat /etc/setops/icinga-api.pass)"

View file

@ -154,3 +154,7 @@ serveur_icinga_sonde_age_crit: 300
# DEGRADE, JAMAIS DEVINE : pas de cache d'amorcage declare, pas de reecriture.
serveur_icinga_depot_schema: >-
{{ 'http' if (artefacts_amorcage | default('') | string | length > 0) else 'https' }}
# LA FABRIC SURVEILLEE, DERIVEE DE L'UNDERLAY PAR L'INVENTAIRE DU SITE.
# Forme : [{ nom, adresse, role }]. Vide chez un tenant — il n'a pas de fabric a lui.
serveur_icinga_fabric: []

View file

@ -96,3 +96,27 @@ flux:
pair: serveur_postgresql
chiffrement: tls-requis
raison: "Base relationnelle du moteur Icinga (verify-full)."
# LA PASSERELLE FAIT PARTIE DE CE QUI PEUT TOMBER (2026-09-10).
#
# La supervision du site voyait ses sept VM et rien d'autre. Mesure : depuis
# `site-mon-01`, AUCUNE patte de la frontiere ne repondait — pas meme `10.0.36.1`, sa
# PROPRE passerelle par defaut. OPNsense ne repond pas a l'ICMP sur ses interfaces
# internes tant qu'une regle ne l'autorise pas.
#
# UNE PATTE PAR ZONE, ET C'EST LE POINT. La frontiere est un seul boitier, mais chaque
# zone du site depend de SON interface a elle. Une interface eteinte — on en a deja vu,
# les routes creees `disabled` le 2026-09-02 — coupe une zone pendant que les autres
# vont bien. Un ping vers une seule patte dirait « la frontiere est debout » et
# manquerait exactement ce cas-la.
#
# `fabric` couvre les hyperviseurs ET la frontiere : c'est le materiel de l'hebergeur,
# celui qu'aucun agent ne peut habiter.
- sens: egress
port: echo-request
protocole: icmp
pair: fabric
chiffrement: n-a
raison: >-
La supervision verifie que la frontiere sert encore chaque zone. Aucun agent ne peut
vivre sur un pare-feu : le controle ACTIF est le seul chemin, et il n'existait pas.

View file

@ -19,3 +19,26 @@ object Host "{{ noeud }}" {
vars.role = "{{ 'depot de sauvegarde' if noeud == serveur_icinga_hote_sauvegarde else 'noeud de l ecosysteme' }}"
}
{% endfor %}
/*
* LA FABRIC — CE QUI PORTE TOUT, ET QU'AUCUN AGENT NE PEUT HABITER (2026-09-10).
*
* Un pare-feu n'accueille pas de porteur de sante : le controle ACTIF est le seul chemin
* possible, et il n'existait pas. La supervision du site voyait ses sept VM et rien
* d'autre — pas meme la passerelle par defaut de la machine qui la fait tourner.
*
* UNE ENTREE PAR PATTE, ET C'EST LE POINT. La frontiere est un seul boitier, mais chaque
* zone depend de SON interface. Une interface eteinte — on en a deja vu, les routes
* creees `disabled` le 2026-09-02 — coupe une zone pendant que les autres vont bien.
* Surveiller une seule adresse dirait « la frontiere est debout » et manquerait ce cas.
*
* L'ADRESSE, JAMAIS UN NOM : ces equipements ne sont dans aucun annuaire, et le plancher
* `/etc/hosts` ne les porte pas. Un nom fabrique ici ne resoudrait nulle part.
*/
{% for f in serveur_icinga_fabric | default([]) %}
object Host "{{ f.nom }}" {
check_command = "hostalive"
address = "{{ f.adresse }}"
vars.role = "{{ f.role }}"
}
{% endfor %}

View file

@ -15,8 +15,25 @@
* Les HOTES sont definis dans `setops-hotes.conf` : ici, rien que des services.
*/
{#
* UN SERVICE PASSIF N'EXISTE QUE POUR UN HOTE QUI PEUT POUSSER (2026-09-10).
*
* Les hyperviseurs ont rejoint `client_metrique` — c'est par ce groupe qu'on leur
* DEPLOIE node_exporter. La derivation en tirait donc `asgard!metriques`, et Icinga
* refusait la configuration ENTIERE :
*
* Attribute 'host_name': Object 'asgard' of type 'Host' does not exist.
*
* Meme en definissant l'hote, le service serait reste UNKNOWN pour toujours : un
* hyperviseur ne porte pas `client_sante`, parce que sa route par defaut est gelee et
* qu'il ne peut rien pousser (D-57). On le TIRE avec Prometheus.
*
* L'appartenance a un groupe sert donc DEUX choses qui ne coincident pas toujours : a
* qui l'on deploie, et de qui l'on attend un rapport. La seconde se lit sur le porteur
* lui-meme — `client_sante` — et nulle part ailleurs.
#}
{% for role, sondes in (serveur_icinga_sondes | default({})) | dictsort %}
{% for hote in (groups[role] | default([])) | sort %}
{% for hote in ((groups[role] | default([])) | intersect(groups['client_sante'] | default([]))) | sort %}
{% for sonde in sondes %}
object Service "{{ sonde.nom }}" {
host_name = "{{ hote }}"

View file

@ -13,3 +13,23 @@ flux:
pair: localhost
chiffrement: clair
raison: "Console Prometheus consommée en local par Grafana co-localisé (pas d'exposition inter-nœud)."
# LA FABRIC SE SURVEILLE EN LA TIRANT (2026-09-10).
#
# Les hyperviseurs portent `node_exporter` — D-48 l'autorise : « hors flotte » ne vaut
# que pour les commutateurs et la frontiere. Mais ils ne peuvent pas POUSSER : leur route
# par defaut vise le routeur du site, qui ne connait pas les reseaux du site, et cette
# route est GELEE (D-57). Mesure : le porteur de sante y expirait en 20 s.
#
# Prometheus tire donc, dans le sens que la frontiere route deja pour `serveur_ops_site`.
# 7887 lignes de metriques sur `asgard`, dont 1005 unites systemd avec leur etat — soit
# davantage que ce qu'un ping ou un porteur de sante auraient dit.
- sens: egress
port: 9100
protocole: tcp
pair: fabric
chiffrement: clair
raison: >-
Scrape des hyperviseurs : la machine qui PORTE les VM etait invisible de la
supervision qui les surveille. Elle ne peut pas pousser (route par defaut gelee,
D-57), donc on la tire.

View file

@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Les hyperviseurs routent-ils toutes les zones que le SITE declare ? Ne corrige rien.
POURQUOI CE CONTROLE EXISTE (2026-09-10).
Un hyperviseur atteint les zones du site par des routes SPECIFIQUES posees sur `vmbr0`,
et non par sa route par defaut — celle-ci est GELEE (D-57) et vise le routeur du site.
Le commentaire qui les accompagne, ecrit le 2026-08-26, dit pourquoi :
Aller et retour empruntent la MEME patte — pf cree son etat sur une seule
interface, et une reponse revenant par une autre serait jetee.
CE QUE CA A COUTE. Le site a gagne deux zones — `site-sauvegarde` (35) et
`site-supervision` (36) — et la liste des routes ne les a pas suivies. Elle s'arretait a
34. Consequence mesuree : la supervision du site ne pouvait PAS collecter les
hyperviseurs, parce que la reponse partait vers un routeur qui ne connait pas 10.0.36.0/24.
Le symptome ne ressemblait pas a une route manquante — il ressemblait a un pare-feu, puis
a un probleme de reseau chez l'exploitant.
Une liste qui doit suivre une autre liste finit toujours par prendre du retard. La seule
question est de savoir si quelqu'un s'en apercevra avant la panne.
CE QU'ON COMPARE, ET DANS LES DEUX SENS :
1. les zones DECLAREES par `underlay.reseaux` (la verite du site) ;
2. les routes DECLAREES dans `/etc/network/interfaces` (ce qui survit au redemarrage) ;
3. les routes VIVANTES dans la table du noyau (ce qui vaut maintenant).
Une route vivante mais non declaree disparait au prochain redemarrage — c'est le cas le
plus traitre, parce que tout fonctionne jusqu'a la maintenance suivante.
Ce n'est pas une preuve du harnais : elle exigerait le cluster, que `make prouver` ne
suppose pas joignable. C'est un controle a la demande, comme `make gabarit-etat`.
Usage :
python3 scripts/routes_fabric_etat.py # code de sortie 0 si conforme
"""
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import underlay as underlay_mod # noqa: E402
# La patte de la frontiere sur le plan d'administration : le prochain saut de ces routes.
# DERIVEE, jamais ecrite : c'est l'hote `frontiere` du reseau `grappe-controle`.
RESEAU_PROCHAIN_SAUT = "grappe-controle"
def _lire(hote: str, commande: str) -> str:
r = subprocess.run(
["ssh", "-o", "ConnectTimeout=8", "-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=no", f"ansible@{hote}", commande],
capture_output=True, text=True, timeout=60)
return r.stdout if r.returncode == 0 else ""
def main() -> int:
u = underlay_mod.charger()
if not u:
print("Aucun underlay monte : rien a comparer.")
return 0
hotes = underlay_mod.hotes(u)
# 1. Les zones du site, telles que la carte les declare.
zones = sorted({str(r["sous_reseau"]) for r in underlay_mod.reseaux(u)
if str(r.get("nom", "")).startswith("site-") and r.get("sous_reseau")})
if not zones:
print("Le site ne declare aucune zone : rien a router.")
return 0
saut = next((str(h["ip"]) for h in hotes
if h.get("role") == "frontiere"
and str(h.get("reseau")) == RESEAU_PROCHAIN_SAUT and h.get("ip")), "")
if not saut:
print(f"Aucune patte `frontiere` sur `{RESEAU_PROCHAIN_SAUT}` : "
f"le prochain saut ne se derive pas.")
return 1
# Les hyperviseurs, par leur adresse d'administration — la seule que le poste route.
noeuds = {}
for h in hotes:
if h.get("role") != "hyperviseur" or not h.get("ip"):
continue
if str(h.get("reseau")) != RESEAU_PROCHAIN_SAUT:
continue
noeuds.setdefault(str(h["nom"]), str(h["ip"]))
if not noeuds:
print("Aucun hyperviseur sur le plan d'administration : rien a interroger.")
return 0
print(f"Zones declarees par le site : {len(zones)} — prochain saut {saut}")
ecarts: list[str] = []
for nom, ip in sorted(noeuds.items()):
conf = _lire(ip, "cat /etc/network/interfaces")
table = _lire(ip, "ip route show")
if not conf and not table:
ecarts.append(f"{nom} ({ip}) : injoignable — le controle ne peut rien dire")
print(f" {nom:<9} INJOIGNABLE")
continue
declarees = set(re.findall(r"ip route replace (\d+\.\d+\.\d+\.\d+/\d+) via " + re.escape(saut), conf))
vivantes = set(re.findall(r"^(\d+\.\d+\.\d+\.\d+/\d+) via " + re.escape(saut), table, re.M))
manque_decl = [z for z in zones if z not in declarees]
manque_vive = [z for z in zones if z not in vivantes]
# Le cas le plus traitre : vivante mais non declaree — elle part au redemarrage.
ephemeres = sorted(vivantes - declarees)
etat = "=" if not (manque_decl or manque_vive or ephemeres) else "≠"
print(f" {nom:<9} declarees {len(declarees)}/{len(zones)} "
f"vivantes {len(vivantes)}/{len(zones)} {etat}")
for z in manque_decl:
ecarts.append(f"{nom} : {z} n'est pas declaree dans `/etc/network/interfaces` "
f"— elle ne survivra pas au redemarrage")
for z in manque_vive:
ecarts.append(f"{nom} : {z} absente de la table du noyau — les reponses vers "
f"cette zone partent par la route par defaut et se perdent")
for z in ephemeres:
ecarts.append(f"{nom} : {z} est VIVANTE mais non declaree — elle disparaitra "
f"au prochain redemarrage, et tout marchera jusque-la")
if ecarts:
print("\nECART — la fabric ne route pas ce que le site declare :")
for e in ecarts:
print(f" - {e}")
print(f"\n Remede, sur le noeud concerne :")
print(f" ip route replace <zone> via {saut} dev vmbr0 # tout de suite")
print(f" puis la meme ligne en `post-up` dans /etc/network/interfaces # pour apres")
print(" L'ordre importe peu ; faire les DEUX importe.")
return 1
print("\nConforme — chaque hyperviseur route toutes les zones du site, "
"declarees ET vivantes.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -311,6 +311,44 @@ def inventaire() -> dict:
if erreurs:
raise SystemExit("plan du site refuse :\n - " + "\n - ".join(erreurs))
# LES ADRESSES DE LA FABRIC, pour que Prometheus puisse la TIRER (2026-09-10).
#
# Le job principal de Prometheus vaut `client_metrique ∩ hotes_actifs`, et les
# hyperviseurs sont volontairement HORS de `hotes_actifs` : ce groupe sert aux
# operations de flotte (`deployer-tout`), et un hyperviseur n'est pas une VM de la
# flotte. L'intersection les excluait donc, alors meme qu'ils portent node_exporter.
#
# `serveur_prometheus_cibles_supplementaires` est le point d'extension prevu par le
# role. On y met la fabric sous son PROPRE job : elle n'est pas la flotte, et un
# tableau qui les melange ferait croire a un parc homogene.
_fabric_metriques = sorted(
f"{h['ip']}:9100" for h in U.hotes(u)
if str(h.get("role")) == "hyperviseur" and str(h.get("ip", "")).startswith("192.168.11."))
# CE QUE LA SUPERVISION IRA VOIR : chaque patte de la frontiere, nommee par la zone
# qu'elle sert. Les commutateurs restent dehors — D-48 les tient hors flotte, et rien
# ne les rend interrogeables sans leur ouvrir un acces qu'on ne veut pas leur ouvrir.
_fabric_surveillee = []
for _h in U.hotes(u):
if str(_h.get("role")) != "frontiere":
continue
# UN NOM RESERVE N'EST PAS UN EQUIPEMENT. `bifrost-2` est declare pour que le lien
# de transit soit documente et que le nom soit pris — le boitier n'existe pas.
# Le surveiller donnerait deux CRITICAL permanents, et une alarme toujours rouge
# est une alarme qu'on cesse de lire.
if str(_h.get("etat") or "actif") != "actif":
continue
_ip = str(_h.get("ip") or "")
_res = str(_h.get("reseau") or "")
if not _ip:
continue
_fabric_surveillee.append({
"nom": f"frontiere-{_ip.replace('.', '-')}",
"adresse": _ip,
"role": f"frontiere — patte {_res or _ip}",
})
_fabric_surveillee.sort(key=lambda x: x["adresse"])
hostvars: dict[str, dict] = {}
groupes: dict[str, list[str]] = {}
@ -440,6 +478,17 @@ def inventaire() -> dict:
# Le plan du site, pour les roles qui lisent des registres.
"setops_plan_dir": str(plan_dir() or ""),
"hosts_statiques_expositions": expositions,
# LA FABRIC SURVEILLEE PAR CONTROLE ACTIF (2026-09-10).
#
# Un pare-feu n'accueille aucun agent : Icinga doit aller VOIR. Une entree par
# PATTE de la frontiere, parce que chaque zone depend de son interface a elle —
# une interface eteinte coupe une zone pendant que les autres vont bien.
**({"serveur_icinga_fabric": _fabric_surveillee}
if (_fabric_surveillee and "serveur_icinga" in _groupes_de(nom)) else {}),
# La fabric, en job separe — voir `_fabric_metriques` plus haut.
**({"serveur_prometheus_cibles_supplementaires":
[{"job": "fabric", "cibles": _fabric_metriques}]}
if (_fabric_metriques and "serveur_prometheus" in _groupes_de(nom)) else {}),
**communes,
# En dernier : ce qu'une machine declare d'elle-meme prime sur ce que le
# plan declare pour toutes.
@ -461,6 +510,93 @@ def inventaire() -> dict:
if hote in hostvars and groupe:
groupes.setdefault(str(groupe), []).append(hote)
# --- LES HYPERVISEURS ENTRENT, MAIS SEULEMENT POUR ETRE VUS (2026-09-10) ----------
#
# L'Icinga du site ne connaissait que ses sept VM. Mesure : depuis `site-mon-01`, les
# trois hyperviseurs ET sa propre passerelle rendaient 100 % de perte au ping. La
# machine qui PORTE les VM etait invisible de la supervision qui les surveille.
#
# D-48 le permet, et le dit : « les hyperviseurs sont gerables par Ansible ; hors
# flotte ne vaut que pour les commutateurs et la frontiere ». Ils peuvent donc porter
# les integrations d'observation, ce qui vaut infiniment mieux qu'un ping : charge,
# disque, unites systemd en echec, certificat.
#
# ILS N'ENTRENT PAS DANS LE SOCLE, ET C'EST LE POINT DELICAT. Un hyperviseur Proxmox
# n'est pas une VM de la flotte : lui appliquer `serveur_durci` reecrirait son
# pare-feu, son SSH et ses sysctl — sur la machine qui tient tout le reste. Ils sont
# donc places dans leur PROPRE groupe, hors de `GROUPE_SOCLE` et hors de
# `GROUPE_FLOTTE` : `make site-appliquer GROUPE=serveur_durci` ne les touchera jamais,
# parce qu'ils n'y sont pas.
#
# Ce que ca coute a dire : un groupe qui recoit des roles doit etre nomme a chaque
# fois. C'est le prix d'un perimetre explicite, et il est moins cher qu'un
# durcissement applique par megarde a un hyperviseur.
# L'adresse du temoin, derivee comme tout le reste : l'hote qui porte `serveur_icinga`.
_ip_icinga = ""
for _n_app, _a_app in applications.items():
if str(_a_app.get("groupe")) == "serveur_icinga":
_s = serveurs.get(_a_app.get("hote")) or {}
_ip_icinga = str(_s.get("ip") or "")
break
_hyperviseurs = {}
for _h in U.hotes(u):
if str(_h.get("role")) != "hyperviseur":
continue
_nom, _ip = str(_h.get("nom") or ""), str(_h.get("ip") or "")
if not _nom or not _ip:
continue
# UNE SEULE ADRESSE PAR HYPERVISEUR : l'underlay en declare trois par machine
# (administration, transport, fabric). On retient celle du plan d'ADMINISTRATION,
# la seule que le poste et le runner routent — les autres ne sont pas joignables
# d'ou l'on parle.
if _nom in _hyperviseurs:
continue
if not _ip.startswith("192.168.11."):
continue
_hyperviseurs[_nom] = _ip
for _nom, _ip in sorted(_hyperviseurs.items()):
hostvars[_nom] = {
"ansible_host": _ip,
"ansible_user": UTILISATEUR_DEFAUT,
"setops_site": True,
"setops_plan_dir": str(plan_dir() or ""),
# PAS DE PARE-FEU DERIVE : aucun `nftables_baseline_ruleset_genere`. Un
# hyperviseur porte son propre filtrage, pose par Proxmox et par l'exploitant.
#
# LE TEMOIN SE JOINT PAR SON ADRESSE, PAS PAR SON NOM. Le porteur de sante vise
# `<hote>.<domaine>`, ce qui suppose le plancher `/etc/hosts` du socle — que ces
# machines n'ont pas, et ne doivent pas avoir : Proxmox s'en sert pour l'identite
# de noeud du cluster. Mesure : `curl: (6) Could not resolve host` sur les trois.
**({"client_sante_icinga_url": f"https://{_ip_icinga}:5665"}
if _ip_icinga else {}),
**communes,
}
groupes.setdefault("hyperviseurs", []).append(_nom)
# ON TIRE, ON NE POUSSE PAS — ET C'EST LA ROUTE GELEE QUI LE DECIDE (2026-09-10).
#
# Toute la supervision de Set-OPS est PASSIVE : chaque noeud mesure et pousse. Ici
# c'est impossible, et la mesure le dit sans ambiguite :
#
# asgard -> 10.0.36.11 via 192.168.11.254 (le routeur du site)
# route par defaut via 192.168.11.254 GELEE (D-57)
#
# Un hyperviseur envoie vers un routeur qui ne connait pas les reseaux du site.
# Le porteur de sante expirait donc en 20 s. Le remede evident — router 10.0.0.0/8
# par la frontiere — touche la route par defaut d'un hyperviseur EN SERVICE, ce que
# D-57 interdit : « risquer de perdre l'hyperviseur ET le chemin pour le reparer ».
#
# LE SENS INVERSE, LUI, EST OUVRABLE : la frontiere route deja `serveur_ops_site`
# vers `SETOPS_FABRIC`. Prometheus TIRE donc les metriques, et rien ne pousse.
#
# CE QU'ON NE PERD PAS EN ECHANGE. `node_exporter` expose 1005 unites systemd avec
# leur etat — exactement ce que la sonde `sante` mesurait, plus la charge, le
# disque et l'horloge. On perd le mecanisme du `ttl` (le SILENCE qui alerte) ;
# Prometheus le remplace par sa propre cible `down`, que la sonde `collecte`
# rapporte deja.
groupes.setdefault("client_metrique", []).append(_nom)
out: dict = {g: {"hosts": sorted(set(h))} for g, h in groupes.items()}
out["site"] = {"hosts": sorted(hostvars)}
out["_meta"] = {"hostvars": hostvars}