P33 / D-73 : aucune collision de port entre roles co-localises

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>
This commit is contained in:
Daniel Allaire 2026-08-09 09:46:17 -04:00
parent d52f256366
commit 28e0c45696
9 changed files with 177 additions and 3 deletions

View file

@ -1,5 +1,34 @@
# CHANGELOG — Set-OPS # CHANGELOG — Set-OPS
## 2026-08-09 — P33 : deux rôles co-localisés ne revendiquent pas le même port
Deuxième des trois chantiers ouverts par la reconstruction. Il retrouve son défaut nº 6 à
froid, sans machine.
Un port n'appartient à personne : le premier service démarré le prend, l'autre échoue.
Sur `infra-mail-01`, le SASL de Dovecot (12345, choix délibéré) et l'interface HTTP d'Alloy
(12345, défaut amont) se le disputaient **depuis le premier jour** — et c'est Dovecot qui
perdait, sans que rien ne le dise. Il a fallu inverser l'ordre de démarrage, ce que fait un
rejeu depuis zéro, pour que ça devienne audible.
**Le contrôle n'était possible qu'après avoir déclaré le port d'Alloy.** C'est la vraie
leçon du défaut nº 6 : un port **subi** — le défaut 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 pouvoir le vérifier.
**`partage: true`**, nouveau mot du registre des flux, distingue deux situations qu'il
confondait : un rôle qui **ouvre** une écoute, et un rôle qui **décrit** celle d'un autre —
`serveur_backup` empruntant le sshd de `serveur_debian`. Sans lui, la seule co-location
légitime de la flotte (`tcp/22` sur `backup-01`) serait signalée à tort. Une preuve qui
crie sur un cas sain finit par être ignorée : c'est pire que de ne pas l'avoir.
**Vérifié dans les deux sens.** Sur la flotte réelle : 32 revendications, aucune collision.
En remettant le port d'Alloy à 12345 comme hier : `infra-mail-01 : tcp/12345 revendiqué par
client_journal et serveur_dovecot`, code 1 — le défaut nº 6 reproduit sans toucher à une
machine.
Harnais : **33 preuves, 0 échec, 0 sautée.**
## 2026-08-09 — P32 : un `assert` de rôle est un contrat, et l'instance doit l'honorer ## 2026-08-09 — P32 : un `assert` de rôle est un contrat, et l'instance doit l'honorer
Le premier des trois chantiers que la reconstruction avait rendus évidents. Il aurait Le premier des trois chantiers que la reconstruction avait rendus évidents. Il aurait

View file

@ -438,6 +438,9 @@ certificats-plan: ansible-runtime ## Ecart entre les certificats sur disque et c
@ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-certificats.yml >/dev/null @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-certificats.yml >/dev/null
@python3 scripts/devis_certificats.py @python3 scripts/devis_certificats.py
ports-verifier: ## Deux roles co-localises revendiquent-ils le meme port ? (lecture seule)
@python3 scripts/verifier_ports.py
intrants-verifier: ## Tout intrant qu'un role EXIGE est-il fourni par l'instance ? (lecture seule) intrants-verifier: ## Tout intrant qu'un role EXIGE est-il fourni par l'instance ? (lecture seule)
@python3 scripts/verifier_intrants.py @python3 scripts/verifier_intrants.py

View file

@ -7,7 +7,7 @@
> [`docs/audit/affirmations.md`](affirmations.md). > [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml` - **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (32 OK · 0 echec · 0 saute) - **Verdict** : ✅ CONFORME (33 OK · 0 echec · 0 saute)
## Preuves ## Preuves
@ -43,8 +43,9 @@
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 2 pool(s) Proxmox, 28 VM placee(s), aucun nom ni VMID en collision. | | P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 2 pool(s) Proxmox, 28 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 23 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 12, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv | | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 23 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 12, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. | | P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 38 scripts expliques et atteignables, 88 cibles make documentees, 54 roles avec README. | | P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 39 scripts expliques et atteignables, 89 cibles make documentees, 54 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 30 exigence(s) de role, toutes satisfaites (126 cle(s) declaree(s) par l'instance). | | P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 30 exigence(s) de role, toutes satisfaites (126 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 32 revendication(s) de port, aucune collision entre roles co-localises (33 groupes). |
## Couverture des affirmations ✅ du registre ## Couverture des affirmations ✅ du registre

View file

@ -23,7 +23,7 @@ code + les README de rôles). Cette page comble ces deux trous.
| **Ordre de déploiement** | `docs/couches-deploiement.yml` (couches) + `docs/dependances-groupes.yml` (graphe) → `playbooks/site.yml` (**généré**, `make site`) | | **Ordre de déploiement** | `docs/couches-deploiement.yml` (couches) + `docs/dependances-groupes.yml` (graphe) → `playbooks/site.yml` (**généré**, `make site`) |
| **Conformité du déployé** | `docs/devis-services.md` — les **cinq devis de service** (`make identite-plan`, `certificats-plan`, `expositions-plan`, `postgresql-plan`, `courriel-plan`). Répondent à ce que `make prouver` ne demande jamais : *ce qui tourne correspond-il à ce qui est déclaré ?* | | **Conformité du déployé** | `docs/devis-services.md` — les **cinq devis de service** (`make identite-plan`, `certificats-plan`, `expositions-plan`, `postgresql-plan`, `courriel-plan`). Répondent à ce que `make prouver` ne demande jamais : *ce qui tourne correspond-il à ce qui est déclaré ?* |
| **Preuve / recette** | `docs/audit/affirmations.md` (registre), `make prouver``docs/audit/preuve-<date>.md`**statique** : lit le dépôt, aucun appel réseau ; la conformité du déployé est l'affaire des devis de service (ligne au-dessus), `docs/audit/plan-de-recette.md` (**généré** du wiki), `docs/audit/protocole-operateur-independant.md` | | **Preuve / recette** | `docs/audit/affirmations.md` (registre), `make prouver``docs/audit/preuve-<date>.md`**statique** : lit le dépôt, aucun appel réseau ; la conformité du déployé est l'affaire des devis de service (ligne au-dessus), `docs/audit/plan-de-recette.md` (**généré** du wiki), `docs/audit/protocole-operateur-independant.md` |
| **Décisions d'architecture** | `docs/decisions-architecture.md`**69 décisions en vigueur** (D-01 → D-72, 3 renversées), pourquoi, où lire le détail, et ce qui les garde ; plus les **décisions renversées** et leur cause | | **Décisions d'architecture** | `docs/decisions-architecture.md`**70 décisions en vigueur** (D-01 → D-73, 3 renversées), pourquoi, où lire le détail, et ce qui les garde ; plus les **décisions renversées** et leur cause |
| **SDN / routage** | `docs/sdn-evpn.md` — décision du 2026-08-02 : le routage inter-zone passe des commutateurs aux hyperviseurs (zones EVPN = VRF). **Non éprouvé** : spike avant génération | | **SDN / routage** | `docs/sdn-evpn.md` — décision du 2026-08-02 : le routage inter-zone passe des commutateurs aux hyperviseurs (zones EVPN = VRF). **Non éprouvé** : spike avant génération |
| **Migration de tenant** | `docs/migration-tenant.md` — recette en 8 étapes, machine à états, gardes ; le receveur se construit **avant** tout gel | | **Migration de tenant** | `docs/migration-tenant.md` — recette en 8 étapes, machine à états, gardes ; le receveur se construit **avant** tout gel |
| **Exploitation courante** | `docs/runbooks-exploitation.md`, `docs/intrants-communs.md`, `docs/intrants-base-gui-conception.md`, `docs/theme-forgejo-hors-flotte.md` | | **Exploitation courante** | `docs/runbooks-exploitation.md`, `docs/intrants-communs.md`, `docs/intrants-base-gui-conception.md`, `docs/theme-forgejo-hors-flotte.md` |

