2026-08-08 07:33:49 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Devis des expositions web : chaque `expose:` du plan répond-il vraiment ?
|
|
|
|
|
|
|
|
|
|
Lit le relevé déposé par `playbooks/maintenance/devis-expositions.yml`. Aucun accès réseau
|
|
|
|
|
ici. Sort en code 1 s'il y a un écart.
|
|
|
|
|
|
|
|
|
|
**Une vraie requête, jamais un `connect()`.** À travers l'OPNsense (anti-spoofing), toute
|
|
|
|
|
connexion TCP réussit — y compris vers une adresse où aucune machine n'existe. Et « lire
|
|
|
|
|
des données après connexion » ne vaut rien en TLS, où c'est le *client* qui parle en
|
|
|
|
|
premier : le port 443 d'un edge sain se comporte exactement comme un port mort. Seul le
|
|
|
|
|
code de retour d'une requête HTTPS complète tranche.
|
|
|
|
|
|
|
|
|
|
**Deux points de vue, et c'est leur différence qui diagnostique :**
|
|
|
|
|
|
|
|
|
|
depuis l'edge — edge + dorsal, sans la frontière
|
|
|
|
|
depuis le poste — DNS + frontière + edge + dorsal, le chemin d'un vrai utilisateur
|
|
|
|
|
|
|
|
|
|
L'un répond et pas l'autre : ce n'est pas le service, c'est le chemin.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
resolution d'instance : une seule, partagee — au lieu de neuf copies
Cinq jours, cinq defauts, tous de la meme famille : « quelle instance, quel inventaire ? »
Neuf modules portaient chacun leur reponse.
- 18 aout : P03 comparait chaque instance a l'inventaire d'une AUTRE ;
- 19 aout : verifier_ports codait `principal/` en dur ; verifier_intrants et
_frontiere_absente lisaient le symlink au lieu de la variable ;
- 20 aout : devis_placement rendait un verdict juste sur le mauvais tenant ;
- 22 aout : P35, puis P36 — la dixieme, trouvee par la preuve elle-meme.
Aucune n'etait une faute d'inattention : chacune avait ete ecrite de bonne foi, a un
moment ou le besoin semblait local. C'est le mode de panne de la duplication — pas
l'erreur, mais la DERIVE, invisible depuis l'interieur d'un fichier.
LA RESOLUTION UNIQUE. `inventory_rules` porte instance_courante(), inventaire_de(),
dossier_inventaire() et plan_de(). Trois niveaux de repli, dont le TROISIEME manquait a la
moitie des copies : un hosts.yml existant, puis un REPERTOIRE existant (instance neuve —
c'est ce qui faisait echouer `make instancier` sur le modele public), puis le defaut.
Vingt-huit modules y sont branches.
CE QUI REND CE REFACTOR SUR : avant de toucher quoi que ce soit, chaque module a ete
interroge sur ce qu'il resolvait, pour les DEUX ecosystemes. Apres refactor, meme mesure :
17 modules x 2 instances, diff VIDE. Aucune resolution n'a change — prouve, pas suppose.
P41 echoue des qu'un module reintroduit une copie. Eprouvee en negatif : une copie
replacee dans genome.py est signalee avec son numero de ligne. Trois exemptions nommees :
instances.py et inventory_gui.py manipulent le SYMLINK lui-meme (bascule d'instance), et
devis_opnsense lit deliberement quelle instance est ACTIVE. Elles parlent du lien, pas de
la resolution.
make verifier 41 OK, 0 echec, 0 saute ; make ci idem ; lint vert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:19:55 -04:00
|
|
|
from inventory_rules import instance_courante # noqa: E402
|
|
|
|
|
|
2026-08-08 07:33:49 -04:00
|
|
|
RACINE = Path(__file__).resolve().parent.parent
|
resolution d'instance : une seule, partagee — au lieu de neuf copies
Cinq jours, cinq defauts, tous de la meme famille : « quelle instance, quel inventaire ? »
Neuf modules portaient chacun leur reponse.
- 18 aout : P03 comparait chaque instance a l'inventaire d'une AUTRE ;
- 19 aout : verifier_ports codait `principal/` en dur ; verifier_intrants et
_frontiere_absente lisaient le symlink au lieu de la variable ;
- 20 aout : devis_placement rendait un verdict juste sur le mauvais tenant ;
- 22 aout : P35, puis P36 — la dixieme, trouvee par la preuve elle-meme.
Aucune n'etait une faute d'inattention : chacune avait ete ecrite de bonne foi, a un
moment ou le besoin semblait local. C'est le mode de panne de la duplication — pas
l'erreur, mais la DERIVE, invisible depuis l'interieur d'un fichier.
LA RESOLUTION UNIQUE. `inventory_rules` porte instance_courante(), inventaire_de(),
dossier_inventaire() et plan_de(). Trois niveaux de repli, dont le TROISIEME manquait a la
moitie des copies : un hosts.yml existant, puis un REPERTOIRE existant (instance neuve —
c'est ce qui faisait echouer `make instancier` sur le modele public), puis le defaut.
Vingt-huit modules y sont branches.
CE QUI REND CE REFACTOR SUR : avant de toucher quoi que ce soit, chaque module a ete
interroge sur ce qu'il resolvait, pour les DEUX ecosystemes. Apres refactor, meme mesure :
17 modules x 2 instances, diff VIDE. Aucune resolution n'a change — prouve, pas suppose.
P41 echoue des qu'un module reintroduit une copie. Eprouvee en negatif : une copie
replacee dans genome.py est signalee avec son numero de ligne. Trois exemptions nommees :
instances.py et inventory_gui.py manipulent le SYMLINK lui-meme (bascule d'instance), et
devis_opnsense lit deliberement quelle instance est ACTIVE. Elles parlent du lien, pas de
la resolution.
make verifier 41 OK, 0 echec, 0 saute ; make ci idem ; lint vert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:19:55 -04:00
|
|
|
RELEVE = instance_courante() / "devis-expositions.json"
|
2026-08-08 07:33:49 -04:00
|
|
|
|
|
|
|
|
# Un service VIVANT peut legitimement repondre autre chose que 200 : une redirection vers
|
|
|
|
|
# l'IdP, ou un 401/403 quand l'authentification est exigee. Ce qui compte est qu'il ait
|
|
|
|
|
# repondu quelque chose d'intelligible.
|
|
|
|
|
VIVANT = {200, 301, 302, 303, 307, 401, 403}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _lire(paires: list) -> dict[str, dict]:
|
|
|
|
|
"""Le relevé associe chaque résultat à son FQDN (zip côté Ansible)."""
|
|
|
|
|
out = {}
|
|
|
|
|
for resultat, fqdn in paires:
|
|
|
|
|
out[fqdn] = resultat
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verdict(r: dict) -> tuple[int, bool, str]:
|
|
|
|
|
"""Rend (code affiché, vivant ?, message).
|
|
|
|
|
|
|
|
|
|
Un code n'est pas un verdict : un 502 ou un 504 est une reponse, et pourtant le
|
|
|
|
|
service est mort derriere. Le premier jet ne testait que la PRESENCE d'un code et
|
|
|
|
|
laissait donc passer un dorsal tombe — trouve par le test negatif, pas par la
|
|
|
|
|
lecture. Le 2026-08-08.
|
|
|
|
|
"""
|
|
|
|
|
code = r.get("status", 0)
|
|
|
|
|
if not isinstance(code, int) or code <= 0:
|
|
|
|
|
return 0, False, (r.get("msg") or "aucune réponse")[:90]
|
|
|
|
|
return code, code in VIVANT, (r.get("msg") or "")[:90]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def analyser(releve: dict) -> list[str]:
|
|
|
|
|
edge, poste, ecarts = _lire(releve["depuis_edge"]), _lire(releve["poste"]), []
|
|
|
|
|
largeur = max((len(f) for f in releve["expositions"]), default=10)
|
|
|
|
|
|
|
|
|
|
print(f" {'exposition':<{largeur}} edge poste")
|
|
|
|
|
for fqdn in releve["expositions"]:
|
|
|
|
|
ce, ve, me = _verdict(edge.get(fqdn, {}))
|
|
|
|
|
cp, vp, mp = _verdict(poste.get(fqdn, {}))
|
|
|
|
|
print(f" {fqdn:<{largeur}} {ce or '---':<6} {cp or '---'}")
|
|
|
|
|
|
|
|
|
|
if not ve and not vp:
|
|
|
|
|
ecarts.append(f"{fqdn} : en echec des DEUX cotes ({ce or 'muet'}) — le service "
|
|
|
|
|
f"lui-meme ne repond pas. {me}")
|
|
|
|
|
elif ve and not vp:
|
|
|
|
|
ecarts.append(f"{fqdn} : repond depuis l'edge ({ce}) mais PAS depuis le poste "
|
|
|
|
|
f"({cp or 'muet'}) — ce n'est pas le service, c'est le chemin "
|
|
|
|
|
f"(DNS ou frontiere). {mp}")
|
|
|
|
|
elif vp and not ve:
|
|
|
|
|
ecarts.append(f"{fqdn} : repond depuis le poste ({cp}) mais pas depuis l'edge "
|
|
|
|
|
f"({ce or 'muet'}) — la resolution interne diverge de la publique. {me}")
|
|
|
|
|
return ecarts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
|
|
|
chemin = Path(argv[0]) if argv else RELEVE
|
|
|
|
|
if not chemin.exists():
|
|
|
|
|
print("Relevé absent. Lancer d'abord : make expositions-plan", file=sys.stderr)
|
|
|
|
|
return 2
|
|
|
|
|
|
|
|
|
|
releve = json.loads(chemin.read_text(encoding="utf-8"))
|
|
|
|
|
print(f"Devis des expositions — {len(releve['expositions'])} déclarée(s) au plan, "
|
|
|
|
|
f"edge {releve['edge']}\n")
|
|
|
|
|
ecarts = analyser(releve)
|
|
|
|
|
|
|
|
|
|
if not ecarts:
|
|
|
|
|
print("\nCONFORME : toute exposition déclarée répond, des deux points de vue.")
|
|
|
|
|
return 0
|
|
|
|
|
print()
|
|
|
|
|
for e in ecarts:
|
|
|
|
|
print(f" [écart] {e}")
|
|
|
|
|
print(f"\nÉCART : {len(ecarts)} exposition(s) en difficulté.")
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
raise SystemExit(main(sys.argv[1:]))
|