sondes conditionnelles : attendues seulement la ou elles sont posees

seulement_si dans meta/supervision.yml, lu par le gabarit Icinga des sondes ;
garde test_sondes_conditionnelles. journaux-frontiere ne rougit plus a vie chez
les locataires ; Technolibre sans critique.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-28 12:31:17 -04:00
parent 185b000c60
commit f5adffb0de
7 changed files with 144 additions and 0 deletions

View file

@ -1,5 +1,30 @@
# CHANGELOG — Set-OPS # CHANGELOG — Set-OPS
## 2026-09-28 (4) — Une sonde posée sous condition n'est attendue que là où elle est posée
**Ce que la fraîcheur a fait voir.** Chez les deux locataires, `obs-01!journaux-frontiere`
est passé au rouge et y serait resté pour toujours. `serveur_loki` ne pose cette sonde que
si une étiquette de frontière est déclarée — au SITE, jamais chez un locataire, dont la
frontière appartient à l'hébergeur — et la RETIRE sinon. Icinga, lui, l'attendait sur tout
hôte du groupe. Trois rôles posent ainsi leur sonde sous `when:` (`journaux-frontiere`,
`audit`, `console-ops`) ; les deux autres n'étaient pas rouges par chance, leur condition
étant vraie partout.
**Le correctif.** La déclaration porte la même condition que le dépôt :
`seulement_si: <variable>` dans `meta/supervision.yml`, lue par `setops-sondes.conf.j2`
dans l'inventaire de l'hôte, sinon dans les défauts du rôle qui la définit (`defini_par`)
— la valeur même que ce rôle voit. `test_sondes_conditionnelles.py` (dans `make test`)
exige qu'un `when:` qui pose une sonde ait son `seulement_si`, sur la même variable, avec
un défaut littéral ; vérifié en retirant volontairement une condition.
Simulé puis appliqué : chez chaque locataire, un seul changement — `journaux-frontiere`
retiré d'`obs-01`. Technolibre n'a plus aucun critique ; Chezlepro n'en garde qu'un, son
exportateur PostgreSQL, faute du secret `vault_pg_exportateur`.
**Ce que ça révèle au passage.** Le site ne déclare `journaux-frontiere` nulle part : aucune
machine ne sonde aujourd'hui les journaux de la frontière — la panne de dix jours qu'elle
devait empêcher n'est pas couverte.
## 2026-09-28 (3) — Un service qui n'a jamais rien reçu passe au rouge ## 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 **Le trou.** Tous les services passifs (sauvegardes, santé, sondes de rôle) étaient

View file

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

View file

@ -19,6 +19,10 @@
sondes: sondes:
- nom: audit - nom: audit
ttl: 5400 ttl: 5400
# Posee par `auditd` seulement si `auditd_enabled` : Icinga ne l'attend qu'a ces
# conditions (voir `setops-sondes.conf.j2`, 2026-09-28).
seulement_si: auditd_enabled
defini_par: auditd
raison: >- raison: >-
La piste d'audit s'ecrit-elle vraiment ? Des regles armees dans le noyau ne La piste d'audit s'ecrit-elle vraiment ? Des regles armees dans le noyau ne
prouvent pas qu'un demon collecte : le 2026-08-30, quinze machines ont eu les prouvent pas qu'un demon collecte : le 2026-08-30, quinze machines ont eu les

View file