View file

@ -108,6 +108,7 @@ sont les seules vérifiables.
| **D-68** | On **écrit, puis on relit et on compare** — quelle que soit l'interface ; on choisit celle dont le chemin de **lecture** parle le même langage que le chemin d'**écriture** | « toujours préférer l'API » n'aurait prédit aucune des pannes du 2026-08-08 : sur six familles de défauts, deux venaient d'un CLI, une d'un module Ansible (`ldap_entry` crée sans jamais modifier), une d'un `grep` de fichier, une de la précédence Ansible, une de mon comparateur. Le facteur commun est d'avoir écrit sans relire. Et la plupart de la flotte n'a **pas** d'API — Postfix, Dovecot, nginx, slapd, nftables : `postconf -h` / `postconf -e` sont symétriques, c'est tout ce qu'on demande | `devis-services.md` | les 5 devis | | **D-68** | On **écrit, puis on relit et on compare** — quelle que soit l'interface ; on choisit celle dont le chemin de **lecture** parle le même langage que le chemin d'**écriture** | « toujours préférer l'API » n'aurait prédit aucune des pannes du 2026-08-08 : sur six familles de défauts, deux venaient d'un CLI, une d'un module Ansible (`ldap_entry` crée sans jamais modifier), une d'un `grep` de fichier, une de la précédence Ansible, une de mon comparateur. Le facteur commun est d'avoir écrit sans relire. Et la plupart de la flotte n'a **pas** d'API — Postfix, Dovecot, nginx, slapd, nftables : `postconf -h` / `postconf -e` sont symétriques, c'est tout ce qu'on demande | `devis-services.md` | les 5 devis |
| **D-69** | Sur Keycloak : **l'API pour toute map ou collection** (`smtpServer`, `attributes`, `config`), `kcadm` pour les scalaires et les créations | `kcadm -s` sur une map accepte la commande, **sort en succès et n'écrit rien** — mesuré deux fois le 2026-08-08 (`smtpServer` resté vide après deux déploiements verts, puis `post.logout.redirect.uris`). Le CLI reste préféré ailleurs : c'est le vocabulaire de la documentation du produit, donc lisible sans IA | `roles/serveur_keycloak/tasks/` | `make identite-plan` | | **D-69** | Sur Keycloak : **l'API pour toute map ou collection** (`smtpServer`, `attributes`, `config`), `kcadm` pour les scalaires et les créations | `kcadm -s` sur une map accepte la commande, **sort en succès et n'écrit rien** — mesuré deux fois le 2026-08-08 (`smtpServer` resté vide après deux déploiements verts, puis `post.logout.redirect.uris`). Le CLI reste préféré ailleurs : c'est le vocabulaire de la documentation du produit, donc lisible sans IA | `roles/serveur_keycloak/tasks/` | `make identite-plan` |
| **D-72** | Un `assert` de rôle est un **contrat d'intrant**, et l'instance doit l'honorer — vérifié hors ligne | la première reconstruction from-zero s'est arrêtée sur `amorcage_acces_courriel` : obligatoire depuis le matin, déclaré par aucun tenant, et invisible parce que le compte existait déjà — la garde n'avait jamais eu l'occasion de se déclencher. Un intrant est satisfait par un **défaut non vide**, un `set_fact` de résolveur, ou une déclaration de l'inventaire (fichier `hosts.yml` compris) | `scripts/verifier_intrants.py`, `make intrants-verifier` | **P32** | | **D-72** | Un `assert` de rôle est un **contrat d'intrant**, et l'instance doit l'honorer — vérifié hors ligne | la première reconstruction from-zero s'est arrêtée sur `amorcage_acces_courriel` : obligatoire depuis le matin, déclaré par aucun tenant, et invisible parce que le compte existait déjà — la garde n'avait jamais eu l'occasion de se déclencher. Un intrant est satisfait par un **défaut non vide**, un `set_fact` de résolveur, ou une déclaration de l'inventaire (fichier `hosts.yml` compris) | `scripts/verifier_intrants.py`, `make intrants-verifier` | **P32** |
| **D-73** | Tout port **lié** se déclare, et deux rôles co-localisés ne peuvent pas revendiquer le même | un port n'appartient à personne : le premier démarré le prend, l'autre échoue — parfois **en silence**. Sur `infra-mail-01`, le SASL de Dovecot et l'interface d'Alloy se disputaient le 12345 depuis le premier jour, et c'est Dovecot qui perdait sans que rien ne le dise. Le contrôle n'était possible qu'une fois le port d'Alloy — un défaut amont **subi** — déclaré. `partage: true` distingue « j'ouvre cette écoute » de « je décris celle d'un autre » | `scripts/verifier_ports.py`, `make ports-verifier` | **P33** |
| **D-70** | La documentation **dit et explique tout ce que le dépôt fait** — et l'exigence est **outillée**, pas seulement énoncée | une exigence qu'on n'outille pas pourrit en silence : la carte annonçait « 28 décisions » quand il y en avait 66, et disait les accès « non construits » alors qu'ils tournaient en production. **P31** garde le couvert — chaque script s'explique et reste atteignable, chaque cible `make` porte son aide (sauf les internes préfixées `_`), chaque rôle a son README. Elle ne garde **pas** la qualité du « pourquoi » : ça se juge en revue, et ça vit dans `CHANGELOG.md` et ici | `devis-services.md`, `CHANGELOG.md` | **P31** | | **D-70** | La documentation **dit et explique tout ce que le dépôt fait** — et l'exigence est **outillée**, pas seulement énoncée | une exigence qu'on n'outille pas pourrit en silence : la carte annonçait « 28 décisions » quand il y en avait 66, et disait les accès « non construits » alors qu'ils tournaient en production. **P31** garde le couvert — chaque script s'explique et reste atteignable, chaque cible `make` porte son aide (sauf les internes préfixées `_`), chaque rôle a son README. Elle ne garde **pas** la qualité du « pourquoi » : ça se juge en revue, et ça vit dans `CHANGELOG.md` et ici | `devis-services.md`, `CHANGELOG.md` | **P31** |
| **D-71** | **Une PKI et un DNS fonctionnels avant toute chose** ; puis, par VM : socle → enrôlement PKI → enregistrement DNS (A **et** PTR) | `deployer-tout` déroule par COUCHES — correct, mais chaque VM réclame alors un certificat à une autorité pas encore debout, et l'échec se lit comme un défaut du rôle et non d'ordre. Les deux hôtes d'amorçage se **dérivent** de `applications.<step_ca\|powerdns>.hote` : déplacer l'autorité déplace l'amorçage. **Deux exceptions structurelles assumées** — l'AC s'auto-signe, le DNS pose son propre enregistrement | `Makefile` `_amorcer-socle`, `scripts/socle_amorcage.py` | — | | **D-71** | **Une PKI et un DNS fonctionnels avant toute chose** ; puis, par VM : socle → enrôlement PKI → enregistrement DNS (A **et** PTR) | `deployer-tout` déroule par COUCHES — correct, mais chaque VM réclame alors un certificat à une autorité pas encore debout, et l'échec se lit comme un défaut du rôle et non d'ordre. Les deux hôtes d'amorçage se **dérivent** de `applications.<step_ca\|powerdns>.hote` : déplacer l'autorité déplace l'amorçage. **Deux exceptions structurelles assumées** — l'AC s'auto-signe, le DNS pose son propre enregistrement | `Makefile` `_amorcer-socle`, `scripts/socle_amorcage.py` | — |

