#!/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 sys from pathlib import Path import yaml 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) inventaire = Path(a.inventaire) if a.inventaire else \ (RACINE / "instance" / "inventories" / "principal" / "hosts.yml") 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())