@ -31,10 +31,25 @@
* L'appartenance a un groupe sert donc DEUX choses qui ne coincident pas toujours : a * 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 * 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. * lui-meme — `client_sante` — et nulle part ailleurs.
UNE SONDE POSEE SOUS CONDITION N'EST ATTENDUE QUE LA OU ELLE EST POSEE (2026-09-28).
Trois roles posent leur sonde sous `when:` (`journaux-frontiere`, `audit`,
`console-ops`). Ce fichier les attendait pourtant sur tout hote du groupe : chaque
locataire portait un `journaux-frontiere` rouge a vie, sa frontiere appartenant au site.
La declaration porte donc la MEME condition que le depot : `seulement_si: <variable>`,
lue dans l'inventaire de l'hote, sinon dans les defauts du role qui la definit
(`defini_par`, par defaut le role declarant) — la valeur meme que ce role voit.
`test_sondes_conditionnelles.py` garde l'accord entre le `when:` et la declaration.
#} #}
{% for role, sondes in (serveur_icinga_sondes | default({})) | dictsort %} {% for role, sondes in (serveur_icinga_sondes | default({})) | dictsort %}
{% for hote in ((groups[role] | default([])) | intersect(groups['client_sante'] | default([]))) | sort %} {% for hote in ((groups[role] | default([])) | intersect(groups['client_sante'] | default([]))) | sort %}
{% for sonde in sondes %} {% for sonde in sondes %}
{% set condition = sonde.seulement_si | default('') %}
{% if condition %}
{% set defauts = lookup('file', role_path ~ '/../' ~ (sonde.defini_par | default(role)) ~ '/defaults/main.yml') | from_yaml %}
{% set valeur = hostvars[hote][condition] if hostvars[hote][condition] is defined else defauts.get(condition) %}
{% endif %}
{% if not condition or valeur %}
object Service "{{ sonde.nom }}" { object Service "{{ sonde.nom }}" {
import "setops-rapport-attendu" import "setops-rapport-attendu"
host_name = "{{ hote }}" host_name = "{{ hote }}"
@ -45,6 +60,7 @@ object Service "{{ sonde.nom }}" {
vars.setops_role = "{{ role }}" vars.setops_role = "{{ role }}"
vars.setops_raison = "{{ (sonde.raison | default('')) | replace('"', "'") | replace('\n', ' ') | trim }}" vars.setops_raison = "{{ (sonde.raison | default('')) | replace('"', "'") | replace('\n', ' ') | trim }}"
} }
{% endif %}
{% endfor %} {% endfor %}
{% endfor %} {% endfor %}
{% endfor %} {% endfor %}

View file

@ -9,6 +9,10 @@ sondes:
- nom: journaux-frontiere - nom: journaux-frontiere
ttl: 5400 ttl: 5400
# Posee seulement la ou une frontiere est ecoutee : au SITE, pas chez un locataire,
# dont la frontiere appartient a l'hebergeur. Sans cette ligne, Icinga l'attendait
# partout, et chaque locataire portait un rouge permanent (2026-09-28).
seulement_si: serveur_loki_sonde_frontiere_etiquette
raison: >- raison: >-
La frontiere journalise-t-elle encore ? Sa destination syslog a deja pointe une VM La frontiere journalise-t-elle encore ? Sa destination syslog a deja pointe une VM
rasee : toute la journalisation d'OPNsense s'est arretee dix jours sans que rien ne rasee : toute la journalisation d'OPNsense s'est arretee dix jours sans que rien ne

View file

@ -18,6 +18,8 @@ sondes:
# REFUS sur une requete anonyme : c'est la seule facon de mesurer une serrure. # REFUS sur une requete anonyme : c'est la seule facon de mesurer une serrure.
- nom: console-ops - nom: console-ops
ttl: 5400 ttl: 5400
# Posee seulement si le GUI est actif (voir `setops-sondes.conf.j2`, 2026-09-28).
seulement_si: serveur_ops_gui_actif
raison: 'La console d''exploitation repond-elle, et son vestibule tient-il ? Un GUI raison: 'La console d''exploitation repond-elle, et son vestibule tient-il ? Un GUI
mort derriere un nginx vivant rend une page d''erreur a qui sait deja entrer ; un mort derriere un nginx vivant rend une page d''erreur a qui sait deja entrer ; un
vestibule tombe rend la fabric entiere a qui trouve l''URL. Le second ne fait vestibule tombe rend la fabric entiere a qui trouve l''URL. Le second ne fait

View file

@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Une sonde posee sous condition est declaree sous la MEME condition.
UNE LISTE QUI EN SUIT UNE AUTRE PREND DU RETARD (2026-09-28). Un role pose sa sonde dans
`/usr/local/lib/setops/sondes/` avec ses taches ; Icinga l'attend d'apres
`meta/supervision.yml`. Quand le depot est conditionnel (`when:`) et la declaration ne
l'est pas, Icinga attend une sonde qui n'existera jamais : `journaux-frontiere` a ete
rouge a vie chez chaque locataire, dont la frontiere appartient au site.
Trois regles :
1. toute sonde deposee sous `when:` est declaree avec `seulement_si` ;
2. la variable de `seulement_si` figure dans ce `when:` ;
3. elle a une valeur par defaut LITTERALE dans le role qui la definit — Icinga la lit
telle quelle, il ne rend pas le Jinja d'un autre role.
"""
from __future__ import annotations
import sys
from pathlib import Path
import yaml
ROLES = Path(__file__).resolve().parents[2] / "roles"
def depots_conditionnels() -> dict[str, str]:
"""{nom de sonde : texte du `when:`} pour chaque sonde deposee sous condition."""
out = {}
for f in sorted(ROLES.glob("*/tasks/*.yml")):
try:
taches = yaml.safe_load(f.read_text(encoding="utf-8")) or []
except yaml.YAMLError:
continue
for t in taches if isinstance(taches, list) else []:
if not isinstance(t, dict):
continue
module = next((v for v in t.values() if isinstance(v, dict) and "dest" in v), None)
dest = str((module or {}).get("dest", ""))
if "/setops/sondes/" in dest and t.get("when"):
quand = t["when"] if isinstance(t["when"], str) else " ".join(map(str, t["when"]))
out[dest.rsplit("/", 1)[-1].removesuffix(".sh")] = quand
return out
def declarations() -> dict[str, tuple[str, dict]]:
"""{nom de sonde : (role declarant, declaration)}."""
out = {}
for f in sorted(ROLES.glob("*/meta/supervision.yml")):
for s in (yaml.safe_load(f.read_text(encoding="utf-8")) or {}).get("sondes") or []:
out[s["nom"]] = (f.parents[1].name, s)
return out
def main() -> int:
depots, decl = depots_conditionnels(), declarations()
echecs = 0
for nom, quand in sorted(depots.items()):
role, s = decl.get(nom, (None, {}))
var = s.get("seulement_si")
if not var:
print(f"ECHEC « {nom} » est posee sous `when: {quand}` mais declaree sans "
f"`seulement_si` : Icinga l'attendrait la ou elle n'existe pas")
echecs += 1
continue
if var not in quand:
print(f"ECHEC « {nom} » : `seulement_si: {var}` n'est pas la condition du depot "
f"(`when: {quand}`)")
echecs += 1
continue
definisseur = s.get("defini_par") or role
defauts = yaml.safe_load((ROLES / definisseur / "defaults" / "main.yml")
.read_text(encoding="utf-8")) or {}
val = defauts.get(var, "<absente>")
if val == "<absente>" or (isinstance(val, str) and "{{" in val):
print(f"ECHEC « {nom} » : `{var}` doit avoir un defaut LITTERAL dans "
f"`{definisseur}/defaults/main.yml` (trouve : {val!r})")
echecs += 1
continue
print(f"ok {nom:<20} seulement_si {var} (defaut {val!r}, {definisseur})")
for nom, (role, s) in sorted(decl.items()):
if s.get("seulement_si") and nom not in depots:
print(f"ECHEC « {nom} » ({role}) declare `seulement_si` sans depot conditionnel")
echecs += 1
if echecs:
print(f"\n{echecs} echec(s).")
return 1
print("OK — chaque sonde conditionnelle est attendue sous sa propre condition.")
return 0
if __name__ == "__main__":
sys.exit(main())