View file

@ -56,3 +56,24 @@ non activé* tant que le registre n'est pas complet et validé.
2. le **résolveur** (agrégation → règles + registre) ; 2. le **résolveur** (agrégation → règles + registre) ;
3. **remplir** tous les rôles (large transcription du travail zéro-confiance déjà fait) ; 3. **remplir** tous les rôles (large transcription du travail zéro-confiance déjà fait) ;
4. **générer** + registre d'audit ; puis **activer** nftables nœud par nœud. 4. **générer** + registre d'audit ; puis **activer** nftables nœud par nœud.
## `partage:` — décrire une écoute plutôt que l'ouvrir
Ajouté le 2026-08-09 avec **P33**, qui refuse que deux rôles co-localisés revendiquent le
même port.
Le registre confondait deux situations :
| Situation | Exemple | `partage` |
|---|---|---|
| le rôle **ouvre** l'écoute | `serveur_dovecot` lie 12345 (SASL réseau) | absent |
| le rôle **décrit** celle d'un autre | `serveur_backup` emprunte le sshd de `serveur_debian` | `true` |
Sans cette distinction, la seule co-location légitime de la flotte — `tcp/22` sur
`backup-01` — serait signalée à tort. Une preuve qui crie sur un cas sain finit par être
ignorée, ce qui est pire que de ne pas l'avoir.
**Corollaire à retenir** : un port qu'on **subit** (le défaut amont d'un logiciel) doit être
imposé et déclaré comme les autres. Celui d'Alloy ne l'était pas, et c'est la seule raison
pour laquelle la collision a pu durer des semaines.

