icinga : un service passif qui n a jamais rien recu passe au rouge

Gabarit setops-rapport-attendu : actif, dummy critique, check_interval = le
ttl que le noeud envoie. Prouve a t+60 s sur site-mon-01, deploye sur les trois
Icinga ; la garde test_fraicheur_icinga lie seuils et ttl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-28 12:05:15 -04:00
parent 831c7d5cf4
commit 185b000c60
7 changed files with 146 additions and 26 deletions

View file

@ -1,5 +1,33 @@
# CHANGELOG — Set-OPS
## 2026-09-28 (3) — Un service qui n'a jamais rien reçu passe au rouge
**Le trou.** Tous les services passifs (sauvegardes, santé, sondes de rôle) étaient
déclarés `enable_active_checks = false`, avec une commande qui exécutait `/bin/true`. Le
`ttl` de chaque envoi couvrait le silence qui SUIT un rapport ; un service qui n'en a
jamais reçu restait « en attente », gris, pas rouge. C'est ce qui a caché dix nœuds de
Chezlepro sans sauvegarde pendant seize jours.
**Le correctif.** Un gabarit, `setops-rapport-attendu` (dans `setops-sauvegardes.conf.j2`),
que les trois familles importent : service ACTIF, commande `dummy` en CRITIQUE,
`check_interval` = le seuil — patron documenté d'Icinga 2 pour la fraîcheur. Chaque rapport
repousse l'échéance ; seul le silence la laisse arriver, y compris le silence initial.
Seuils = les `ttl` que les nœuds envoient (6 h, 45 min, 90 min), dans
`serveur_icinga_fraicheur_*`. Une liste qui en suit une autre : `test_fraicheur_icinga.py`
(dans `make test`) les compare et refuse tout service passif sans échéance — vérifié en
cassant volontairement une valeur.
**Prouvé en vrai** sur `site-mon-01` : rapport envoyé avec un `ttl` de 60 s → CRITIQUE à
t+60 s (« AUCUN RAPPORT… ») → vrai rapport de santé → OK. Déployé sur les trois Icinga
(site, Chezlepro, Technolibre) : 120 + 138 + 138 services, tous avec échéance.
**Ce que ça a aussitôt montré.** Chez les deux tenants, dix services n'avaient JAMAIS reçu
de rapport, et rougissent maintenant : `obs-01!journaux-frontiere`, et neuf sondes de rôle
(console, passerelle, édition, cache, filtrage, sites/apps servis) — leur `client_sante`
n'a pas été redéployé depuis que ces sondes existent. Leur Icinga surveillait aussi
encore `forge-01`, retiré du plan : la configuration est réalignée, mais `obs-01!collecte`
reste rouge tant que Prometheus scrute l'ancienne adresse (`10.x.21.11:9100`).
## 2026-09-28 (2) — Deux locataires sur deux n'avaient pas de sauvegarde hors de leur flotte
**Question de l'exploitant : les sauvegardes des tenants passent-elles ?** Non. Mesuré

View file

@ -314,6 +314,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
python3 scripts/tests/test_portee_console.py
python3 scripts/tests/test_devis_placement.py
python3 scripts/tests/test_sonder.py
python3 scripts/tests/test_fraicheur_icinga.py
# LA SONDE QUI RAPPORTE CE QUI DISTINGUE (2026-08-28). Trois faux diagnostics en une
# journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine

View file

@ -84,6 +84,18 @@ serveur_icinga_api_conf: "/etc/icinga2/conf.d/setops-api-users.conf"
serveur_icinga_api_utilisateur: "setops-depot"
serveur_icinga_api_motdepasse: "{{ vault_icinga_api_depot | default('') }}"
# LES SEUILS DE FRAICHEUR DES SERVICES PASSIFS (2026-09-28) — voir le gabarit
# `setops-rapport-attendu` dans `setops-sauvegardes.conf.j2`. Au-dela, sans rapport, le
# service passe CRITIQUE — y compris s'il n'a jamais rien recu.
#
# CE SONT LES `ttl` QUE LES NOEUDS ENVOIENT, recopies : ils vivent dans les roles qui
# rapportent (`client_backup`, `serveur_backup`, `client_sante`), que ce role ne charge
# pas. Une liste qui en suit une autre prend du retard : `test_fraicheur_icinga.py` les
# compare, et `make test` echoue a la premiere divergence.
serveur_icinga_fraicheur_sauvegarde: 21600 # = client_backup_ttl_icinga = serveur_backup_ttl_icinga
serveur_icinga_fraicheur_sante: 2700 # = client_sante_ttl_icinga
serveur_icinga_fraicheur_sondes: 5400 # = client_sante_ttl_sondes
# Hote portant les depots : DERIVE du groupe, jamais ecrit en dur.
serveur_icinga_hote_sauvegarde: "{{ (groups['serveur_backup'] | default([]) | first) | default('') }}"

