Set-OPS-Public/scripts/verifier_ports.py
Daniel Allaire a68b9cd10e
Some checks are pending
verifier / verifier (push) Waiting to run
CI : le harnais ne se declenchait que par memoire
Trente-huit preuves, des tests, un lint — et RIEN ne les executait sans qu'un humain tape
`make`. Le meilleur atout du depot dependait de ne pas oublier. Il a desormais une CI
(.forgejo/workflows/verifier.yml) et une cible qui la rejoue a l'identique : `make ci`.

CE QUE LA CI A TROUVE AVANT D'EXISTER. Ecrire le workflow supposait de repondre a une
question jamais posee : est-ce qu'un depot PUBLIC, seul, se tient ? Mesure sur un clone
nu : non, a cinq endroits.

- `make instancier` echouait sur le modele public — le tout premier geste du QUICKSTART.
  Le Makefile forcait `principal/hosts.yml` alors que le modele vit en `production/` ; sa
  precedence suit desormais celle du code (fichier, puis REPERTOIRE existant, puis defaut).
- P32 parcourait les 54 roles sans regarder ce que l'instance deploie. Elle passait sur
  l'ecosysteme de reference PARCE QU'IL PORTE TOUT. Or les modeles sont des OFFRES : toute
  offre plus petite que l'ecosysteme complet echouait son propre harnais, pour des services
  qu'elle ne vend pas. Le perimetre se lit maintenant du plan (groupes de l'inventaire,
  puis roles composes par leur playbook).
- P24 : le modele public ne declarait aucun reseau d'administration — une flotte qu'on
  construit et ou l'on n'entre plus. `nftables_admin_ssh` est pose, avec le pourquoi.
- P33 : verifier_ports.py codait `instance/inventories/principal/hosts.yml` en dur.
- P32 et P24 lisaient le symlink `instance/` au lieu de SETOPS_INSTANCE.

Toutes de la MEME FAMILLE que P03 avant-hier : une resolution d'inventaire recopiee, une
variable d'environnement qui deborde de sa portee. Le depot en compte SEPT ; deux de plus
sont corrigees ici, et la septieme le dit en commentaire plutot que de le taire.

`make ci` NE TOUCHE AUCUN SYMLINK : le modele public est monte comme instance jetable,
vise par SETOPS_INSTANCE/SETOPS_UNDERLAY, detruit en sortant. Deux details mesures parce
que devines faux d'abord : l'instance jetable est un DOSSIER FRERE (la federation se
decouvre ainsi ; ailleurs, quatre preuves tombent) ; et SETOPS_UNDERLAY n'est pose QUE
pour la verification, sinon l'inventaire est ecrit avec une fabric et regenere avec une
autre — la commande fabriquait l'ecart qu'elle denonce.

RESULTAT : clone nu sans instance ni frere -> 38 OK, 0 echec, 0 saute. Depot de
l'exploitant avec ses 3 instances -> 38 OK, 0 echec, 0 saute. Aucun residu.

Et le lint du depot a refuse mon propre fichier de CI avant qu'il ne tourne une seule fois
(`on:` lu par YAML comme le booleen vrai). Le harnais mordait deja.

A AJUSTER AU PREMIER PASSAGE, ecrit en tete du workflow : l'etiquette `runs-on` doit
correspondre a un runner Forgejo enregistre, et le runner a besoin du reseau pour pip et
ansible-galaxy. Le vert de cette CI dira que le moteur et son modele public se tiennent —
pas que la flotte va bien : aucune VM jointe, aucune voute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 19:29:46 -04:00

135 lines
5.6 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
RACINE = Path(__file__).resolve().parent.parent
def _inventaire_par_defaut() -> Path:
"""L'inventaire de l'instance montee — meme precedence que le reste du moteur.
CINQ endroits resolvent l'inventaire dans ce depot, et deux defauts en sont sortis le
meme jour (2026-08-19) : celui-ci codait `principal/` en dur, ce qui rendait la preuve
inapplicable a toute instance en `production/` — dont le modele public. A consolider :
une seule resolution partagee, plutot que cinq copies qui derivent chacune de son cote.
"""
force = os.environ.get("SETOPS_INVENTAIRE")
if force:
return Path(force)
instance = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance"))
for nom in ("principal", "production"):
p = instance / "inventories" / nom / "hosts.yml"
if p.is_file():
return p
return instance / "inventories" / "principal" / "hosts.yml"
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_par_defaut()
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())