View file

@ -6,4 +6,9 @@ flux:
protocole: tcp protocole: tcp
pair: client_backup pair: client_backup
chiffrement: ssh chiffrement: ssh
# Ce role N'OUVRE PAS ce port : il decrit l'ecoute de `serveur_debian`, qu'il
# emprunte avec un utilisateur restreint. Sans ce mot, P33 y verrait une collision
# avec le SSH d'administration sur `backup-01` — deux roles co-localises revendiquant
# le 22 — alors qu'il n'y a qu'un seul sshd.
partage: true
raison: "Dépôt restic servi par SSH (utilisateur restreint restic + clé) ; chaque client_backup pousse ses instantanés." raison: "Dépôt restic servi par SSH (utilisateur restreint restic + clé) ; chaque client_backup pousse ses instantanés."

View file

@ -573,6 +573,8 @@ PREUVES: list[dict] = [
"func": preuve_documentation_outillage}, "func": preuve_documentation_outillage},
{"id": "P32", "titre": "Intrants exiges par les roles : tous fournis", "refs": [], {"id": "P32", "titre": "Intrants exiges par les roles : tous fournis", "refs": [],
"cmds": [[sys.executable, "scripts/verifier_intrants.py"]]}, "cmds": [[sys.executable, "scripts/verifier_intrants.py"]]},
{"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],
"cmds": [[sys.executable, "scripts/verifier_ports.py"]]},
] ]

112
scripts/verifier_ports.py Executable file
View file

@ -0,0 +1,112 @@
#!/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())