diff --git a/CHANGELOG.md b/CHANGELOG.md index d4ee5c6..03201c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # CHANGELOG — Set-OPS +## 2026-08-25 — P43 : la frontière voit-elle les machines du site ? + +**43 preuves.** Celle-ci couvre ce qui a failli coûter 36 objets ce soir : le devis de la +frontière avait cessé de voir le site, et proposait de retirer tous ses alias et toutes +ses règles. La frontière aurait laissé tomber la forge du génome, le cache racine et le +runner du site, d'un seul `CONFIRMER=true`. + +Rien ne l'avait signalé — harnais vert, `ansible-lint` vert. Seule la lecture manuelle du +plan avant application l'a attrapé. + +### La première version était inutile, et c'est instructif + +Elle lisait le plan par `devis_opnsense._machines_du_plan_site()` — **la fonction même +dont la panne était à détecter**. Éprouvée sur la régression réelle, elle ne criait pas : +elle **se taisait**. Les deux voyaient le vide, et la preuve concluait « rien à prouver ». + +> Une preuve qui partage la source de ce qu'elle vérifie ne vérifie rien. + +C'est la même erreur d'instrument qui a coûté quatre faux diagnostics cette session : le +VPN pris pour la frontière, `ping` pour du TCP, l'`overview` d'OPNsense pour ses +assignations. Ici elle était logée dans la preuve elle-même. + +La version retenue lit `plan/serveurs.yml` et `plan/applications.yml` **directement**, et +confronte au devis produit. Éprouvée sur la régression réelle : elle refuse, et nomme la +cause — *« `SETOPS_SITE` est absent ou vide, alors que le plan déclare 5 machines »*. + ## 2026-08-25 — Le site résout chez lui, et le socle cesse de le défaire **Mise au point de l'exploitant** : la frontière OPNsense n'a pas de service DNS actif et diff --git a/docs/audit/preuve-2026-08-25.md b/docs/audit/preuve-2026-08-25.md index 215c4f1..c115fda 100644 --- a/docs/audit/preuve-2026-08-25.md +++ b/docs/audit/preuve-2026-08-25.md @@ -7,7 +7,7 @@ > [`docs/audit/affirmations.md`](affirmations.md). - **Instance** : `instance` — inventaire `instance/inventories/production/hosts.yml` -- **Verdict** : ✅ CONFORME (42 OK · 0 echec · 0 saute) +- **Verdict** : ✅ CONFORME (43 OK · 0 echec · 0 saute) ## Preuves @@ -55,6 +55,7 @@ | P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. | | P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 45 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). | | P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product | +| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 5 machine(s) du plan retrouvees, 21 regle(s) du site. | ## Couverture des affirmations ✅ du registre diff --git a/scripts/prouver.py b/scripts/prouver.py index d1aeec5..ee4913d 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -928,6 +928,91 @@ def preuve_parente_inscrite() -> tuple[bool, str]: return r.returncode == 0, (sortie[-1] if sortie else "") +def preuve_devis_frontiere_du_site() -> tuple[bool, str]: + """Le devis de la frontiere VOIT les machines du site — lues independamment. + + POURQUOI CETTE PREUVE EXISTE (2026-08-25). En deplacant le plan du site hors de + `underlay.yml`, `underlay.machines()` s'est vide sans que `devis_opnsense` soit + rebranche. Le devis a cesse de voir le site et a propose de RETIRER 36 objets — tous + ses alias et toutes ses regles. La frontiere aurait laisse tomber la forge du genome, + le cache racine et le runner du site, d'un seul `CONFIRMER=true`. + + Rien ne l'a signale : le harnais est reste vert, `ansible-lint` aussi. Seule la + lecture MANUELLE du plan avant application l'a attrape. + + ELLE LIT LE PLAN ELLE-MEME, ET C'EST TOUT SON INTERET. Un premier jet passait par + `devis_opnsense._machines_du_plan_site()` — la fonction meme dont la panne etait a + detecter. Eprouve sur la regression reelle, il SE TAISAIT : les deux voyaient le vide, + et la preuve concluait « rien a prouver ». Une preuve qui partage la source de ce + qu'elle verifie ne verifie rien. + + Elle se tait quand il n'y a pas de plan de site — un depot sans underlay monte n'a + rien a prouver ici — mais jamais quand le plan existe et que le devis l'ignore. + """ + import ipaddress + sys.path.insert(0, str(RACINE / "scripts")) + import underlay as underlay_mod + import devis_opnsense as D + import devis_reseau + + # --- LE TEMOIN : le plan, lu directement, sans passer par le devis --------- + chemin = underlay_mod.chemin() + plan = (chemin.resolve().parent / "plan") if chemin else None + f_srv = (plan / "serveurs.yml") if plan else None + if not f_srv or not f_srv.is_file(): + return True, "Aucun plan de site (pas de `plan/serveurs.yml`) : rien a exiger." + serveurs = (yaml.safe_load(f_srv.read_text(encoding="utf-8")) or {}).get("serveurs") or {} + attendus = {n: s for n, s in serveurs.items() + if str(s.get("etat", "actif")) == "actif"} + if not attendus: + return True, "Plan de site sans machine active : rien a exiger." + + f_app = plan / "applications.yml" + apps = ((yaml.safe_load(f_app.read_text(encoding="utf-8")) or {}).get("applications") or {} + ) if f_app.is_file() else {} + roles_par_hote: dict[str, list[str]] = {} + for app in apps.values(): + if app.get("hote") and app.get("groupe"): + roles_par_hote.setdefault(str(app["hote"]), []).append(str(app["groupe"])) + + # --- CE QUE LE DEVIS PRODUIT, au meme perimetre que `make frontiere-plan` -- + devis = D.construire(devis_reseau.decouvrir_du_site()) + alias = devis.get("alias") or {} + regles = devis.get("regles") or [] + manques: list[str] = [] + + reseaux = [str(x) for x in ((alias.get("SETOPS_SITE") or {}).get("contenu") or [])] + if not reseaux: + manques.append(f"`SETOPS_SITE` est absent ou vide, alors que le plan declare " + f"{len(attendus)} machine(s) : le devis ne voit pas le site") + else: + for nom, srv in attendus.items(): + if not any(ipaddress.ip_address(str(srv["ip"])) in + ipaddress.ip_network(r, strict=False) for r in reseaux): + manques.append(f"{nom} ({srv['ip']}) n'est dans aucun sous-reseau de " + f"`SETOPS_SITE`") + + for hote, groupes in roles_par_hote.items(): + if hote not in attendus: + continue + for g in groupes: + attendu = D.nom_alias(g, "SITE") + if attendu not in alias: + manques.append(f"{hote} porte `{g}` au plan, mais l'alias `{attendu}` " + f"n'est pas au devis") + + n_site = sum(1 for r in regles if str(r.get("tenant")) == "SITE") + if not n_site: + manques.append("aucune regle du devis ne porte sur le site : la frontiere ne " + "laisserait rien passer vers ses machines") + + if manques: + return False, ("Le devis de la frontiere ne voit pas le site :\n - " + + "\n - ".join(manques)) + return True, (f"Devis de la frontiere : {len(attendus)} machine(s) du plan retrouvees, " + f"{n_site} regle(s) du site.") + + def preuve_glossaire_enseigne() -> tuple[bool, str]: """Tout mot que le depot emploie devant l'exploitant est explique au glossaire. @@ -1266,6 +1351,8 @@ PREUVES: list[dict] = [ "func": preuve_resolution_unique}, {"id": "P42", "titre": "L'edge porte les noms qu'il publie", "refs": [], "func": preuve_edge_porte_ses_noms}, + {"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [], + "func": preuve_devis_frontiere_du_site}, {"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [], "cmds": [[sys.executable, "scripts/verifier_ports.py"]]}, ]