Set-OPS-Public/scripts/verifier_ports.py
Daniel Allaire c18debc25e
Some checks are pending
verifier / verifier (push) Waiting to run
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

120 lines
4.8 KiB
Python
Executable file

#!/usr/bin/env python3
"""Deux rôles co-localisés revendiquent-ils le même port ?
Un port n'appartient à personne : le premier service démarré le prend, et l'autre boucle
sur `bind: address already in use`. Rien ne vérifiait qu'un hôte ne porte pas deux rôles
qui veulent le même.
Le défaut n'est pas théorique. Sur `infra-mail-01`, l'écoute SASL de Dovecot (12345,
choix délibéré de Set-OPS) et l'interface HTTP d'Alloy (12345, défaut amont) se disputent
le port depuis le premier jour. En exploitation courante Alloy le tenait, et c'est Dovecot
qui échouait — **en silence**. Il a fallu une reconstruction from-zero, qui inverse l'ordre
de démarrage, pour que ça devienne audible (2026-08-08).
**La condition pour que ce contrôle existe** : que tout port lié soit DÉCLARÉ. Celui
d'Alloy ne l'était pas — c'était un défaut amont subi, donc invisible. Il l'est désormais.
**`partage: true`** distingue les deux situations que le registre confondait :
- un rôle qui **ouvre** une écoute → il revendique le port ;
- un rôle qui **décrit** l'écoute d'un autre pour la documenter ou l'autoriser
(`serveur_backup` empruntant le sshd de `serveur_debian`) → pas une revendication.
Sans cette distinction, la seule co-location légitime de la flotte serait signalée à tort,
et on apprendrait à ignorer la preuve — ce qui est pire que de ne pas l'avoir.
"""
from __future__ import annotations
import argparse
import collections
import os
import sys
from pathlib import Path
import yaml
from inventory_rules import inventaire_de
RACINE = Path(__file__).resolve().parent.parent
def _groupes(inventaire: Path) -> dict[str, set[str]]:
"""Groupe -> hotes, en descendant les `children` de l'inventaire YAML."""
inv = yaml.safe_load(inventaire.read_text(encoding="utf-8")) or {}
trouves: dict[str, set[str]] = collections.defaultdict(set)
def marcher(noeud, nom):
if not isinstance(noeud, dict):
return
if nom and isinstance(noeud.get("hosts"), dict):
trouves[nom] |= set(noeud["hosts"])
for enfant, sous in (noeud.get("children") or {}).items():
marcher(sous, enfant)
marcher(inv.get("all") or {}, "all")
return trouves
def _revendications() -> dict[tuple[str, str], set[str]]:
"""(protocole, port) -> roles qui OUVRENT cette ecoute."""
par_port: dict[tuple[str, str], set[str]] = collections.defaultdict(set)
for f in sorted(RACINE.glob("roles/*/meta/flux.yml")):
role = f.parts[-3]
d = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
for fl in d.get("flux") or []:
if fl.get("sens") != "ingress" or fl.get("partage"):
continue
port = str(fl.get("port"))
if not port.isdigit(): # `derive`, codes ICMP : pas une ecoute fixe
continue
par_port[(str(fl.get("protocole", "tcp")).lower(), port)].add(role)
return par_port
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--inventaire", default=None)
a = ap.parse_args(argv)
# MEME PRECEDENCE QUE PARTOUT AILLEURS : SETOPS_INVENTAIRE, puis l'inventaire present
# (principal > production). Le chemin `principal/` etait code en dur ici, et rendait
# cette preuve inapplicable a toute instance en `production/` — dont le modele public,
# donc dont un depot fraichement clone (mesure du 2026-08-19).
inventaire = Path(a.inventaire) if a.inventaire else inventaire_de()
if not inventaire.is_file():
print(f"Inventaire introuvable : {inventaire}", file=sys.stderr)
return 2
groupes = _groupes(inventaire)
par_port = _revendications()
conflits: list[str] = []
for (proto, port), roles in sorted(par_port.items()):
if len(roles) < 2:
continue
hotes = set().union(*[groupes.get(r, set()) for r in roles])
for hote in sorted(hotes):
ici = sorted(r for r in roles if hote in groupes.get(r, set()))
if len(ici) > 1:
conflits.append(f"{hote} : {proto}/{port} revendique par " + " et ".join(ici))
if conflits:
print(f"{len(conflits)} collision(s) de port entre roles co-localises :\n")
for c in conflits:
print(f" {c}")
print("\nLe premier service demarre prendra le port ; l'autre echouera, peut-etre\n"
"en silence. Deplacer l'un des deux, ou marquer `partage: true` si les deux\n"
"decrivent la MEME ecoute.")
return 1
total = sum(len(r) for r in par_port.values())
print(f"CONFORME : {total} revendication(s) de port, aucune collision "
f"entre roles co-localises ({len(groupes)} groupes).")
return 0
if __name__ == "__main__":
raise SystemExit(main())