View file

@ -20,12 +20,11 @@
{% for noeud in serveur_icinga_sante_attendue | sort %}
object Service "sante" {
import "setops-rapport-attendu"
host_name = "{{ noeud }}"
check_command = "setops-passif"
enable_active_checks = false
enable_passive_checks = true
volatile = false
max_check_attempts = 1
check_interval = {{ serveur_icinga_fraicheur_sante }}s
retry_interval = {{ serveur_icinga_fraicheur_sante }}s
vars.dummy_text = "AUCUN RAPPORT depuis {{ (serveur_icinga_fraicheur_sante / 60) | int }} min : ce noeud ne rapporte plus sa sante, ou ne l a jamais rapportee."
vars.setops_source = "{{ noeud }}"
}
{% endfor %}

View file

@ -19,14 +19,31 @@
* lui-meme, par `client_backup` et son minuteur. Le code de ce fichier avait suivi la
* decision — il attache les services aux vrais noeuds — mais l'en-tete decrivait encore
* un acteur disparu, a celui-la meme qui l'ouvrirait pour comprendre qui pousse.
* Le `ttl` porte dans chaque envoi fait la fraicheur : sans nouvelle, Icinga bascule tout
* seul en « expire ». C'est le SILENCE qui doit alerter, pas seulement l'echec — le
* silence est precisement ce qui n'a alerte personne.
* C'est le SILENCE qui doit alerter, pas seulement l'echec — le silence est precisement
* ce qui n'a alerte personne. Voir `setops-rapport-attendu` ci-dessous.
*/
object CheckCommand "setops-passif" {
// Les resultats arrivent par l'API ; cette commande n'est jamais executee.
command = [ "/bin/true" ]
/*
* UN RAPPORT ATTENDU, ET NON UN RAPPORT ESPERE (2026-09-28).
*
* Tous les services passifs de Set-OPS (sauvegardes, sante, sondes de role) importent ce
* gabarit. Ils etaient declares `enable_active_checks = false`, avec une commande qui
* executait `/bin/true`. Le `ttl` de chaque envoi couvrait le silence qui SUIT un
* rapport — pas celui d'un noeud qui n'a JAMAIS rapporte : un service passif sans aucun
* resultat reste « en attente », gris, jamais rouge. Dix noeuds de Chezlepro n'ont eu
* aucune sauvegarde du 2026-09-12 au 2026-09-28, et leurs services attendaient.
*
* Le patron documente d'Icinga 2 pour la fraicheur : le service est ACTIF, sa commande est
* `dummy` en CRITIQUE, et son `check_interval` est le seuil. Chaque rapport passif repousse
* l'echeance ; seul le silence la laisse arriver — y compris le silence initial.
*/
template Service "setops-rapport-attendu" {
check_command = "dummy"
enable_active_checks = true
enable_passive_checks = true
volatile = false
max_check_attempts = 1
vars.dummy_state = 2
}
{#
@ -51,12 +68,11 @@ object CheckCommand "setops-passif" {
#}
{% for noeud in serveur_icinga_sauvegarde_attendue | sort %}
object Service "sauvegarde: {{ noeud }}" {
import "setops-rapport-attendu"
host_name = "{{ serveur_icinga_hote_sauvegarde }}"
check_command = "setops-passif"
enable_active_checks = false
enable_passive_checks = true
volatile = false
max_check_attempts = 1
check_interval = {{ serveur_icinga_fraicheur_sauvegarde }}s
retry_interval = {{ serveur_icinga_fraicheur_sauvegarde }}s
vars.dummy_text = "AUCUN RAPPORT depuis {{ (serveur_icinga_fraicheur_sauvegarde / 60) | int }} min : la verification de ce depot ne dit plus rien, ou n a jamais rien dit."
vars.setops_source = "{{ noeud }}"
}
{% endfor %}
@ -74,12 +90,11 @@ object Service "sauvegarde: {{ noeud }}" {
#}
{% for noeud in serveur_icinga_sauvegarde_attendue | sort %}
object Service "sauvegarde" {
import "setops-rapport-attendu"
host_name = "{{ noeud }}"
check_command = "setops-passif"
enable_active_checks = false
enable_passive_checks = true
volatile = false
max_check_attempts = 1
check_interval = {{ serveur_icinga_fraicheur_sauvegarde }}s
retry_interval = {{ serveur_icinga_fraicheur_sauvegarde }}s
vars.dummy_text = "AUCUN RAPPORT depuis {{ (serveur_icinga_fraicheur_sauvegarde / 60) | int }} min : la verification de ce depot ne dit plus rien, ou n a jamais rien dit."
vars.setops_source = "{{ noeud }}"
}
{% endfor %}

View file

@ -36,12 +36,11 @@
{% for hote in ((groups[role] | default([])) | intersect(groups['client_sante'] | default([]))) | sort %}
{% for sonde in sondes %}
object Service "{{ sonde.nom }}" {
import "setops-rapport-attendu"
host_name = "{{ hote }}"
check_command = "setops-passif"
enable_active_checks = false
enable_passive_checks = true
volatile = false
max_check_attempts = 1
check_interval = {{ serveur_icinga_fraicheur_sondes }}s
retry_interval = {{ serveur_icinga_fraicheur_sondes }}s
vars.dummy_text = "AUCUN RAPPORT depuis {{ (serveur_icinga_fraicheur_sondes / 60) | int }} min : cette sonde ne rapporte plus, ou n a jamais rapporte."
vars.setops_source = "{{ hote }}"
vars.setops_role = "{{ role }}"
vars.setops_raison = "{{ (sonde.raison | default('')) | replace('"', "'") | replace('\n', ' ') | trim }}"

View file

@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Les seuils de fraicheur d'Icinga suivent les `ttl` que les noeuds envoient.
UNE LISTE QUI EN SUIT UNE AUTRE PREND DU RETARD (2026-09-28). Les services passifs de
Set-OPS passent CRITIQUES quand aucun rapport n'arrive dans leur `check_interval`. Ce
seuil vit dans `serveur_icinga` ; le `ttl` que chaque noeud envoie vit dans le role qui
rapporte. Si le seuil devient plus court que le rythme des rapports, tout rougit a tort ;
plus long, un noeud muet reste vert trop longtemps. Les deux doivent etre egaux.
Et plus aucun service passif ne doit revenir a `enable_active_checks = false` : c'est
exactement ce qui laissait un service jamais alimente « en attente », jamais rouge.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
import yaml
RACINE = Path(__file__).resolve().parents[2]
ROLES = RACINE / "roles"
def defauts(role: str) -> dict:
return yaml.safe_load((ROLES / role / "defaults" / "main.yml").read_text(encoding="utf-8")) or {}
def main() -> int:
icinga = defauts("serveur_icinga")
paires = [
("serveur_icinga_fraicheur_sauvegarde", "client_backup", "client_backup_ttl_icinga"),
("serveur_icinga_fraicheur_sauvegarde", "serveur_backup", "serveur_backup_ttl_icinga"),
("serveur_icinga_fraicheur_sante", "client_sante", "client_sante_ttl_icinga"),
("serveur_icinga_fraicheur_sondes", "client_sante", "client_sante_ttl_sondes"),
]
echecs = 0
for seuil, role, ttl in paires:
a, b = icinga.get(seuil), defauts(role).get(ttl)
if a is None or b is None or int(a) != int(b):
print(f"ECHEC {seuil} = {a} ≠ {role}.{ttl} = {b}")
echecs += 1
else:
print(f"ok {seuil} = {role}.{ttl} = {a}")
gabarits = ROLES / "serveur_icinga" / "templates"
for f in sorted(gabarits.glob("setops-*.conf.j2")):
code = re.sub(r"/\*.*?\*/|\{#.*?#\}", "", f.read_text(encoding="utf-8"), flags=re.S)
for bloc in re.findall(r'object Service "[^"]*" \{.*?\n\}', code, flags=re.S):
if "enable_passive_checks = true" in bloc and "setops-rapport-attendu" not in bloc:
print(f"ECHEC {f.name} : service passif sans `setops-rapport-attendu` :\n{bloc[:120]}")
echecs += 1
if "enable_active_checks = false" in code:
print(f"ECHEC {f.name} : `enable_active_checks = false` — un service jamais "
f"alimente resterait « en attente », jamais rouge")
echecs += 1
if echecs:
print(f"\n{echecs} echec(s).")
return 1
print("OK — fraicheur d'Icinga alignee sur les ttl, aucun service passif sans echeance.")
return 0
if __name__ == "__main__":
sys.exit(main())