From f5adffb0de6ce97969d94b64d94c837c21c8dea0 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Mon, 28 Sep 2026 12:31:17 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 25 +++++ Makefile | 1 + roles/serveur_durci/meta/supervision.yml | 4 + .../templates/setops-sondes.conf.j2 | 16 ++++ roles/serveur_loki/meta/supervision.yml | 4 + roles/serveur_ops/meta/supervision.yml | 2 + scripts/tests/test_sondes_conditionnelles.py | 92 +++++++++++++++++++ 7 files changed, 144 insertions(+) create mode 100644 scripts/tests/test_sondes_conditionnelles.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fa5a6b..cdec946 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # 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: ` 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 **Le trou.** Tous les services passifs (sauvegardes, santé, sondes de rôle) étaient diff --git a/Makefile b/Makefile index 73a09dc..b527714 100644 --- a/Makefile +++ b/Makefile @@ -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_sonder.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 # journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine diff --git a/roles/serveur_durci/meta/supervision.yml b/roles/serveur_durci/meta/supervision.yml index bb4de28..b66ba5a 100644 --- a/roles/serveur_durci/meta/supervision.yml +++ b/roles/serveur_durci/meta/supervision.yml @@ -19,6 +19,10 @@ sondes: - nom: audit 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: >- 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 diff --git a/roles/serveur_icinga/templates/setops-sondes.conf.j2 b/roles/serveur_icinga/templates/setops-sondes.conf.j2 index c690379..923f826 100644 --- a/roles/serveur_icinga/templates/setops-sondes.conf.j2 +++ b/roles/serveur_icinga/templates/setops-sondes.conf.j2 @@ -31,10 +31,25 @@ * 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. + + 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: `, + 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 hote in ((groups[role] | default([])) | intersect(groups['client_sante'] | default([]))) | sort %} {% 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 }}" { import "setops-rapport-attendu" host_name = "{{ hote }}" @@ -45,6 +60,7 @@ object Service "{{ sonde.nom }}" { vars.setops_role = "{{ role }}" vars.setops_raison = "{{ (sonde.raison | default('')) | replace('"', "'") | replace('\n', ' ') | trim }}" } +{% endif %} {% endfor %} {% endfor %} {% endfor %} diff --git a/roles/serveur_loki/meta/supervision.yml b/roles/serveur_loki/meta/supervision.yml index 544a32b..3e5092a 100644 --- a/roles/serveur_loki/meta/supervision.yml +++ b/roles/serveur_loki/meta/supervision.yml @@ -9,6 +9,10 @@ sondes: - nom: journaux-frontiere 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: >- 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 diff --git a/roles/serveur_ops/meta/supervision.yml b/roles/serveur_ops/meta/supervision.yml index da0890d..2e73878 100644 --- a/roles/serveur_ops/meta/supervision.yml +++ b/roles/serveur_ops/meta/supervision.yml @@ -18,6 +18,8 @@ sondes: # REFUS sur une requete anonyme : c'est la seule facon de mesurer une serrure. - nom: console-ops 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 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 diff --git a/scripts/tests/test_sondes_conditionnelles.py b/scripts/tests/test_sondes_conditionnelles.py new file mode 100644 index 0000000..428f66f --- /dev/null +++ b/scripts/tests/test_sondes_conditionnelles.py @@ -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, "") + if val == "" 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())