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:
parent
d52f256366
commit
28e0c45696
9 changed files with 177 additions and 3 deletions
29
CHANGELOG.md
29
CHANGELOG.md
|
|
@ -1,5 +1,34 @@
|
|||
# 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
|
||||
|
||||
Le premier des trois chantiers que la reconstruction avait rendus évidents. Il aurait
|
||||
|
|
|
|||
3
Makefile
3
Makefile
|
|
@ -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
|
||||
@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)
|
||||
@python3 scripts/verifier_intrants.py
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
> [`docs/audit/affirmations.md`](affirmations.md).
|
||||
|
||||
- **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
|
||||
|
||||
|
|
@ -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. |
|
||||
| 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. |
|
||||
| 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). |
|
||||
| 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`) |
|
||||
| **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` |
|
||||
| **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 |
|
||||
| **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` |
|
||||
|
|
|
|||
|
|
@ -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-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-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-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` | — |
|
||||
|
||||
|
|
|
|||
|
|
@ -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) ;
|
||||
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.
|
||||
|
||||
## `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.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,4 +6,9 @@ flux:
|
|||
protocole: tcp
|
||||
pair: client_backup
|
||||
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."
|
||||
|
|
|
|||
|
|
@ -573,6 +573,8 @@ PREUVES: list[dict] = [
|
|||
"func": preuve_documentation_outillage},
|
||||
{"id": "P32", "titre": "Intrants exiges par les roles : tous fournis", "refs": [],
|
||||
"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
112
scripts/verifier_ports.py
Executable 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())
|
||||
Loading…
Reference in a new issue