Some checks are pending
verifier / verifier (push) Waiting to run
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>
120 lines
4.8 KiB
Python
Executable file
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())
|