Deuxieme des trois chantiers ouverts par la reconstruction. Retrouve son defaut n6 a froid, sans machine. Le SASL de Dovecot et l'interface d'Alloy se disputaient le 12345 sur infra-mail-01 depuis le premier jour, et c'est Dovecot qui perdait EN SILENCE. Il a fallu inverser l'ordre de demarrage — ce que fait un rejeu depuis zero — pour que ca devienne audible. Le controle n'etait possible qu'apres avoir DECLARE le port d'Alloy : un port SUBI (defaut amont d'un logiciel qu'on n'a pas choisi) n'existe pour aucun registre, donc aucune preuve ne peut le voir. Il faut l'imposer pour le verifier. partage: true — nouveau mot du registre — distingue « j'ouvre cette ecoute » de « je decris celle d'un autre » (serveur_backup empruntant le sshd de serveur_debian). Sans lui, la seule co-location legitime de la flotte serait signalee a tort, et une preuve qui crie sur un cas sain finit par etre ignoree. Verifie dans les deux sens : 32 revendications sans collision sur le reel ; en remettant Alloy a 12345, le defaut n6 est nomme, code 1. Harnais : 33 preuves, 0 echec, 0 sautee. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
112 lines
4.5 KiB
Python
Executable file
112 lines
4.5 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 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())
|