#!/usr/bin/env python3 """Harnais de preuve Set-OPS : rejoue les preuves automatisables du registre. `make prouver` enchaine les commandes de preuve du registre des affirmations (`docs/audit/affirmations.md`) et produit un rapport horodate `docs/audit/preuve-AAAA-MM-JJ.md` — rejouable, presentable comme piece justificative. Ce script est un ORCHESTRATEUR MINCE : il ne reimplemente AUCUNE validation. Il appelle l'outillage existant (les memes `scripts/*.py`, `ansible-lint`, `make syntaxe` que `make verifier`) et met en forme les resultats. Ce n'est pas un framework de test parallele : la logique de validation vit dans les scripts appeles. Sortie : 0 si aucune preuve automatisable n'echoue (les preuves SAUTEES — ex. voute requise absente — ne sont pas des echecs) ; 1 sinon. Usage : python3 scripts/prouver.py # execute + ecrit docs/audit/preuve-.md python3 scripts/prouver.py --verifier # preuves seules (verdict + code sortie), sans rapport """ from __future__ import annotations import datetime as _dt import ast import json import re import unicodedata import os import subprocess import sys from pathlib import Path import yaml RACINE = Path(__file__).resolve().parents[1] sys.path.insert(0, str(RACINE / "scripts")) from inventory_rules import instance_courante, plan_de # noqa: E402 INSTANCE = instance_courante() DOSSIER_AUDIT = RACINE / "docs" / "audit" GROUPES = "playbooks/groupes" DEPENDANCES = "docs/dependances-groupes.yml" def _inventaire() -> str: """Inventaire de l'instance : premier existant (principal > production > lab).""" for nom in ("principal", "production", "lab"): p = INSTANCE / "inventories" / nom / "hosts.yml" if (RACINE / p).exists(): return str(p) return str(INSTANCE / "inventories" / "principal" / "hosts.yml") INV = _inventaire() # --- Preuves natives (petites verifications d'invariants, pas de logique metier) --- def preuve_handlers() -> tuple[bool, str]: """Tout `notify` d'un role pointe vers un handler du meme role (AGENTS.md).""" import glob import yaml problemes: list[str] = [] roles_dir = RACINE / "roles" for role in sorted(p.name for p in roles_dir.iterdir() if p.is_dir()): rp = roles_dir / role handlers: set[str] = set() for hf in glob.glob(str(rp / "handlers" / "*.yml")): data = yaml.safe_load(Path(hf).read_text(encoding="utf-8")) or [] def collect(tasks): for t in tasks or []: if not isinstance(t, dict): continue if "name" in t: handlers.add(t["name"]) if "listen" in t: listen = t["listen"] handlers.update(listen if isinstance(listen, list) else [listen]) for k in ("block", "rescue", "always"): if k in t: collect(t[k]) collect(data) notifies: set[str] = set() for tf in glob.glob(str(rp / "tasks" / "*.yml")): data = yaml.safe_load(Path(tf).read_text(encoding="utf-8")) or [] def scan(tasks): for t in tasks or []: if not isinstance(t, dict): continue if "notify" in t: n = t["notify"] notifies.update(n if isinstance(n, list) else [n]) for k in ("block", "rescue", "always"): if k in t: scan(t[k]) scan(data) manquants = notifies - handlers if manquants: problemes.append(f"{role}: notify sans handler -> {sorted(manquants)}") if problemes: return False, "; ".join(problemes) return True, "Tout notify pointe vers un handler du meme role (49 roles)." def preuve_runbooks() -> tuple[bool, str]: """Les runbooks / registres cites par les surfaces publiques existent.""" attendus = [ "docs/plan-et-generation.md", "docs/catalogue-services.md", "docs/dependances-groupes.yml", "docs/dns-interne.md", "docs/nomenclature-vm.md", "docs/config-proxmox.md", "docs/vm-lifecycle.md", "docs/procedure-template-debian13-proxmox.md", "docs/positionnement.md", "docs/bindings-conception.md", "docs/flux-conception.md", "docs/couches-deploiement.yml", "docs/registre-flux.md", "docs/carte-set-ops.md", "docs/runbooks-exploitation.md", "exemples/vault.exemple.yml", "exemples/instance.exemple.yml", ] manquants = [f for f in attendus if not (RACINE / f).exists()] if manquants: return False, "manquants : " + ", ".join(manquants) return True, f"{len(attendus)}/{len(attendus)} runbooks/registres cites presents." def preuve_structurels() -> tuple[bool, str]: """Invariants de structure/doctrine verifiables par le contenu du depot.""" echecs: list[str] = [] def lire(rel: str) -> str: p = RACINE / rel return p.read_text(encoding="utf-8") if p.exists() else "" # AFF-015 : LICENSE present if not (RACINE / "LICENSE").exists(): echecs.append("LICENSE absent") # AFF-022 : modele socle en forme dossier (intrants charges, pas de shadowing) if not (RACINE / "exemples/modeles/socle/inventories/production/group_vars/all/10-intrants.yml").exists(): echecs.append("socle: all/10-intrants.yml absent") if (RACINE / "exemples/modeles/socle/inventories/production/group_vars/all.yml").exists(): echecs.append("socle: all.yml (forme fichier) masquerait all/") # AFF-038 : pas de playbooks de couches paralleles for couche in ("socle", "durcissement"): if (RACINE / "playbooks" / couche).exists(): echecs.append(f"playbooks/{couche}/ ne doit pas exister") # AFF-037 : SSH clef-only des la construction if 'ssh_baseline_password_authentication: "no"' not in lire("roles/ssh_baseline/defaults/main.yml"): echecs.append("ssh_baseline: PasswordAuthentication != no") if "AuthenticationMethods publickey" not in lire("roles/ssh_baseline/templates/10-setops.conf.j2"): echecs.append("ssh_baseline: AuthenticationMethods publickey absent") # AFF-062 : nftables installe mais desactive par defaut if "nftables_baseline_enabled: false" not in lire("roles/nftables_baseline/defaults/main.yml"): echecs.append("nftables_baseline_enabled != false") if echecs: return False, "; ".join(echecs) return True, "LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK." def preuve_modele_socle() -> tuple[bool, str]: """Le modele public `socle` valide (registres) — garde contre une regression d'exemple.""" socle = "exemples/modeles/socle" # Meme retrait qu'en P03 : les validateurs lisent SETOPS_INVENTAIRE, qui gagnerait # sur le socle et ferait valider le modele public contre l'inventaire du tenant actif. env = {**os.environ, "SETOPS_INSTANCE": socle} env.pop("SETOPS_INVENTAIRE", None) for s in ("domaines", "serveurs", "applications", "bases_donnees"): r = subprocess.run([sys.executable, f"scripts/{s}.py", "verifier"], cwd=RACINE, env=env, capture_output=True, text=True) if r.returncode != 0: sortie = (r.stdout + r.stderr).strip().splitlines() return False, f"{s}: {sortie[-1] if sortie else 'rc!=0'}" return True, "Modele public socle : domaines/serveurs/applications/bases valides." PORTEES_AUTH = {"web-sso", "socle-identite", "ldap-direct", "interne-sans-auth", "sans-auth-humaine"} # LES MECANISMES QUI PORTENT REELLEMENT UNE HABILITATION (meta/acces.yml, champ # `porte_par`). Le champ existe pour rendre visible, DANS LA DECLARATION, ce qui n'est # pas cable : sans lui, « le groupe sysadmin donne Admin » se lit comme un fait alors que # c'est parfois un souhait. Un mecanisme hors de ce jeu est donc refuse — inventer un nom # de transport, c'est se donner l'air d'avoir cable quelque chose. MECANISMES_ACCES = { "defaut", # tout utilisateur authentifie l'obtient, sans rien porter "claim-groupe", # le claim `groups` du jeton, lu par l'application "role-realm", # un role de realm Keycloak, projete depuis un groupe LDAP "role-client", # un role d'un client OIDC (ex. realm-management) "groupe-annuaire", # l'application lit l'annuaire directement "group-ldap-mapper", # le mapper Keycloak qui projette un groupe en role } NOMBRES_FR = {"deux": 2, "trois": 3, "quatre": 4, "cinq": 5, "six": 6, "sept": 7, "huit": 8, "neuf": 9, "dix": 10, "onze": 11, "douze": 12, "treize": 13, "quatorze": 14, "quinze": 15, "seize": 16} def _enumeration_apres(lignes: list[str], i: int) -> tuple[str | None, int]: """Le bloc qui suit la ligne `i` est-il une table ou une liste, et de combien ?""" j = i + 1 while j < len(lignes) and not lignes[j].strip(): j += 1 if j >= len(lignes): return None, 0 s = lignes[j].lstrip() if s.startswith("|"): k = j + 2 if j + 1 < len(lignes) and set(lignes[j + 1].strip()) <= set("|-: ") else j n = 0 while k < len(lignes) and lignes[k].lstrip().startswith("|"): n += 1 k += 1 return "table", n if re.match(r"^([-*+]|\d+\.)\s", s): n, k = 0, j while k < len(lignes): u = lignes[k].lstrip() if re.match(r"^([-*+]|\d+\.)\s", u): n += 1 elif not lignes[k].strip() or lignes[k][:1] in " \t": pass else: break k += 1 return "liste", n return None, 0 def _lignes_premiere_table(texte: str) -> int: """Lignes de DONNEES de la PREMIERE table markdown d'un texte. « Premiere », et c'est une correction de l'instrument, pas de la cible : le §7 de `preparer-un-site-hebergeur.md` porte DEUX tables. En les additionnant, la garde annoncait un ecart la ou il n'y en avait pas — elle accusait le document de ce qu'elle mesurait mal. """ n, dans = 0, False for l in texte.splitlines(): s = l.lstrip() if s.startswith("|"): if not dans: dans = True # l'en-tete continue if set(s) <= set("|-: "): continue # le separateur n += 1 elif dans: break return n def preuve_schema_du_plan() -> tuple[bool, str]: """Le schema des registres decrit tout ce que les plans reels contiennent. CE QU'IL REMPLACE (2026-09-08). Le GUI portait `CHAMPS_ECRITS_PAR_GUI`, une liste tenue A LA MAIN de ce qu'il savait ecrire, et P19 la confrontait au reel. C'etait une copie — gardee, donc honnete, mais une copie : quelqu'un devait penser a l'allonger. Le schema la remplace comme SOURCE : les formulaires en derivent, et cette preuve verifie que le schema, lui, n'a rien oublie. DEUX SENS, ET LE SECOND EST LE MOINS EVIDENT : 1. tout champ present dans un plan reel (instance courante ET tous les modeles) est DECRIT par le schema — sinon l'operateur devra editer le YAML a la main, et le principe 10 se dement en silence ; 2. tout champ decrit est REELLEMENT admis quelque part — un champ inventé dans le schema ferait apparaitre a l'ecran une case que rien ne consomme. Le second sens n'est PAS une erreur en soi : `mail`, `coeurs`, `noeud` et `stockage` sont legitimes et simplement inutilises dans les plans d'aujourd'hui. On les COMPTE donc, sans refuser — la meme mesure que les lacunes nommees de P29. Refuser obligerait a retirer du schema un champ valide des que plus personne ne s'en sert. Et le fichier genere doit etre a jour : un schema perime ferait generer des formulaires d'hier. """ import subprocess as _sp r = _sp.run([sys.executable, "scripts/schema_plan.py", "--verifier"], capture_output=True, text=True, cwd=str(RACINE)) if r.returncode != 0: return False, (r.stderr or r.stdout).strip() import json as _json schema = _json.loads((RACINE / "docs" / "audit" / "schema-plan.json").read_text(encoding="utf-8")) def _noms(props: dict) -> set: """Tous les champs decrits, TABLES IMBRIQUEES COMPRISES. `couverture_gui.champs_utilises()` aplatit : il descend dans `nomenclature.fonctions.*` et rend `categorie` et `service` comme s'ils etaient des champs de la nomenclature. Comparer a plat d'un cote et en arbre de l'autre faisait crier la preuve sur un schema correct — l'instrument mesurait autre chose que la cible. On aplatit donc des deux cotes. """ vus = set() for nom, p in (props or {}).items(): vus.add(nom) # Trois imbrications possibles : table a clefs libres (`additionalProperties`), # bloc a clefs fixes (`properties`), liste d'objets (`items.properties`). for sous in (p.get("additionalProperties"), p.get("items")): if isinstance(sous, dict): vus |= _noms(sous.get("properties")) if isinstance(p.get("properties"), dict): vus |= _noms(p["properties"]) return vus decrit = {nom: _noms(reg["entite"]["properties"]) for nom, reg in schema["registres"].items()} sys.path.insert(0, str(RACINE / "scripts")) import couverture_gui as _cg observes = {nom: set(champs) for nom, champs in _cg.champs_utilises().items()} # TROISIEME SENS, ajoute le 2026-09-08 : la FORME, pas seulement le nom. # # `reservations` etait decrit comme une TABLE de zones, chacune portant `passerelle`. # Le fichier reel est un bloc PLAT, et `underlay.py` lit `reservations.passerelle` # a plat. Les deux sens ci-dessus n'y voyaient rien : ils aplatissent les noms des # deux cotes, et `passerelle` existe bien de part et d'autre — a des profondeurs # differentes. Un formulaire genere depuis cette description aurait offert un bouton # « ajouter une zone » et ecrit dans le plan une forme que le moteur ne lit pas. # # Un nom commun ne dit donc rien de la structure. On compare desormais trois formes : # scalaire, objet a clefs fixes, table a clefs libres. La lecture des plans se derive # de `x-fichier` / `x-racine` du schema lui-meme — pas d'une seconde liste a tenir. def _forme_declaree(spec: dict) -> str: if isinstance(spec.get("additionalProperties"), dict): return "table" if spec.get("type") == "object": return "objet" return "scalaire" def _forme_observee(valeur): if isinstance(valeur, dict): if not valeur: return None # un dictionnaire vide ne tranche pas entre objet et table return "table" if all(isinstance(x, dict) for x in valeur.values()) else "objet" return "scalaire" # `plan_de()` et non `RACINE / "instance" / "plan"` : P41 refuse une dixieme copie de # la resolution d'instance, et elle a refuse celle-ci en premier. plans = [plan_de()] + sorted((RACINE / "exemples" / "modeles").glob("*/plan")) formes = [] for plan in plans: for nom, reg in schema["registres"].items(): fichier = plan / Path(reg["x-fichier"]).name if not fichier.is_file(): continue try: data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {} except yaml.YAMLError: continue racine = reg["x-racine"] # `x-racine: null` = le document EST l'entite (la nomenclature). entites = list((data.get(racine) or {}).values()) if racine else [data] props = reg["entite"]["properties"] for entite in entites: if not isinstance(entite, dict): continue for champ, valeur in entite.items(): if champ not in props: continue vue = _forme_observee(valeur) dite = _forme_declaree(props[champ]) if vue and vue != dite: formes.append(f"{nom}.{champ} : le plan en fait un {vue}, " f"le schema un {dite} ({plan.parent.name})") trous, inutilises = [], [] for nom, champs in observes.items(): manquants = sorted(champs - decrit.get(nom, set())) if manquants: trous.append(f"{nom} : {', '.join(manquants)} present(s) au plan, absent(s) du schema") for nom, champs in decrit.items(): jamais = sorted(champs - observes.get(nom, set())) if jamais: inutilises.append(f"{nom}:{len(jamais)}") if formes: return False, ("Le schema decrit une FORME que les plans n'ont pas :\n - " + "\n - ".join(sorted(set(formes))) + "\n (corriger REGISTRES dans scripts/schema_plan.py — `entree` " "pour une table a clefs libres, `sous_champs` pour un bloc fixe — " "puis `make schema`)") if trous: return False, ("Le schema ne decrit pas tout ce que les plans contiennent :\n - " + "\n - ".join(trous) + "\n (ajouter le champ a REGISTRES dans scripts/schema_plan.py, " "puis `make schema`)") n = sum(len(c) for c in decrit.values()) suffixe = (f" ; {', '.join(inutilises)} champ(s) decrits et inutilises dans les plans " f"d'aujourd'hui (legitime)") if inutilises else "" return True, (f"Le schema decrit {n} champ(s) sur {len(decrit)} registres ; il couvre " f"tout ce que les plans reels contiennent, et la FORME de chaque champ " f"(scalaire / objet / table) correspond dans {len(plans)} plan(s){suffixe}.") def preuve_schema_couvre_les_validateurs() -> tuple[bool, str]: """Le schema decrit-il tout ce que le MOTEUR accepte — pas seulement ce que les plans ont ? LA LACUNE QUE CETTE PREUVE COMBLE (2026-09-08). P61 confronte le schema aux plans REELS : tout champ present quelque part doit etre decrit. C'est necessaire, et ca ne suffit pas. Un champ que les validateurs acceptent mais qu'aucun plan n'emploie ENCORE passe entre les mailles — et le formulaire genere depuis le schema ne saura jamais l'offrir. L'operateur devra rouvrir le YAML pour une fonctionnalite que le moteur possede deja. DEUX CAS MESURES, LE JOUR OU CETTE PREUVE A ETE ECRITE : - `valider_domaines` valide entierement `exposition` (une liste de `{nom, cible, type}`) et le schema l'ignorait. Aucun plan ne s'en sert : P61 etait au vert. - `applications.liens` etait decrit `items: {type: object}` — une liste d'objets sans forme. Le moteur, lui, exige `vers` et `role`. Un formulaire genere aurait offert « ajouter un lien » sans savoir quelles cases y mettre. COMMENT ON SEPARE L'ENTITE DU RESTE. Un validateur lit deux choses : l'entite qu'il valide, et d'AUTRES registres passes en parametres pour la coherence croisee (`valider_serveurs` lit `nomenclature.get("fonctions")`). Les champs de l'entite sont donc les acces dont le destinataire est une variable LOCALE — jamais un parametre. C'est ce qui distingue `srv.get("fonction")` de `nomenclature.get("fonctions")`, et c'est mecanique : aucune liste a tenir. CE QU'ELLE NE FAIT PAS. Elle ne verifie pas l'inverse (un champ decrit qu'aucun validateur ne lit) : `usage`, `websocket` ou `dnssec` sont consommes par les roles et les gabarits, pas par les validateurs. Exiger la reciproque ferait crier la preuve sur des champs parfaitement legitimes. """ import json as _json schema = _json.loads((RACINE / "docs" / "audit" / "schema-plan.json").read_text(encoding="utf-8")) def _noms(props: dict) -> set: vus = set() for nom, p in (props or {}).items(): vus.add(nom) for sous in (p.get("additionalProperties"), p.get("items")): if isinstance(sous, dict): vus |= _noms(sous.get("properties")) if isinstance(p.get("properties"), dict): vus |= _noms(p["properties"]) return vus decrit = {nom: _noms(reg["entite"]["properties"]) for nom, reg in schema["registres"].items()} racines = {reg["x-racine"] for reg in schema["registres"].values()} | set(schema["registres"]) VALIDATEURS = { "domaines_publics": "valider_domaines", "serveurs": "valider_serveurs", "bases_donnees": "valider_bases", "applications": "valider_applications", } source = (RACINE / "scripts" / "inventory_rules.py").read_text(encoding="utf-8") arbre = ast.parse(source) fonctions = {n.name: n for n in ast.walk(arbre) if isinstance(n, ast.FunctionDef)} trous, mesures = [], [] for registre, nom_fn in sorted(VALIDATEURS.items()): fn = fonctions.get(nom_fn) if fn is None: trous.append(f"{registre} : validateur `{nom_fn}` introuvable — la preuve ne mesure plus rien") continue parametres = {a.arg for a in fn.args.args} | {a.arg for a in fn.args.kwonlyargs} champs = set() for n in ast.walk(fn): cible = None if (isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and n.func.attr == "get" and n.args and isinstance(n.args[0], ast.Constant) and isinstance(n.args[0].value, str)): cible, champ = n.func.value, n.args[0].value elif (isinstance(n, ast.Subscript) and isinstance(n.slice, ast.Constant) and isinstance(n.slice.value, str)): cible, champ = n.value, n.slice.value if cible is None: continue # Seuls les acces sur une variable LOCALE decrivent l'entite validee. if not isinstance(cible, ast.Name) or cible.id in parametres: continue champs.add(champ) manquants = sorted(champs - decrit.get(registre, set()) - racines) mesures.append(f"{registre}:{len(champs)}") if manquants: trous.append(f"{registre} : `{nom_fn}` accepte {', '.join(manquants)}, " f"que le schema ne decrit pas") if trous: return False, ("Le moteur accepte des champs que le schema ignore — le formulaire " "genere ne pourra pas les offrir :\n - " + "\n - ".join(trous) + "\n (ajouter le champ a REGISTRES dans scripts/schema_plan.py, " "puis `make schema`)") return True, (f"Les {len(VALIDATEURS)} validateurs n'acceptent aucun champ que le schema " f"ignore ({', '.join(mesures)} champ(s) lus par validateur).") def preuve_wiki_publie_a_jour() -> tuple[bool, str]: """Le wiki publie sur la forge correspond a `wiki/` dans le depot. LA LACUNE QUE CETTE PREUVE COMBLE (2026-09-07). Le wiki est publie DEPUIS le depot : « on lit la-bas, on ecrit ici ». Rien ne mesurait l'ecart entre les deux, et il s'est creuse de VINGT-SEPT JOURS sans que personne ne le voie — deux unites n'avaient jamais ete publiees, vingt et une differaient. Pour qui lit le wiki plutot que le depot, toute une revision de documentation n'existait pas. POURQUOI UN TEMOIN PLUTOT QUE LA MESURE DIRECTE. Le harnais est STATIQUE : il lit le depot, zero appel reseau. C'est ce qui le rend rejouable partout, par n'importe qui, et presentable comme piece justificative. Cloner le wiki de la forge pour comparer romprait cette propriete — la seule qui fasse qu'une preuve vaille hors de ce poste. `make wiki-publier` depose donc `docs/audit/wiki-publie.yml` : le commit du depot qui a ete publie. Cette preuve compare ce commit a l'etat de `wiki/`. CE QU'ELLE NE PROUVE PAS, ET IL FAUT LE SAVOIR AVANT DE S'Y FIER. Elle prouve que le depot n'a pas bouge depuis la derniere publication REUSSIE — pas que la forge porte bien ce contenu. Si quelqu'un edite une page dans l'interface de la forge, le temoin ne le voit pas ; c'est la publication suivante qui l'ecrase, et c'est la regle du depot. Un temoin dit ce qui est PARTI, jamais ce qui est ARRIVE. """ temoin = RACINE / "docs" / "audit" / "wiki-publie.yml" if not temoin.is_file(): return False, ("Aucun temoin de publication (docs/audit/wiki-publie.yml) : on ne " "peut pas savoir si le wiki en ligne correspond au depot. " "Publier — `make wiki-publier WIKI_REMOTE=.wiki.git` — depose " "le temoin.") import yaml as _yaml d = _yaml.safe_load(temoin.read_text(encoding="utf-8")) or {} publie = str(d.get("source", "")).strip() if not publie: return False, "docs/audit/wiki-publie.yml sans clef `source` : temoin inexploitable." def _git(*a: str) -> str: return subprocess.run(["git", "-C", str(RACINE), *a], capture_output=True, text=True).stdout.strip() if _git("cat-file", "-t", publie) != "commit": return False, (f"Le temoin designe le commit `{publie}`, que ce depot ne connait " f"pas — une histoire reecrite, ou un temoin d'un autre depot.") # Les commits qui ont touche `wiki/` APRES celui qui a ete publie. depuis = [l for l in _git("log", "--oneline", f"{publie}..HEAD", "--", "wiki/").splitlines() if l] if not depuis: return True, (f"Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis " f"`{publie}` (publie le {d.get('date', '?')}).") dernier = _git("log", "-1", "--format=%h %ad", "--date=short", "--", "wiki/") return False, (f"Le wiki en ligne est EN RETARD : {len(depuis)} commit(s) ont touche " f"`wiki/` depuis la publication de `{publie}` ({d.get('date', '?')}), " f"le dernier etant {dernier}. Ce que la forge sert n'est plus ce que le " f"depot dit. Republier : `make wiki-publier WIKI_REMOTE=.wiki.git`.") def preuve_enumerations_annoncees() -> tuple[bool, str]: """Un nombre qui annonce une enumeration correspond a ce qui suit. POURQUOI, ALORS QUE P57 GARDE DEJA DES COMPTES (2026-09-07). P57 tient trois comptes NOMMES — preuves, roles, groupes — parce qu'ils se mesurent contre le depot. Les deux ecarts trouves a la main pendant la tournee de documentation etaient d'une autre forme : un nombre qui compte ce que le document lui-meme enumere. README.md « Il y en a cinq : » au-dessus d'une table de SIX portes implanter-... « les huit lignes du §7 de … » pour une fiche qui en compte DIX CE QUE CETTE PREUVE NE FAIT PAS, ET POURQUOI ELLE SERAIT NUISIBLE AUTREMENT. Une premiere version acceptait toute ligne finissant par « : » et contenant un nombre en toutes lettres. Elle a signale CINQ ecarts sur le corpus, et les cinq etaient du bruit : dans « reprise dans les deux devis : » ou « de quatorze machines : », le nombre qualifie autre chose que l'enumeration qui suit. Cent pour cent de faux positifs — exactement la preuve qui crie sur un cas sain et qu'on apprend a ignorer. Elle ne retient donc que les DEUX formes ou le nombre ne peut compter rien d'autre : A. « Il y en a : » / « Il y a … : » suivi d'une table ou d'une liste ; B. « les lignes du § de » — un renvoi chiffre vers la premiere table de cette section-la. Une annonce enfouie au milieu d'une phrase reste hors de portee, et c'est assume : mieux vaut une garde etroite qui dit vrai qu'une large qui devine. """ docs = (sorted((RACINE / "docs").glob("*.md")) + sorted((RACINE / "wiki").glob("*.md")) + [RACINE / "AGENTS.md", RACINE / "README.md", RACINE / "QUICKSTART.md"]) mot = "|".join(NOMBRES_FR) forme_a = rf"\bil y (?:en )?a\s+(?:\*\*)?({mot})(?:\*\*)?\b[^:]{{0,20}}:\s*$" forme_b = rf"\bles\s+(?:\*\*)?({mot})(?:\*\*)?\s+lignes\s+du\s+§\s*(\d+)" ecarts: list[str] = [] vus = 0 for d in docs: if not d.is_file() or "/audit/" in str(d): continue texte = d.read_text(encoding="utf-8") lignes = texte.splitlines() for i, l in enumerate(lignes): m = re.search(forme_a, l, re.I) if not m: continue genre, n = _enumeration_apres(lignes, i) if not genre: continue vus += 1 annonce = NOMBRES_FR[m.group(1).lower()] if n != annonce: ecarts.append(f"{d.relative_to(RACINE)}:{i + 1} annonce {annonce}, " f"la {genre} qui suit en compte {n}") for m in re.finditer(forme_b, texte, re.I): cible = re.search(r"\(([^)]+\.md)\)", texte[m.end():m.end() + 220]) if not cible: continue f = d.parent / cible.group(1) if not f.is_file(): continue sec = re.search(rf"^##\s*{m.group(2)}\..*?(?=^##\s|\Z)", f.read_text(encoding="utf-8"), re.M | re.S) if not sec: continue vus += 1 annonce = NOMBRES_FR[m.group(1).lower()] n = _lignes_premiere_table(sec.group(0)) if n != annonce: ecarts.append(f"{d.relative_to(RACINE)} renvoie aux {annonce} lignes du " f"§{m.group(2)} de {cible.group(1)}, qui en compte {n}") if ecarts: return False, ("Des enumerations annoncees ne disent plus vrai :\n - " + "\n - ".join(ecarts)) return True, (f"{vus} enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent " f"(formes non ambigues seulement).") def preuve_habilitations_declarees() -> tuple[bool, str]: """Chaque service qui accorde quelque chose declare A QUI, et par quel mecanisme. LA LACUNE QUE CETTE PREUVE COMBLE (2026-09-07). `docs/autorisation.md` posait la regle — un service nomme un GROUPE, jamais une personne (D-66) — et `meta/acces.yml` la portait. Rien ne la verifiait : ni qu'un service `web-sso` en porte un, ni que le mecanisme annonce existe. Le §5 d'`authentification.md` dit pourtant l'inverse en toutes lettres : *une directive qu'aucune garde ne verifie finit par ne plus etre vraie*. P29 gardait les POSITIONS d'authentification ; personne ne gardait les HABILITATIONS. CE QU'ELLE N'EXIGE PAS, ET C'EST LE POINT LE PLUS IMPORTANT. Elle ne demande PAS que les groupes nommes existent dans l'annuaire. Ce serait contredire la decision centrale du document (§2) : le depot AMORCE un acces et se retire ; les appartenances appartiennent a une personne, et le depot n'a pas a les reconcilier. `amorcage_acces` ne cree qu'un seul groupe ; `dev` et `personnel` sont crees par l'exploitant, et leur absence du code n'est pas un defaut — c'est le regime. CE QU'ELLE EXIGE : 1. tout role `web-sso` porte un `meta/acces.yml` — SAUF `formulaire_local: aucun`, qui designe une passerelle : elle authentifie devant, elle n'accorde rien. Le cas est DERIVE de la declaration, jamais un nom en dur ; 2. chaque entree nomme `groupe`, `accorde`, `porte_par` et `raison` ; 3. `porte_par` vient de `MECANISMES_ACCES` ; 4. `groupe` ressemble a un groupe et non a un compte — ni `@`, ni `uid=`, ni le nom du compte d'amorcage lui-meme ; 5. LE CROISEMENT QUI PORTE LA PREUVE : une entree `porte_par: role-realm` dit que l'habilitation voyage par un role de realm PROJETE depuis un groupe LDAP. Ce n'est vrai que si `serveur_keycloak` projette effectivement ce groupe (`acces_projection`). Sans ce controle, un service peut annoncer une habilitation que rien ne transporte, et l'ecran restera vide sans que personne ne sache pourquoi — un chèque vert sur un perimetre vide, exactement. """ import yaml as _yaml roles = RACINE / "roles" fautes: list[str] = [] declarees: list[str] = [] def _acces(nom_role: str) -> dict: f = roles / nom_role / "meta" / "acces.yml" if not f.is_file(): return {} return _yaml.safe_load(f.read_text(encoding="utf-8")) or {} # Le compte d'amorcage : un `groupe` qui vaudrait ce nom-la designerait une PERSONNE. amorc = _yaml.safe_load( (roles / "amorcage_acces" / "defaults" / "main.yml").read_text(encoding="utf-8")) or {} compte_amorcage = str(amorc.get("amorcage_acces_uid", "")).strip() # Ce que Keycloak projette reellement : groupe LDAP -> role(s) de realm. projetes = {str(e.get("groupe", "")).strip() for e in (_acces("serveur_keycloak").get("acces_projection") or [])} for d in sorted(x for x in roles.iterdir() if x.is_dir() and x.name.startswith("serveur_")): meta = d / "meta" / "authentification.yml" if not meta.is_file(): continue decl = ((_yaml.safe_load(meta.read_text(encoding="utf-8")) or {}) .get("authentification") or {}) portee = str(decl.get("portee", "")).strip() passerelle = decl.get("formulaire_local") == "aucun" entrees = _acces(d.name).get("acces") or [] if portee == "web-sso" and not passerelle and not entrees: fautes.append(f"{d.name}: web-sso sans meta/acces.yml — il accorde quelque " f"chose a quelqu'un, et ne dit pas a qui") continue if portee == "web-sso" and passerelle and entrees: fautes.append(f"{d.name}: se declare passerelle (formulaire_local: aucun) mais " f"porte des habilitations — l'un des deux est faux") for e in entrees: groupe = str(e.get("groupe", "")).strip() ref = f"{d.name}[{groupe or '?'}]" for champ in ("groupe", "accorde", "porte_par", "raison"): if not str(e.get(champ, "")).strip(): fautes.append(f"{ref}: champ `{champ}` vide ou absent") mecanisme = str(e.get("porte_par", "")).strip() if mecanisme and mecanisme not in MECANISMES_ACCES: fautes.append(f"{ref}: mecanisme `{mecanisme}` inconnu " f"(attendus : {', '.join(sorted(MECANISMES_ACCES))})") if groupe and ("@" in groupe or "uid=" in groupe or (compte_amorcage and groupe == compte_amorcage and groupe != str(amorc.get("amorcage_acces_groupe", "")).strip())): fautes.append(f"{ref}: `{groupe}` designe une PERSONNE, pas un groupe (D-66)") if mecanisme == "role-realm" and groupe and groupe not in projetes: fautes.append(f"{ref}: annonce `role-realm`, mais serveur_keycloak ne " f"projette pas le groupe `{groupe}` (acces_projection) — " f"l'habilitation n'est portee par rien") if groupe: declarees.append(ref) if fautes: return False, ("Des habilitations ne disent pas vrai :\n - " + "\n - ".join(fautes[:6])) return True, (f"{len(declarees)} habilitation(s) declarees, toutes nommant un groupe, " f"un mecanisme connu et une raison ; les `role-realm` sont projetees.") def preuve_authentification() -> tuple[bool, str]: """Chaque role serveur_* declare sa position d'authentification, et ne ment pas. La directive (D-38..D-41) est une regle de conduite : sans garde, elle finit par ne plus etre vraie — c'est ce qui etait arrive aux 28 lignes d'integration recopiees. La declaration seule ne suffirait pas non plus : on verifie qu'elle est COHERENTE avec le code du role. Echecs : role sans declaration, portee inconnue, `web-sso` sans acces de secours, `web-sso natif` avec compte local mais sans reglage `_connexion_locale`, `web-sso natif` dont le code ne mentionne aucun OIDC, `ldap-direct` sans reference LDAP. Les portees `interne-sans-auth` sont COMPTEES, pas refusees : ce sont des lacunes assumees et nommees. Les taire les ferait oublier ; les refuser bloquerait le harnais sur une decision deja prise. """ import yaml as _yaml roles = RACINE / "roles" fautes, lacunes, par_portee = [], [], {} for d in sorted(x for x in roles.iterdir() if x.is_dir() and x.name.startswith("serveur_")): meta = d / "meta" / "authentification.yml" if not meta.is_file(): fautes.append(f"{d.name}: aucune declaration (meta/authentification.yml)") continue decl = ((_yaml.safe_load(meta.read_text(encoding="utf-8")) or {}) .get("authentification") or {}) portee = str(decl.get("portee", "")).strip() if portee not in PORTEES_AUTH: fautes.append(f"{d.name}: portee '{portee}' inconnue") continue par_portee.setdefault(portee, []).append(d.name) if not str(decl.get("raison", "")).strip(): fautes.append(f"{d.name}: portee sans raison") # Confronter la declaration au CODE. Les premieres versions cherchaient les # mots « ldap » / « oidc » dans le role : trop faible — le mot « LDAP » dans un # commentaire de serveur_grafana suffisait a valider une declaration # `ldap-direct` mensongere. On exige donc des indices NOMMES : une variable du # namespace du role, ou une URI de schema ldap. Une phrase en prose n'en cree pas. corps = "" for f in d.rglob("*"): if f.is_file() and f.suffix in (".yml", ".j2"): corps += f.read_text(encoding="utf-8", errors="ignore").lower() defauts = {} fd = d / "defaults" / "main.yml" if fd.is_file(): defauts = _yaml.safe_load(fd.read_text(encoding="utf-8")) or {} preuve_ldap = (f"{d.name}_ldap" in corps or "ldap://" in corps or "ldaps://" in corps) preuve_oidc = any(f"{d.name}_{m}" in corps for m in ("oidc", "oauth")) if portee in ("web-sso", "socle-identite") and not str(decl.get("secours", "")).strip(): fautes.append(f"{d.name}: {portee} sans acces de secours declare") if portee == "web-sso": if decl.get("mecanisme") not in ("natif", "oauth2-proxy"): fautes.append(f"{d.name}: web-sso sans mecanisme (natif|oauth2-proxy)") # `aucun` = le service n'a pas de compte local du tout (une passerelle). # Sans cette valeur, on serait tente d'ecrire « ferme » — ce qui laisserait # croire qu'une porte a ete fermee alors qu'il n'y en a jamais eu. if decl.get("formulaire_local") not in ("ferme", "masque", "ouvert", "aucun"): fautes.append(f"{d.name}: web-sso sans posture de formulaire local") if decl.get("mecanisme") == "natif" and decl.get("formulaire_local") != "aucun": # DEFINI dans defaults, pas seulement reference ailleurs : le gabarit # peut citer la variable alors que plus rien ne lui donne de valeur. if f"{d.name}_connexion_locale" not in defauts: fautes.append(f"{d.name}: web-sso natif sans reglage " f"{d.name}_connexion_locale dans defaults/main.yml") if not preuve_oidc: fautes.append(f"{d.name}: se declare web-sso natif, mais aucune " f"variable {d.name}_oidc/_oauth dans son code") if portee == "ldap-direct" and not preuve_ldap: fautes.append(f"{d.name}: se declare ldap-direct, mais aucune variable " f"{d.name}_ldap ni URI ldap:// dans son code") if portee == "interne-sans-auth": lacunes.append(d.name) # LE TABLEAU DU DOCUMENT DOIT DIRE LA MEME CHOSE QUE LES DECLARATIONS (2026-09-06). # # `docs/authentification.md` §5 recopie ces portees avec, en troisieme colonne, soit un # COMPTE, soit la LISTE des roles. Les deux derivent de ce qu'on vient de lire — et les # deux avaient cesse d'etre vrais : la table annoncait 12 roles `sans-auth-humaine`, il # y en a 21. Personne ne l'avait vu parce que rien ne les confrontait ; la preuve lisait # deja les declarations sans jamais regarder ce que le document en disait. # # On accepte les DEUX formes, parce que les deux sont legitimes : un compte quand la # liste serait illisible, la liste quand elle tient et qu'elle informe davantage. doc = RACINE / "docs" / "authentification.md" if doc.is_file(): for ligne in doc.read_text(encoding="utf-8").splitlines(): m = re.match(r"\|\s*`([a-z-]+)`\s*\|.*\|\s*([^|]+?)\s*\|\s*$", ligne) if not m or m.group(1) not in PORTEES_AUTH: continue portee, cellule = m.group(1), m.group(2) reels = sorted(n.replace("serveur_", "") for n in par_portee.get(portee, [])) if cellule.isdigit(): if int(cellule) != len(reels): fautes.append(f"docs/authentification.md : la portee '{portee}' est " f"annoncee pour {cellule} role(s), il y en a {len(reels)}") else: cites = sorted(x.strip().replace("-", "_") for x in cellule.split(",") if x.strip()) if cites != reels: fautes.append(f"docs/authentification.md : la portee '{portee}' cite " f"{cites}, les declarations disent {reels}") if fautes: return False, "; ".join(fautes[:6]) total = sum(len(v) for v in par_portee.values()) resume = ", ".join(f"{k} {len(v)}" for k, v in sorted(par_portee.items())) suffixe = f" ; {len(lacunes)} lacune(s) nommee(s) : {', '.join(lacunes)}" if lacunes else "" return True, (f"{total} role(s) serveur declares ({resume}){suffixe} ; " f"le tableau de docs/authentification.md dit la meme chose.") def preuve_propriete_des_intrants() -> tuple[bool, str]: """Aucune cle de l'HEBERGEUR ne subsiste dans un group_vars de tenant. Un cluster, une fabric et une frontiere appartiennent a l'hebergeur : recopies chez chaque tenant, ils avaient deja diverge (deux listes de stockages contradictoires pour le meme materiel). La garde attrape la rechute — un `make config` d'un autre poste, une reprise a la main — avant qu'elle ne reintroduise deux valeurs pour un seul objet. Portee : l'instance liee. Un depot sans hebergeur monte (aucun `underlay.yml`) ecrit legitimement tout au meme endroit : la preuve se saute alors. """ sys.path.insert(0, str(RACINE / "scripts")) import yaml as _yaml underlay = Path(os.environ.get("SETOPS_UNDERLAY") or (RACINE / "underlay.yml")) if not underlay.exists(): return True, "Aucun hebergeur monte (pas d'underlay.yml) : separation sans objet." hebergeur = underlay.resolve().parent instance = instance_courante() if instance.resolve() == hebergeur.resolve(): cles = {} # l'hebergeur est aussi tenant : son propre depot porte les deux else: fichier_heb = hebergeur / "proxmox-hebergeur.yml" if not fichier_heb.is_file(): return False, f"Fichier du cluster absent chez l'hebergeur : {fichier_heb}" cles = _yaml.safe_load(fichier_heb.read_text(encoding="utf-8")) or {} fautes = [] for gv in sorted((instance / "inventories").glob("*/group_vars/*.yml")): data = _yaml.safe_load(gv.read_text(encoding="utf-8")) or {} if not isinstance(data, dict): continue redites = sorted(set(data) & set(cles)) if redites: fautes.append(f"{gv.parent.name}/{gv.name}: {', '.join(redites)}") if fautes: return False, "cle(s) de l'hebergeur recopiee(s) chez le tenant — " + " ; ".join(fautes[:4]) return True, (f"{len(cles)} cle(s) de cluster chez l'hebergeur, aucune recopiee dans les " f"group_vars du tenant.") def preuve_integrations_universelles() -> tuple[bool, str]: """Aucun hote n'echappe a une integration universelle, et le plan ne la redit pas. C'est la garde de l'inversion du defaut (D-33). Elle attrape les deux rechutes possibles : un hote sans supervision/journal/certificat, et une recopie dans le plan — qui reintroduirait deux sources et donc, tot ou tard, un ecart. """ sys.path.insert(0, str(RACINE / "scripts")) from inventory_rules import (charger_applications, charger_serveurs, integrations_de, integrations_universelles) universelles = integrations_universelles(RACINE) if not universelles: return False, "Aucune integration universelle declaree : la politique a disparu." instance = instance_courante() serveurs = (charger_serveurs(instance / "plan/serveurs.yml").get("serveurs") or {}) apps = (charger_applications(instance / "plan/applications.yml").get("applications") or {}) services: dict[str, set[str]] = {} for app in apps.values(): services.setdefault(app.get("hote"), set()).add(app.get("groupe")) fautes, exemptes = [], 0 for nom, srv in serveurs.items(): rendus = services.get(nom, set()) obtenues = integrations_de(srv, rendus, universelles) for role, politique in universelles.items(): exempt = politique["sauf_role"] and politique["sauf_role"] in rendus if exempt: exemptes += 1 elif role not in obtenues: fautes.append(f"{nom} sans {role}") redites = sorted(set(srv.get("integrations") or []) & set(universelles)) if redites: fautes.append(f"{nom} redit {', '.join(redites)} (politique du role)") if fautes: return False, "; ".join(fautes[:6]) return True, (f"{len(serveurs)} hote(s) x {len(universelles)} integration(s) universelle(s) : " f"aucune lacune, aucune recopie ({exemptes} exemption(s) derivee(s) du service rendu).") def preuve_nomenclature_derivee() -> tuple[bool, str]: """Aucune nomenclature ne STOCKE d'adressage : tout se derive du seed `index`. Interdit `supernet` au niveau racine et `sous_reseau`/`passerelle`/`vlan` par zone. Attrape toute rechute vers l'ecriture manuelle de l'adressage (rupture du 2026-07-23). Portee : l'instance liee + tous les modeles decouverts (SETOPS_MODELES inclus). """ import yaml sys.path.insert(0, str(RACINE / "scripts")) import modeles as _mod cibles = [] inst_nom = RACINE / INSTANCE / "plan" / "nomenclature.yml" if inst_nom.exists(): cibles.append(inst_nom) cibles += [m / "plan" / "nomenclature.yml" for m in _mod.decouvrir() if (m / "plan" / "nomenclature.yml").exists()] interdits_zone = {"sous_reseau", "passerelle", "vlan"} fautes = [] for p in cibles: n = yaml.safe_load(p.read_text(encoding="utf-8")) or {} rel = p.relative_to(RACINE) if p.is_relative_to(RACINE) else p if "supernet" in n: fautes.append(f"{rel}: supernet stocke") if "vmid_schema" in n: fautes.append(f"{rel}: vmid_schema obsolete (mode compact retire)") for z, c in (n.get("categories") or {}).items(): for k in interdits_zone & set(c or {}): fautes.append(f"{rel}: categorie {z}.{k} stocke") if fautes: return False, f"{len(fautes)} champ(s) d'adressage stockes : " + " | ".join(fautes[:4]) return True, f"{len(cibles)} nomenclature(s) : adressage 100% derive du seed index." def preuve_pas_lab_code_en_dur() -> tuple[bool, str]: """Aucun chemin `instance/inventories/lab/group_vars` code en dur (AFF-097). On vise le vrai chemin (prefixe `instance/`) et on ignore les fichiers meta qui *parlent* de l'audit (CHANGELOG, docs/audit) sans etre des chemins d'exploitation. """ r = subprocess.run( ["git", "grep", "-n", "instance/inventories/lab/group_vars", "--", "*.md", "*.yml"], cwd=RACINE, capture_output=True, text=True, ) exclus = ("docs/audit/", "CHANGELOG.md:") lignes = [l for l in r.stdout.splitlines() if not l.startswith(exclus)] if lignes: return False, f"{len(lignes)} occurrence(s) : " + " | ".join(lignes[:3]) return True, "Aucun chemin instance/inventories/lab/group_vars code en dur." def preuve_inventaire_ansible() -> tuple[bool, str]: """`ansible-inventory --list` : l'inventaire (voute dechiffree) se parse (AFF-030).""" r = subprocess.run(["ansible-inventory", "-i", INV, "--list"], cwd=RACINE, capture_output=True, text=True, timeout=600) if r.returncode != 0: sortie = (r.stdout + r.stderr).strip().splitlines() return False, f"rc={r.returncode} : {sortie[-1] if sortie else ''}" try: data = json.loads(r.stdout) except ValueError: return True, "inventaire liste (JSON non parse)." n_hotes = len((data.get("_meta") or {}).get("hostvars") or {}) n_groupes = len([k for k in data if k not in ("_meta", "all")]) return True, f"{n_hotes} hotes, {n_groupes} groupes (inventaire dechiffre et parse)." def _valeur_effective(cle: str, role: str) -> str | None: """La valeur d'une variable pour l'instance montee : group_vars d'abord, defaut du role. Sert aux preuves qui doivent tenir compte d'un CHOIX de l'instance et non seulement de ce que le role sait faire. Lecture textuelle des group_vars — aucune voute n'est dechiffree, aucun Ansible n'est lance : ces preuves doivent rester instantanees. """ inv = INSTANCE / "inventories" if inv.is_dir(): for f in sorted(inv.glob("*/group_vars/**/*.yml")): try: d = yaml.safe_load(f.read_text(encoding="utf-8")) or {} except (OSError, yaml.YAMLError): continue if isinstance(d, dict) and cle in d: return str(d[cle]) defauts = RACINE / "roles" / role / "defaults" / "main.yml" if defauts.is_file(): d = yaml.safe_load(defauts.read_text(encoding="utf-8")) or {} if isinstance(d, dict) and cle in d: return str(d[cle]) return None def preuve_base_par_consommateur() -> tuple[bool, str]: """Toute application deployee dont le role exige une base en a une au plan. `resoudre_base` porte deja la garde (D-72) : sans entree, il refuse. Mais elle se declenche EN COURS DE DEPLOIEMENT — le 2026-08-10, a la 92e tache de `collab-01`, apres quarante minutes. L'ecart est pourtant entierement lisible dans le plan : une application declaree, un role qui reclame une base, aucune entree correspondante. Deux secondes ici valent quarante minutes la-bas. Meme raisonnement que P32 pour les intrants. RIEN N'EST CODE EN DUR, et c'est ce qui la rend juste : - les roles qui exigent une base sont ceux qui incluent `resoudre_base` ; - le GROUPE consommateur qu'ils reclament est lu dans le DEFAUT de la variable qu'ils passent — pas deduit de leur nom. `serveur_icingaweb2` reclame la base de `serveur_icinga`, pas la sienne : une preuve qui aurait suppose « role = groupe » aurait crie sur un cas parfaitement sain ; - les noms acceptables suivent la meme regle que le resolveur : le groupe, ou toute application qui declare ce groupe (les deux portees du registre). CE QU'ELLE NE TESTE PAS : que le secret nomme existe — c'est P18, sur le gabarit de voute. Ni que la base reponde : c'est `make postgresql-plan`, qui interroge le reel. """ # INSTANCE, pas le symlink en dur : sans cela cette preuve mesurait toujours # l'instance montee, quelle que soit celle qu'on visait (2026-08-22, en verifiant # patient 0 — elle a rendu un verdict juste sur le mauvais ecosysteme). plan = INSTANCE / "plan" if not (plan / "bases-donnees.yml").is_file(): return True, "Aucun registre de bases : rien a verifier." bases = (yaml.safe_load((plan / "bases-donnees.yml").read_text(encoding="utf-8")) or {}).get("bases_donnees") or {} apps = (yaml.safe_load((plan / "applications.yml").read_text(encoding="utf-8")) or {}).get("applications") or {} # Quels roles exigent une base, et QUEL groupe ils reclament. exigences: dict[str, str] = {} for tache in sorted((RACINE / "roles").glob("*/tasks/main.yml")): texte = tache.read_text(encoding="utf-8", errors="ignore") if "name: resoudre_base" not in texte: continue role = tache.parent.parent.name m = re.search(r"resoudre_base_groupe:\s*[\"']\{\{\s*([a-z0-9_]+)", texte) if not m: continue defauts = tache.parent.parent / "defaults" / "main.yml" val = (yaml.safe_load(defauts.read_text(encoding="utf-8")) or {}).get(m.group(1)) \ if defauts.is_file() else None exigences[role] = str(val or role) consommateurs = {str((b or {}).get("consommateur")) for b in bases.values()} # UN ROLE PEUT SAVOIR SE PASSER DE SERVEUR (2026-08-22). `serveur_forgejo` accepte # `serveur_forgejo_bd: sqlite` : la base devient un fichier, aucune entree de registre # n'est attendue. La convention est `_bd`, lue dans les group_vars de l'instance # et, a defaut, dans le defaut du role. Sans cette lecture, la preuve reclamerait une # base a une instance qui a justement choisi de ne pas en avoir — et on aurait appris # a l'ignorer. for role in list(exigences): val = _valeur_effective(f"{role}_bd", role) if val == "sqlite": del exigences[role] manques: list[str] = [] for nom_app, app in sorted(apps.items()): groupe = str((app or {}).get("groupe") or "") if groupe not in exigences: continue attendu = exigences[groupe] # Memes noms acceptables que le resolveur : le groupe, ou une application qui # declare ce groupe. noms = {attendu} | {a for a, v in apps.items() if str((v or {}).get("groupe") or "") == attendu} if not (noms & consommateurs): manques.append(f"{nom_app} (role {groupe}) attend une base dont le " f"`consommateur` soit l'un de {sorted(noms)}") if manques: return False, (f"{len(manques)} application(s) sans base au plan : " + " | ".join(manques[:3]) + ("…" if len(manques) > 3 else "") + " — ajouter l'entree dans `plan/bases-donnees.yml`.") concernees = [a for a, v in apps.items() if str((v or {}).get("groupe") or "") in exigences] return True, (f"{len(concernees)} application(s) exigeant une base l'ont toutes " f"({len(bases)} entree(s) au registre).") def preuve_diff_vide_toutes_instances() -> tuple[bool, str]: """CHAQUE instance decouverte a un inventaire applique conforme a son plan. Pourquoi toutes, et pas seulement l'active. La frontiere nord/sud est PARTAGEE : ses alias d'hotes (`SETOPS__SERVEUR_*`) sont construits depuis le `hosts.yml` de CHAQUE tenant, pas depuis la formule. Le 2026-08-12, l'inventaire de Technolibre est reste a `10.21.x` apres un changement de derivation — et la frontiere a garde ces adresses pendant que ses alias RESEAU, eux, passaient a `10.11`. Le devis annoncait « conforme » : il lisait fidelement un intrant perime. Un tenant qu'on ne regarde pas — parce qu'il n'a aucune VM, precisement — impose donc ses adresses au pare-feu de tout le monde. Verifier la seule instance active laissait cet angle entier. CE QU'ELLE NE TESTE PAS : que le boitier porte ce que le devis dit — c'est `make frontiere-plan`. Ici on garde l'INTRANT de ce devis, pas sa sortie. """ sys.path.insert(0, str(RACINE / "scripts")) import instances as mod_instances trouvees = mod_instances.decouvrir() if not trouvees: return True, "Aucune instance decouverte : rien a comparer." ecarts: list[str] = [] verifiees: list[str] = [] for i in trouvees: chemin = RACINE.parent / i["nom"] if not (chemin / "inventories").is_dir(): continue # SETOPS_INVENTAIRE FORCE la cible et GAGNE sur SETOPS_INSTANCE (instancier.py). # Le Makefile l'exporte (ligne 13), pointe sur l'inventaire de l'instance ACTIVE. # Sans ce retrait, la boucle generait le plan de CHAQUE instance et le comparait # a l'inventaire applique de la SEULE instance active : « 17 hotes avec ecart » # sur un lab qui n'en a aucun (mesure du 2026-08-18). Et le vert etait pire que # le rouge — l'inventaire applique des autres tenants n'etait JAMAIS lu, ce qui # est precisement l'angle que cette preuve existe pour couvrir. env = {**os.environ, "SETOPS_INSTANCE": str(chemin)} env.pop("SETOPS_INVENTAIRE", None) # SETOPS_UNDERLAY aussi, et pour la meme raison (2026-08-20). L'inventaire APPLIQUE # d'un tenant a ete produit avec la fabric de SON site ; le regenerer contre une # autre fabric fabrique un ecart sur chaque hote — les ponts derivent des VNet de # l'underlay. Vu en lancant `make ci`, qui vise l'underlay du modele public : les # trois instances reelles sont apparues « en ecart », toutes les 14 machines. env.pop("SETOPS_UNDERLAY", None) gen = subprocess.run([sys.executable, "scripts/instancier.py", "generer"], cwd=RACINE, env=env, capture_output=True, text=True, timeout=300) if gen.returncode != 0: ecarts.append(f"{i['nom']} : generation impossible") continue cmp_ = subprocess.run([sys.executable, "scripts/instancier.py", "comparer", "--strict"], cwd=RACINE, env=env, capture_output=True, text=True, timeout=300) verifiees.append(i["nom"]) if cmp_.returncode != 0: lignes = [l.strip() for l in cmp_.stdout.splitlines() if l.strip()] resume = next((l for l in lignes if "ecart" in l), "diff non vide") ecarts.append(f"{i['nom']} : {resume}") if ecarts: return False, (f"{len(ecarts)}/{len(verifiees)} instance(s) dont l'inventaire " f"applique ne suit plus le plan : " + " | ".join(ecarts) + " — basculer avec `make instancier-appliquer` sur chacune.") return True, (f"{len(verifiees)} instance(s) verifiee(s) — " f"{', '.join(verifiees)} : plan et inventaire applique coincident.") def preuve_placement_chez_hebergeur() -> tuple[bool, str]: """Le placement d'un tenant designe des objets que son hebergeur offre REELLEMENT. D-80 : un tenant est agnostique de l'underlay — tout son adressage derive du seed. Il porte pourtant une LIAISON DE PLACEMENT vers l'hebergeur qui l'accueille : sur quel noeud cloner, sur quel stockage poser le disque, sur quel pont brancher la carte. Trois valeurs, cote tenant parce que c'est lui qui choisit ou se poser, mais qui NOMMENT des objets de l'hebergeur. C'est donc la seule surface qui casse quand un tenant change de fabric — et elle est entierement lisible : le tenant declare trois noms, l'hebergeur declare ses listes (`proxmox-hebergeur.yml`, trouve par derivation du symlink `underlay.yml`). Un nom absent est un ecart STATIQUE. Sans cette preuve, une faute de frappe ne se decouvre qu'au premier clone — apres quarante minutes de deploiement (D-75). CE QU'ELLE NE VERIFIE PAS : le GABARIT (`proxmox_clone_vmid_modele`). C'est un objet du cluster, pas une liste declaree par l'hebergeur : seul le cluster peut dire s'il existe. `make hote-afficher` et le clone lui-meme s'en chargent. """ import yaml as _y sys.path.insert(0, str(RACINE / "scripts")) import underlay as underlay_mod c = underlay_mod.chemin() if c is None: return True, "Aucun underlay monte : aucun hebergeur a confronter." heb = c.resolve().parent / "proxmox-hebergeur.yml" if not heb.is_file(): return True, f"Aucun {heb.name} chez l'hebergeur monte : rien a confronter." offert = _y.safe_load(heb.read_text(encoding="utf-8")) or {} tenant_f = RACINE / INV tenant_f = tenant_f.parent / "group_vars" / "proxmox.yml" if not tenant_f.is_file(): return True, "Le tenant monte ne declare aucun placement Proxmox." place = _y.safe_load(tenant_f.read_text(encoding="utf-8")) or {} controles = ( ("proxmox_clone_noeud", "proxmox_noeuds", "noeud"), ("proxmox_clone_stockage", "proxmox_stockages", "stockage"), ("proxmox_clone_pont", "proxmox_ponts", "pont"), ) manques, verifies = [], [] for cle_t, cle_h, libelle in controles: voulu = place.get(cle_t) dispo = offert.get(cle_h) or [] if voulu is None or not dispo: continue verifies.append(libelle) if str(voulu) not in [str(x) for x in dispo]: manques.append(f"{libelle} « {voulu} » absent de {cle_h} " f"({', '.join(str(x) for x in dispo)})") if manques: return False, (f"le placement du tenant designe {len(manques)} objet(s) que " f"l'hebergeur monte n'offre pas : " + " | ".join(manques) + " — corriger le placement, ou monter le bon `underlay.yml`.") return True, (f"placement confronte a l'hebergeur monte ({heb.parent.name}) : " f"{', '.join(verifies)} — tous offerts.") def preuve_etat_sauvegarde() -> tuple[bool, str]: """Tout hote qui detient de l'etat non regenerable porte `client_backup`. Pourquoi statiquement. Le 2026-08-11, la mesure a montre que RIEN n'etait sauvegarde dans l'ecosysteme : onze hotes lancaient chaque nuit un timer qui echouait sur « nothing to backup », et `infra-pki-01` — qui porte les CLES DE L'AC, et qui avait servi a prouver le Tier 0 le 2026-07-03 — n'avait aucune sauvegarde du tout. L'ecart etait entierement lisible dans le plan depuis un mois. D-75 : ce qui se lit statiquement se prouve statiquement, sinon on l'apprend le jour de la restauration. RIEN N'EST CODE EN DUR : la liste des groupes detenteurs d'etat est LUE dans `client_backup_catalogue` (defaults du role). Ajouter un role au catalogue etend donc la preuve du meme geste — on ne peut pas declarer une donnee sauvegardable et oublier d'exiger qu'elle le soit. CE QU'ELLE NE TESTE PAS : que la sauvegarde s'execute, ni qu'elle contienne quoi que ce soit. Une unite verte sur un depot vide resterait invisible ici — c'est au reel de le dire, pas au depot. """ plan = plan_de() defauts = RACINE / "roles" / "client_backup" / "defaults" / "main.yml" if not (plan / "serveurs.yml").is_file() or not defauts.is_file(): return True, "Aucun plan ou aucun role client_backup : rien a verifier." # Le catalogue porte du Jinja : on ne lit que les CLES, sans rendre les valeurs. catalogue = set(re.findall(r"^ (serveur_[a-z0-9_]+):\s*$", defauts.read_text(encoding="utf-8"), re.M)) if not catalogue: return False, ("`client_backup_catalogue` est vide ou illisible : aucun groupe " "detenteur d'etat n'est declare.") apps = (yaml.safe_load((plan / "applications.yml").read_text(encoding="utf-8")) or {}).get("applications") or {} serveurs = (yaml.safe_load((plan / "serveurs.yml").read_text(encoding="utf-8")) or {}).get("serveurs") or {} def _manques(apps: dict, serveurs: dict, ou: str) -> tuple[list[str], int]: detenu: dict[str, set[str]] = {} for app in (apps or {}).values(): groupe = str((app or {}).get("groupe") or "") if groupe in catalogue: detenu.setdefault(str((app or {}).get("hote") or ""), set()).add(groupe) return ([f"{ou} : {hote} detient {sorted(groupes)}" for hote, groupes in sorted(detenu.items()) if "client_backup" not in ((serveurs.get(hote) or {}).get("integrations") or [])], len(detenu)) manques, n_tenant = _manques(apps, serveurs, "ecosysteme") # LE SITE DETIENT DE L'ETAT LUI AUSSI, ET LE PLUS CONSEQUENT (2026-09-02). # # Cette preuve ne lisait que le plan de l'instance MONTEE. Or l'hebergeur porte la # racine de son autorite de certification et la forge du genome — perdre l'une oblige # a redistribuer la confiance a chaque machine de chaque ecosysteme, perdre l'autre # coupe la source dont tout descend. Rien ne l'exigeait, et rien ne l'aurait dit. # # Meme catalogue, meme regle : ce qui se lit statiquement se prouve statiquement, # sinon on l'apprend le jour de la restauration (D-75). Le site n'a simplement pas # d'`instance/` — son plan vit a cote de sa carte. n_site = 0 try: import underlay as underlay_mod if underlay_mod.chemin(): m_site, n_site = _manques( (underlay_mod.lire_plan_site("applications.yml") or {}).get("applications") or {}, (underlay_mod.lire_plan_site("serveurs.yml") or {}).get("serveurs") or {}, "site") manques += m_site except Exception: # Aucune carte d'hebergeur montee : on prouve ce qu'on peut voir, et on ne # transforme pas une absence en echec. pass if manques: return False, (f"{len(manques)} hote(s) detiennent de l'etat sans sauvegarde : " + " | ".join(manques) + " — ajouter `client_backup` a leurs `integrations` dans " "`plan/serveurs.yml`.") return True, (f"{n_tenant} hote(s) de l'ecosysteme et {n_site} du site detiennent de " f"l'etat, tous porteurs de `client_backup` " f"({len(catalogue)} groupe(s) au catalogue).") def preuve_lecteur_declare() -> tuple[bool, str]: """Chaque document de `docs/` declare son lecteur des sa premiere ligne. La refonte du 2026-08-10 part d'un constat : la documentation etait rangee par SUJET, ce qui est juste pour de la reference — mais personne n'arrive avec un sujet, on arrive avec une SITUATION. Le symptome exact : `autorisation.md` contient le runbook de reprise le plus utile du depot, enfoui au §6, parce que son SUJET est l'autorisation. Personne n'allait l'y chercher. D'ou la convention : un document declare son lecteur, pas sa categorie. Il se range alors tout seul, et un intrus s'y voit. CE QU'ELLE TESTE : la presence d'une ligne `> **Pour qui :** …` dans l'en-tete (avant le premier titre de section), pour tout `docs/**/*.md`. DEUX EXEMPTIONS, et elles sont DERIVEES, pas listees : - un document qui se declare GENERE ne se lit pas, il se regenere. Il s'annonce lui-meme (« Genere par », « ne pas editer a la main ») ; on le reconnait a ca, et non par un chemin en dur qui vieillirait a la premiere page ajoutee ; - un fragment sans titre `#` n'est pas un document. CE QU'ELLE NE TESTE PAS : que le lecteur declare soit le BON. Ca se juge en revue. Elle garantit seulement qu'on a du y penser — ce qui est exactement ce qui manquait : 32 des 34 documents n'en disaient rien. """ docs = sorted((RACINE / "docs").rglob("*.md")) muets: list[str] = [] generes = 0 for f in docs: lignes = f.read_text(encoding="utf-8", errors="ignore").splitlines() # L'EN-TETE seulement : ce qui suit le premier `##` appartient au corps. Une # mention de « Pour qui » perdue au milieu d'une page ne serait pas une porte. entete = [] for l in lignes: if l.startswith("## "): break entete.append(l) tete = "\n".join(entete) tete_bas = tete.lower() if "genere par" in tete_bas or "généré par" in tete_bas \ or "ne pas editer a la main" in tete_bas or "ne pas éditer à la main" in tete_bas: generes += 1 continue if not any(l.startswith("# ") for l in entete): continue if "**Pour qui" not in tete: muets.append(str(f.relative_to(RACINE))) if muets: return False, ( f"{len(muets)} document(s) ne declarent pas leur lecteur : " + ", ".join(muets[:6]) + ("…" if len(muets) > 6 else "") + " — ajouter `> **Pour qui :** …` sous le titre." ) return True, (f"{len(docs) - generes} document(s) declarent leur lecteur " f"({generes} genere(s) exempte(s)).") # Le jargon que Set-OPS met sous les yeux de l'exploitant, et qu'il s'engage a enseigner. # LISTE DECLAREE, ET C'EST UN CHOIX. La derivation automatique a ete essayee le # 2026-08-21 : 153 acronymes dans le wiki et le README, dont la moitie sont des mots # francais en capitales (AUCUNE, AVANT, TOUS, UNE...). Un critere qui exige une entree de # glossaire pour « AUCUNE » ne se respecte pas longtemps — on l'aurait desactive, et la # preuve serait morte. Une liste explicite qu'on allonge a la main vaut mieux qu'un # controle qu'on eteint. JARGON_A_ENSEIGNER = [ # reseau — la famille la plus obscure, et celle qui a motive cette preuve "underlay", "overlay", "VLAN", "VXLAN", "EVPN", "SDN", "VNet", "VRF", "strophe", "FRR", "blackhole", "MTU", "SVI", "IPAM", "spanning-tree", "MLAG", "nftables", "policy drop", "CIDR", "supernet", "FQDN", "pont", "frontiere", # machines et Ansible "hyperviseur", "gabarit", "cloud-init", "VMID", "systemd", "snapshot", "playbook", "role", "inventaire", "group_vars", "handler", "idempotence", "voute", # noms, confiance, identite, courriel "DNS", "plancher", "reverse proxy", "vhost", "WAF", "edge", "PKI", "ACME", "SAN", "mTLS", "TLS", "certificat", "LDAP", "SSO", "OIDC", "SAML", "realm", "RBAC", "SMTP", "IMAP", "LMTP", "DKIM", "MTA", # etat et methode "restic", "devis", "preuve", "harnais", "GUI", "index", "plan", "tenant", "SQLite", "source unique", "resolution d'instance", # filiation — le vocabulaire arrive le 2026-08-21 avec les signatures "genome", "parente", "commit", "empreinte", "Merkle", "etiquette", "signature", "allowed-signers", "temoin", "journal de transparence", "horodatage", ] def preuve_resolution_unique() -> tuple[bool, str]: """Aucun module ne reecrit sa propre resolution d'instance. POURQUOI CETTE PREUVE EXISTE (2026-08-22). Neuf modules portaient chacun leur copie de « quelle instance, quel inventaire ». Aucune n'etait une faute d'inattention : chacune avait ete ecrite de bonne foi, a un moment ou le besoin semblait local. En cinq jours, cinq defauts en sont sortis — P03 comparait chaque instance a l'inventaire d'une autre, `verifier_ports` codait `principal/` en dur, `verifier_intrants` et `_frontiere_absente` lisaient le symlink au lieu de la variable, `devis_placement` et P35 rendaient un verdict juste sur le mauvais ecosysteme. C'est le mode de panne de la duplication : pas l'erreur, mais la DERIVE — invisible depuis l'interieur d'un fichier, parce que chaque copie a l'air correcte chez elle. La resolution vit donc dans `inventory_rules` (`instance_courante`, `inventaire_de`, `dossier_inventaire`, `plan_de`). Cette preuve garde l'unicite : elle echoue des qu'un module relit la variable d'environnement ou recompose le chemin du symlink lui-meme au lieu d'appeler la resolution partagee. TROIS EXEMPTIONS, nommees pour rester des choix et non des trous : `instances.py` et `inventory_gui.py` manipulent le SYMLINK lui-meme (c'est la bascule d'instance), et `devis_opnsense` lit deliberement quelle instance est ACTIVE pour se situer dans la federation. Ces trois-la parlent du lien, pas de la resolution. """ exemptes = {"inventory_rules.py", "instances.py", "inventory_gui.py", "devis_opnsense.py"} motifs = (re.compile(r'os\.environ\.get\(\s*["\']SETOPS_INSTANCE'), re.compile(r'RACINE(?:_DEPOT)?\s*/\s*["\']instance["\']')) copies: list[str] = [] for f in sorted((RACINE / "scripts").glob("*.py")): if f.name in exemptes: continue texte = f.read_text(encoding="utf-8", errors="ignore") for i, ligne in enumerate(texte.splitlines(), 1): if ligne.lstrip().startswith("#"): continue if any(m.search(ligne) for m in motifs): copies.append(f"{f.name}:{i}") if copies: return False, (f"{len(copies)} copie(s) de la resolution d'instance : " + ", ".join(copies[:6]) + ("…" if len(copies) > 6 else "") + " — passer par inventory_rules.instance_courante().") n = len(list((RACINE / "scripts").glob("*.py"))) - len(exemptes) return True, (f"Resolution unique : {n} script(s) passent par " f"`inventory_rules`, {len(exemptes) - 1} exemption(s) nommee(s).") def preuve_edge_porte_ses_noms() -> tuple[bool, str]: """Tout ecosysteme qui PUBLIE des noms sert un certificat qui les porte. POURQUOI CETTE PREUVE EXISTE (2026-08-23). L'edge de patient 0 publiait sa forge derriere `ssl-cert-snakeoil.pem` — le certificat auto-signe de Debian. Le service repondait, la page s'affichait apres un avertissement, `make prouver` etait vert : rien, nulle part, ne signalait que la publication n'etait pas de confiance. Le premier a refuser fut `git clone`, en voulant cloner le genome — et il avait raison. LA CAUSE N'ETAIT PAS UN BUG, C'ETAIT UN OUBLI DE RECOPIE. Trois instances sur quatre portaient `group_vars/serveur_nginx.yml` (les SAN d'exposition, le chemin du cert step-ca, le rechargement de nginx). La quatrieme, plus recente, ne l'avait pas — et le modele public dont descendent les prochaines ne l'avait pas non plus. Un cablage recopie a la main finit toujours par manquer quelque part ; la seule parade est qu'une preuve le reclame. CE QU'ELLE TESTE, hors ligne, pour CHAQUE instance qui declare un groupe `serveur_nginx` : - le fichier `group_vars/serveur_nginx.yml` existe ; - `client_pki_sans` y etend les SAN avec `sans_exposition` (les `expose:` du plan) ; - `serveur_nginx_certificat` pointe vers les certificats de l'AC, pas ailleurs. CE QU'ELLE NE TESTE PAS : ce que l'edge sert REELLEMENT. Cela demande de joindre la machine — c'est le travail de `make certificats-plan`. Ici on verifie que l'ecosysteme a DEMANDE la bonne chose. """ sys.path.insert(0, str(RACINE / "scripts")) import instances as mod_instances trouvees = mod_instances.decouvrir() if not trouvees: return True, "Aucune instance decouverte : rien a verifier." fautes: list[str] = [] verifiees: list[str] = [] for i in trouvees: base = RACINE.parent / i["nom"] inventaires = sorted((base / "inventories").glob("*")) if (base / "inventories").is_dir() else [] for inv in inventaires: if not inv.is_dir(): continue hosts = inv / "hosts.yml" if not hosts.exists(): continue # Un edge n'est requis que si l'ecosysteme en declare un. if "serveur_nginx" not in hosts.read_text(encoding="utf-8", errors="ignore"): continue f = inv / "group_vars" / "serveur_nginx.yml" nom = f"{i['nom']}/{inv.name}" if not f.exists(): fautes.append(f"{nom} : aucun group_vars/serveur_nginx.yml — l'edge n'emettra " f"pas de cert pour les noms qu'il publie") continue texte = f.read_text(encoding="utf-8", errors="ignore") if "sans_exposition" not in texte: fautes.append(f"{nom} : client_pki_sans n'etend pas les SAN avec `sans_exposition`") if "/etc/step/certs/" not in texte: fautes.append(f"{nom} : serveur_nginx_certificat ne pointe pas vers l'AC interne") verifiees.append(nom) if fautes: return False, (f"{len(fautes)} ecosysteme(s) publient sans porter leurs noms : " + " | ".join(fautes[:4]) + ("…" if len(fautes) > 4 else "")) if not verifiees: return True, "Aucun ecosysteme ne declare d'edge : rien a publier." return True, (f"{len(verifiees)} edge(s) emettent un certificat portant les noms publies " f"({', '.join(verifiees)}).") def preuve_parente_inscrite() -> tuple[bool, str]: """L'ecosysteme sait de QUOI il descend, et cette filiation tient encore. POURQUOI (2026-08-21). Un ecosysteme se reproduit depuis quatre depots, pas depuis ses machines. Fabrique aujourd'hui depuis la forge de son parent, il ne saura plus dans un an de quel moteur il descend ni a quel etat — et cinq enfants sans registre font cinq moteurs differents que plus personne ne sait rapprocher. C'est la mutation sans memoire : le seul vrai risque d'une lignee. CE QU'ELLE TESTE (via `scripts/genome.py verifier`) : la parente est inscrite ; chaque depot qu'elle nomme se retrouve ; chaque commit inscrit EXISTE encore dans son depot (une histoire reecrite se voit ici) ; et chacun porte un remote — un depot qui n'existe qu'ici n'est pas une lignee, c'est un point unique de defaillance. SAUTEE quand l'instance n'est pas un depot git : c'est le cas du modele public monte par la CI. Un gabarit jetable n'a pas d'ascendance a declarer, et exiger un `parente.yml` d'une copie temporaire ferait echouer la CI pour une question qui n'a pas de sens dans son contexte. CE QU'ELLE NE TESTE PAS : que le depot distant contienne encore ce commit. Cela exige de joindre la forge ; ici on ne verifie que ce qui est lisible hors ligne. """ if not (INSTANCE / ".git").exists(): return True, "Instance sans depot git (modele jetable) : aucune ascendance a declarer." r = subprocess.run([sys.executable, "scripts/genome.py", "verifier"], cwd=RACINE, capture_output=True, text=True, timeout=120) sortie = (r.stdout + r.stderr).strip().splitlines() 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_ordre_des_integrations() -> tuple[bool, str]: """Une integration applique SON SERVEUR avant ceux qui s'y adressent. POURQUOI (2026-08-25). `client_pki` s'executait sur tous les hotes en parallele. Sur celui qui porte l'autorite, le role reemet le certificat de `step-ca` et RECHARGE le service ; les quatre autres l'interrogeaient dans cette fenetre et echouaient ensemble sur `net/http: TLS handshake timeout` — un message qui accuse le reseau alors que la cause est une course de dix secondes. Ce n'est pas propre a la PKI. Toute integration a la meme forme : un `client_*` s'adresse a un `serveur_*`, et l'ordre compte. Le cas le plus dangereux n'est pas le certificat mais le RESOLVEUR — un hote bascule sur un resolveur pas encore pret devient muet, et le runner qui devrait le reparer tombe avec lui. La dependance se declare dans `roles//meta/integration.yml` (`serveur:`), et le playbook de groupe en est le miroir. Cette preuve refuse l'ecart entre les deux : sans elle, la declaration deviendrait un commentaire, et l'ordre une coincidence. `serveur: ~` est une reponse valable — `client_metrique` pose un exportateur que Prometheus vient LIRE, il n'attend personne. Toutes les integrations ne dependent pas d'un service debout, et le dire explicitement vaut mieux que l'omettre. """ manques: list[str] = [] vus = 0 for meta in sorted((RACINE / "roles").glob("client_*/meta/integration.yml")): role = meta.parent.parent.name d = (yaml.safe_load(meta.read_text(encoding="utf-8")) or {}).get("integration") or {} if "serveur" not in d: manques.append(f"{role} : `meta/integration.yml` ne declare pas `serveur:` — " f"on ne peut donc pas savoir si son ordre est correct " f"(mettre `~` si l'integration n'attend aucun service)") continue srv = d.get("serveur") pb = RACINE / "playbooks" / "groupes" / f"{role}.yml" if not pb.is_file(): continue texte = pb.read_text(encoding="utf-8") if not srv: continue # rien a ordonner, et c'est declare vus += 1 if f"hosts: {role}:&{srv}" not in texte: manques.append(f"{role} : declare dependre de `{srv}`, mais son playbook " f"n'applique pas d'abord `{role}:&{srv}`") if f"hosts: {role}:!{srv}" not in texte: manques.append(f"{role} : son playbook n'exclut pas `{srv}` du second play — " f"le serveur serait reconfigure deux fois, et l'ordre perdu") if "any_errors_fatal: true" not in texte: manques.append(f"{role} : sans `any_errors_fatal` sur le premier play, un " f"serveur en echec laisse ses clients s'y raccrocher pour rien") if manques: return False, ("L'ordre des integrations n'est pas garanti :\n - " + "\n - ".join(manques)) return True, (f"{vus} integration(s) appliquent leur serveur avant leurs clients.") def preuve_parefeu_proxmox_sdn_seulement() -> tuple[bool, str]: """Le pare-feu Proxmox ne s'arme que sur un VNet SDN, jamais sur un pont classique. POURQUOI (2026-08-25). `firewall=1` fait passer tout le trafic ponte par conntrack. Sur un VNet SDN c'est sans consequence : en EVPN le routage inter-VNet se fait dans le VRF, SUR LE NOEUD, et le flux ne quitte jamais l'hyperviseur. Sur un pont classique route par une frontiere externe, le meme reglage devient un piege. Deux VM du meme noeud dans deux VLAN differents ne se parlent qu'en EPINGLE : la trame sort par le lien physique, la frontiere la route, elle revient sur LE MEME PONT. La meme table conntrack voit les deux moities de la connexion, classe le retour `INVALID`, et `PVEFW-FORWARD` le jette. Mesure : sur le site decoupe en quatre zones, les paires intra-noeud etaient a 0/8 et toutes les paires inter-noeuds a 8/8 ; douze tentatives faisaient monter le compteur `ctstate INVALID` de +112 sur asgard et +116 sur gandalf. `firewall=0` pose, il ne bouge plus. Le defaut se deguise en panne reseau : la poignee TCP aboutit, et ce sont les paquets de DONNEES qui disparaissent — on accuse le MTU, la frontiere, la zone. L'INTENTION DECLAREE NE SUFFIT PAS : un tenant declare `proxmox_clone_parefeu_interface: true` avec `proxmox_clone_pont: vmbr1` comme valeur PAR DEFAUT, chaque hote la remplacant par son VNet derive. L'hote qui retombe sur `vmbr1` naitrait arme sur un pont classique. Cette preuve n'inspecte pas le texte du playbook : elle EVALUE l'expression qu'il porte, sur quatre cas. Le cas « intention vraie sur un VNet » doit rendre VRAI — sinon une expression constamment fausse passerait la preuve sans rien garantir. """ import jinja2 pb = RACINE / "playbooks" / "proxmox" / "cloner_vm_debian.yml" if not pb.is_file(): return False, f"{pb.relative_to(RACINE)} est introuvable." lignes = [l for l in pb.read_text(encoding="utf-8").splitlines() if re.match(r"\s*firewall:\s*", l)] if len(lignes) != 1: return False, (f"{pb.relative_to(RACINE)} porte {len(lignes)} ligne(s) " f"`firewall:` — la preuve en attend exactement une, sinon elle ne " f"sait pas laquelle gouverne la carte.") expr = lignes[0].split("firewall:", 1)[1].strip().strip('"').strip("'") # Jinja2 seul ne connait ni le test `match` ni le filtre `bool` — ils viennent # d'Ansible. On les redonne a l'identique, sinon la preuve echouerait sur l'outil # plutot que sur ce qu'elle mesure. env = jinja2.Environment(undefined=jinja2.StrictUndefined) env.tests["match"] = lambda v, motif: re.match(motif, str(v)) is not None env.filters["bool"] = lambda v: (v if isinstance(v, bool) else str(v).strip().lower() in ("true", "yes", "on", "1")) cas = [ # (intention, pont, attendu, pourquoi) (True, "t29serv", True, "un VNet SDN : le routage inter-VNet reste sur le noeud"), (True, "vmbr3", False, "un pont classique : le retour en epingle serait jete"), (True, "vmbr1", False, "le pont par defaut d'un tenant, si l'hote y retombe"), (False, "t29serv", False, "l'ecosysteme ne demande pas de filtrage est-ouest"), ] manques: list[str] = [] for intention, pont, attendu, pourquoi in cas: try: rendu = env.from_string(expr).render( proxmox_clone_parefeu_interface=intention, proxmox_clone_pont=pont) except jinja2.UndefinedError as e: manques.append(f"intention={intention} pont={pont} : l'expression reclame " f"une variable que la preuve ne fournit pas ({e})") continue obtenu = str(rendu).strip().lower() in ("true", "yes", "1") if obtenu is not attendu: manques.append(f"intention={intention} pont={pont} : rend {obtenu}, " f"attendu {attendu} — {pourquoi}") if manques: return False, ("Le pare-feu Proxmox n'est pas correctement croise avec le pont :" "\n - " + "\n - ".join(manques)) return True, ("Le pare-feu Proxmox ne s'arme que sur un VNet SDN " f"({len(cas)} cas evalues, dont un qui doit rendre VRAI).") def preuve_un_seul_maitre_du_plancher() -> tuple[bool, str]: """Un seul role decide si cloud-init a le droit de reecrire /etc/hosts. POURQUOI (2026-08-25). Deux roles ecrivaient un fragment dans `/etc/cloud/cloud.cfg.d` et se contredisaient : `hosts_statiques` posait `manage_etc_hosts: false`, `cloud_init` posait `true`. Or l'ordre y est LEXICAL — `99-setops-hosts.cfg` (`-`, 0x2D) est lu AVANT `99_setops.cfg` (`_`, 0x5F). C'est donc le `true` qui gagnait, et cloud-init reecrivait /etc/hosts a chaque demarrage. Le plancher de resolution disparaissait ainsi en silence, et le defaut ne se manifestait qu'au redemarrage suivant, ailleurs : un `apt update` qui ne resout plus le cache, un certificat dont le nom ne pointe nulle part. Mesure : les cinq machines du site redemarrees le 2026-08-25 n'avaient plus que les entrees Debian par defaut. Le role qui POSSEDE le fichier decide. Cette preuve refuse qu'un second s'en mele : deux ecritures, meme d'accord aujourd'hui, sont un desaccord qui attend son heure. """ ecrivains: list[str] = [] for chemin in sorted((RACINE / "roles").rglob("*")): if not chemin.is_file() or chemin.suffix not in (".yml", ".yaml", ".j2"): continue for ligne in chemin.read_text(encoding="utf-8", errors="replace").splitlines(): nu = ligne.strip() if nu.startswith("#"): continue # un commentaire explique, il n'ecrit pas if re.match(r"^manage_etc_hosts\s*:", nu): ecrivains.append(f"{chemin.relative_to(RACINE)} : {nu}") if len(ecrivains) != 1: return False, ( f"{len(ecrivains)} ecriture(s) de `manage_etc_hosts` dans les roles — il en " f"faut exactement une, sinon l'ordre lexical de `cloud.cfg.d` tranche a notre " f"place :\n - " + "\n - ".join(ecrivains or ["aucune : plus personne " "n'empeche cloud-init d'effacer le plancher au demarrage"])) # ET LE FRAGMENT NE SUFFIT PAS. Proxmox inscrit `manage_etc_hosts: true` dans la # USER-DATA de son lecteur cloud-init, et la user-data prime sur tout `cloud.cfg.d`. # Le plancher a donc ete efface a chaque demarrage malgre le fragment — d'abord sous # le nom `99-`, puis sous `zz-` : renommer pour parler en dernier ne servait a rien, # le dernier mot n'appartenant pas a ce repertoire. # # Ce que cloud-init regenere, il le regenere depuis `hosts..tmpl`. Un role # doit donc poser CE gabarit, sans quoi la correction n'en est pas une. gabarit = [str(p.relative_to(RACINE)) for p in (RACINE / "roles").rglob("*") if p.is_file() and "hosts.debian.tmpl" in p.name] if not gabarit: return False, ( "Aucun role ne pose `/etc/cloud/templates/hosts.debian.tmpl`. Le fragment " "`cloud.cfg.d` seul ne protege rien : la user-data du lecteur cloud-init " "(Proxmox y met `manage_etc_hosts: true`) prime sur ce repertoire. Le " "plancher serait efface au prochain demarrage, sans erreur ni message.") return True, (f"Un seul maitre du plancher — {ecrivains[0]} ; " f"et le gabarit maitre est pose ({gabarit[0]}).") def preuve_zones_inverses_couvrent_l_occupe() -> tuple[bool, str]: """Les zones `in-addr.arpa` couvrent ce que l'ecosysteme occupe, et rien de plus. POURQUOI (2026-08-25). La derivation supposait un supernet /16 — la forme d'un TENANT, qui tire tout de son index. Un SITE ne derive pas : il declare plusieurs /24 et n'a pas de supernet unique. La derivation rendait donc une chaine vide, aucune zone inverse n'etait generee, et les cinq machines du site restaient anonymes a l'envers — ce que le courriel et la journalisation reprochent en silence. LE PIEGE INVERSE COMPTE AUTANT. Revendiquer `0.10.in-addr.arpa` aurait couvert le site d'un seul geste — et aussi la frontiere, le transit et les hyperviseurs, qui ne sont pas a lui. Une autorite qu'on s'attribue sans l'exercer est une panne differee : le resolveur repondrait NXDOMAIN pour des adresses qu'un autre sait nommer. Cette preuve evalue la derivation elle-meme, sur les deux formes d'ecosysteme plus les cas degenerees. Le cas tenant DOIT rendre une seule zone /16, et le cas site DOIT en rendre quatre : sans ces deux-la, une derivation qui rend toujours une liste vide passerait la preuve sans rien garantir. """ from inventory_rules import zones_inverses as _zi site = ["10.0.31.11", "10.0.32.11", "10.0.33.11", "10.0.33.21", "10.0.34.11"] cas = [ ("site — quatre reseaux declares, un /24 chacun", site, "10.0.34.0/24", ["31.0.10.in-addr.arpa", "32.0.10.in-addr.arpa", "33.0.10.in-addr.arpa", "34.0.10.in-addr.arpa"]), ("tenant — un supernet /16 couvre tout", ["10.27.15.11", "10.27.16.11"], "10.27.0.0/16", ["27.10.in-addr.arpa"]), ("deux machines d'un meme /24 ne font qu'une zone", ["10.0.33.11", "10.0.33.21"], "", ["33.0.10.in-addr.arpa"]), ("aucune adresse : aucune zone, plutot qu'une zone fausse", [], "", []), ("une adresse illisible est ignoree, pas devinee", ["pas-une-adresse", "10.0.31.11"], "", ["31.0.10.in-addr.arpa"]), ] manques: list[str] = [] for titre, adresses, supernet, attendu in cas: obtenu = _zi(adresses, supernet) if obtenu != attendu: manques.append(f"{titre} : rend {obtenu}, attendu {attendu}") if manques: return False, ("Les zones inverses ne couvrent pas ce que l'ecosysteme occupe :" "\n - " + "\n - ".join(manques)) return True, (f"Les zones inverses couvrent l'occupe et rien de plus " f"({len(cas)} cas evalues, dont un site a quatre zones et un tenant " f"a une).") # Ce que la carte nomme a juste titre et que ce depot ne contient pas : le produit de # `make instancier`, qui vit dans le depot d'un ecosysteme. ARTEFACTS_GENERES = {"hosts.yml"} def _sans_accents(texte: str) -> str: return "".join(c for c in unicodedata.normalize("NFD", texte) if unicodedata.category(c) != "Mn") _ECARTES = {".git", "__pycache__", ".venv", "node_modules", "collections"} def _propre(chemin: Path) -> bool: return not any(part in _ECARTES for part in chemin.relative_to(RACINE).parts) def _existe_en_suffixe(fragment: str) -> bool: """Un fichier du depot se termine-t-il par ce fragment de chemin ?""" nom = fragment.rsplit("/", 1)[-1] return any(_propre(p) and str(p).endswith("/" + fragment) for p in RACINE.rglob(nom)) def _existe_quelque_part(nom: str) -> bool: """Un fichier ou dossier de ce nom existe-t-il dans le depot ? La carte nomme volontiers un fichier sans son dossier — c'est ce qui la rend lisible. On cherche donc par NOM, en ecartant ce qui n'appartient pas au depot (`.git`, les caches Python, les dependances installees). """ return any(_propre(chemin) for chemin in RACINE.rglob(nom)) def _carte_chiffres_mesures() -> dict[str, int]: """Ce que la carte affirme, recompte a la source.""" dec = (RACINE / "docs" / "decisions-architecture.md") texte = dec.read_text(encoding="utf-8") if dec.is_file() else "" return { "roles": len([p for p in (RACINE / "roles").glob("*") if p.is_dir()]), "README de roles": len(list((RACINE / "roles").glob("*/README.md"))), "documents": len(list((RACINE / "docs").glob("*.md"))), "pieces d'audit": len([p for p in (RACINE / "docs" / "audit").glob("*") if p.is_file()]), "unites de wiki": len(list((RACINE / "wiki").glob("*.md"))), "decisions en vigueur": len(re.findall(r"^\| \*\*D-\d+\*\* \|", texte, re.M)), "decisions renversees": len(re.findall(r"^\| \*\*D-\d+\*\* —", texte, re.M)), } def preuve_comptes_de_la_prose() -> tuple[bool, str]: """Un nombre de roles ou de preuves ecrit en prose correspond a la mesure. POURQUOI, ALORS QUE P48 GARDE DEJA LA CARTE (mesure du 2026-09-05). P48 ne regarde que le tableau « Le depot en chiffres ». Les MEMES nombres vivent ailleurs, en pleine phrase, et ils avaient tous derive : docs/devis-services.md : « porte 35 preuves » -> 56 AGENTS.md : « les 35 preuves » -> 56 docs/pouvoirs-set-ops : « ≈50 roles » -> 65 docs/catalogue-services : « les 29 groupes » -> 40 Le quatrieme a ete trouve APRES coup (2026-09-06), par la revision de documentation et non par cette preuve : elle ne regardait que `preuves` et `roles`. Un catalogue qui annonce 29 groupes au-dessus d'un tableau qui en cite 40 se contredit A UNE LIGNE DE DISTANCE. D'ou le troisieme compte. Aucun de ces nombres ne fait travailler personne. Mais AGENTS.md est la source d'autorite du depot : un document qui se trompe sur ce qu'il decrit cesse d'etre cru, et c'est alors ses DIRECTIVES qu'on perd — pas seulement ses chiffres. CE QU'ELLE NE FLAGUE PAS, ET POURQUOI C'EST ESSENTIEL. Une mesure DATEE est un fait historique : « Set-OPS a longtemps eu 31 preuves » raconte pourquoi `make valider` existe, et reecrire ce 31 detruirait le recit. On ignore donc toute ligne portant une annee — le depot date ses mesures, c'est deja sa discipline. Une preuve qui crie sur un cas sain finit par etre ignoree, ce qui est pire que de ne pas l'avoir. Celle-ci a ete ecrite, puis passee sur tout le corpus jusqu'a etre SILENCIEUSE sur les formulations legitimes avant d'etre retenue. """ mesures = { "preuves": len(TOUTES_LES_PREUVES), "roles": len([d for d in (RACINE / "roles").iterdir() if d.is_dir()]), # UN GROUPE OPERATIONNEL EST UN PLAYBOOK, PAS UN ROLE. P04 tient deja la regle # « groupe -> playbooks/groupes/.yml » : c'est donc ce repertoire qui # compte, et non `roles/`, qui contient aussi les rôles composés par un groupe # (les onze du durcissement n'ont pas de groupe a eux). "groupes": len(list((RACINE / "playbooks" / "groupes").glob("*.yml"))), } fichiers = sorted( list((RACINE / "docs").glob("*.md")) + list((RACINE / "wiki").glob("*.md")) + [RACINE / "AGENTS.md", RACINE / "README.md", RACINE / "CLAUDE.md"]) ecarts: list[str] = [] for f in fichiers: if not f.is_file(): continue lignes = f.read_text(encoding="utf-8").splitlines() # LA DATATION PORTE SUR LE PARAGRAPHE, PAS SUR LA LIGNE (mesure du 2026-09-05). # # Premiere version : on ignorait une ligne portant une annee. Elle a crie sur les # deux seuls cas legitimes du corpus — `autorisation.md` datait son constat trois # lignes plus haut, et `Verifier-le-deploye.md` ecrit « a longtemps eu » sans # millesime. Une garde qui crie sur des cas sains finit par etre ignoree ; on # regarde donc le PARAGRAPHE, qui est l'unite ou une mesure se date reellement. bornes: list[tuple[int, int]] = [] debut = 0 for i, l in enumerate(lignes): if not l.strip(): if i > debut: bornes.append((debut, i)) debut = i + 1 if debut < len(lignes): bornes.append((debut, len(lignes))) historiques: set[int] = set() for a, b in bornes: para = "\n".join(lignes[a:b]) if re.search(r"\b20\d\d\b|longtemps|autrefois|jusqu'(a|au)\b", para, re.I): historiques.update(range(a, b)) for n, ligne in enumerate(lignes, 1): if (n - 1) in historiques: continue for m in re.finditer( r"(? tuple[bool, str]: """La carte d'orientation designe des choses qui existent, et compte juste. CE QU'EST LA CARTE. `docs/carte-set-ops.md` est l'index du MAINTENEUR : l'ordre de lecture du corpus, et surtout le catalogue des MECANISMES TRANSVERSES avec, pour chacun, OU IL VIT DANS LE CODE. Son but est ecrit en toutes lettres : « ne plus re-deterrer ce qui existe ». POURQUOI UNE GARDE (mesure du 2026-08-26). Un POINTEUR MORT est le defaut couteux : la carte dit ou vit un mecanisme, quelqu'un ne l'y trouve pas, et le reimplemente a cote. C'est exactement la panne que la carte existe pour prevenir. LES CHIFFRES SONT L'AUTRE MOITIE, et ils etaient TOUS faux : 54 roles annonces contre 60, 34 documents contre 38, 15 pieces d'audit contre 27, 70 decisions contre 78. Aucun de ces nombres ne fait travailler personne. Mais un document dont les faits verifiables sont faux cesse d'etre consulte — et c'est alors ses pointeurs qu'on perd. `catalogue-services.md` avait deja sa garde (P38) ; la carte n'en avait aucune. """ carte = RACINE / "docs" / "carte-set-ops.md" if not carte.is_file(): return False, "docs/carte-set-ops.md est introuvable." texte = carte.read_text(encoding="utf-8") manques: list[str] = [] # --- Les pointeurs --------------------------------------------------------- # # On ne retient que ce qui RESSEMBLE a un chemin du depot : un segment avec un `/` # ou une extension connue. Les `make cible`, les noms de variables et les fragments # de code entre accents graves ne sont pas des chemins, et les exiger rendrait la # preuve fausse dans l'autre sens. chemins = set() for brut in re.findall(r"`([^`\n]+)`", texte): c = brut.strip() # Un GABARIT de nom n'est pas un chemin : `docs/audit/preuve-.md` decrit # une forme de fichier, pas un fichier. L'exiger rendrait la preuve fausse. # Ni un gabarit de nom (`preuve-.md` decrit une FORME), ni un chemin HORS # depot (`~/.config/setops-vault-pass` vit sur le poste de l'exploitant, et c'est # tout l'interet de la doctrine des voutes). Les exiger rendrait la preuve fausse. if " " in c or c.startswith(("$", "-", "~", "/")) or "<" in c or ">" in c: continue if not ("/" in c or c.endswith((".md", ".yml", ".py", ".j2"))): continue chemins.add(c.rstrip("/")) for c in sorted(chemins): # DEUX SEMANTIQUES, ET C'EST VOULU. # # Un chemin AVEC dossier (`roles/serveur_nginx/tasks/main.yml`) affirme un # emplacement : on l'exige tel quel. Un nom SANS dossier (`devis_reseau.py`, # `expositions.conf.j2`) affirme seulement qu'une chose de ce nom existe — c'est # ainsi qu'un index se lit, et exiger le chemin complet le rendrait illisible. # UN ARTEFACT GENERE N'A PAS A EXISTER ICI. La carte nomme `hosts.yml` en disant # elle-meme « genere » : c'est le produit de `make instancier`, il vit dans le # depot d'un ECOSYSTEME, pas dans le moteur. L'exiger reprocherait a la carte # d'etre juste. On les nomme plutot que de les sauter en silence. if c in ARTEFACTS_GENERES: continue if "/" in c: # Un fragment (`meta/acces.yml`) designe un emplacement RELATIF a un role ou # a une instance. Il vaut donc comme SUFFIXE : `roles/serveur_forgejo/meta/ # acces.yml` le satisfait. Exiger la racine rendrait la carte illisible. if any((RACINE / base / c).exists() for base in ("", "docs")): continue if _existe_en_suffixe(c): continue elif _existe_quelque_part(c): continue # Les globs et les chemins d'une INSTANCE (`plan/*.yml`, `roles/*/meta/...`) # se verifient par expansion : ils designent une forme, pas un fichier. # UN GLOB DESIGNE UNE FORME, ou qu'elle se trouve. `plan/*.yml` est le plan d'un # ECOSYSTEME : il n'existe pas a la racine du moteur, mais bien sous `instance/` # ou dans un depot voisin. On cherche donc la forme partout. if "*" in c and (list(RACINE.glob(c)) or list(RACINE.glob("**/" + c))): continue manques.append(f"`{c}` : cite par la carte, introuvable dans le depot") # --- Les chiffres ---------------------------------------------------------- # # L'appariement se fait SANS ACCENTS : la carte ecrit « rôles » et « pièces d'audit », # ce code ecrit sans diacritiques comme tout le reste du depot. Comparer les deux # tels quels ferait echouer la preuve sur son propre alphabet. annonces = {} for etiq, valeur in re.findall(r"^\| *([^|]+?) *\| *(\d+) *\|", texte, re.M): annonces[_sans_accents(etiq).lower()] = int(valeur) for etiquette, attendu in _carte_chiffres_mesures().items(): cle = _sans_accents(etiquette).lower() if cle not in annonces: manques.append(f"« {etiquette} » : absent du tableau « Le depot en chiffres » " f"(mesure : {attendu})") continue if annonces[cle] != attendu: manques.append(f"« {etiquette} » : la carte annonce {annonces[cle]}, " f"le depot en compte {attendu}") if manques: return False, ("La carte d'orientation ne dit plus vrai :\n - " + "\n - ".join(manques)) return True, (f"La carte designe {len(chemins)} chemin(s) qui existent, et ses " f"{len(_carte_chiffres_mesures())} chiffres correspondent a la mesure.") def preuve_parametres_clone_traversent() -> tuple[bool, str]: """Tout `SETOPS_*` que le Makefile transmet au clonage a bien une SOURCE. LE PLANCHER DE MEMOIRE A TRAVERSE TROIS MODULES ET S'EST PERDU AU TROISIEME (2026-09-13). `instancier` derivait `proxmox_memoire_min` dans l'inventaire ; le playbook de clonage savait poser `balloon:` ; entre les deux, `inventory_host.py` ne transmettait rien. Quatorze machines allaient naitre avec le ballooning DESACTIVE. ET RIEN N'AURAIT ECHOUE. `$$SETOPS_MEMOIRE_MIN` non defini vaut la chaine vide ; le Makefile ne passe alors pas `-e proxmox_clone_memoire_min` ; la garde `when:` de la tache saute proprement. Trois silences qui s'enchainent et une flotte muette. CE QUE CETTE GARDE LIT, ET C'EST DELIBEREMENT MECANIQUE : chaque `$$SETOPS_X` que la cible `creer-vm` consomme doit figurer dans `CHAMPS_PROXMOX`, la table qui les EMET. Elle ne juge pas la valeur — elle refuse qu'un maillon manque. """ mk = (RACINE / "Makefile").read_text(encoding="utf-8") ih = (RACINE / "scripts" / "inventory_host.py").read_text(encoding="utf-8") # Le bloc de la cible `creer-vm` : de son entete a la ligne vide qui la ferme. m = re.search(r"^creer-vm:.*?(?=^\S|\Z)", mk, re.S | re.M) if not m: return False, "cible `creer-vm` introuvable dans le Makefile — la garde ne lit plus rien." consommes = set(re.findall(r"\$\$\{?(SETOPS_[A-Z_]+)", m.group(0))) if not consommes: return False, "aucun `$SETOPS_*` lu dans `creer-vm` — la garde ne lit plus rien." # La table qui les emet, plus les variables posees a la main autour d'elle. emis = set(re.findall(r'\("(SETOPS_[A-Z_]+)"', ih)) emis |= set(re.findall(r"^\s*(SETOPS_[A-Z_]+)\s*=", ih, re.M)) emis |= set(re.findall(r'"(SETOPS_[A-Z_]+)"\s*:', ih)) manquants = sorted(consommes - emis) if manquants: return False, ("transmis au clonage sans source dans `CHAMPS_PROXMOX` : " + ", ".join(manquants) + " — la VM naitra sans, et aucune etape n'echouera.") return True, f"{len(consommes)} parametre(s) de clonage, tous emis par l'inventaire." def preuve_gabarit_minimal_et_repris() -> tuple[bool, str]: """Le gabarit ne porte que l'indispensable, et rien de retire n'est perdu. LE GABARIT ETAIT UN CACHE DU SOCLE, ET IL PERIMAIT SANS LE DIRE (2026-09-01). Il portait dix-sept roles — exactement ceux que le deploiement rejoue. Derniere recapture le 2026-08-09, et six d'entre eux avaient change depuis. Le deploiement masquait la derive en reappliquant tout : personne ne pouvait la voir. Il est desormais MINIMAL — seulement ce qui doit exister AVANT qu'Ansible puisse agir. Cette preuve garde les deux moities de cette decision : 1. il ne REGROSSIT pas. Un role ajoute ici recree le cache, donc la peremption invisible. La liste est courte et chaque entree se justifie par une condition d'existence, jamais par la vitesse. 2. rien de ce qu'il ne porte plus n'est PERDU. Un role retire du gabarit et absent du socle disparaitrait de toutes les machines neuves — sans erreur, sans trace, et la panne arriverait des mois plus tard sur une machine qu'on croyait durcie. C'est le mode de defaillance le plus couteux de cette famille. """ def roles_de(chemin: str) -> list[str]: data = yaml.safe_load((RACINE / chemin).read_text(encoding="utf-8")) or [] out: list[str] = [] for play in data: for r in (play.get("roles") or []): nom = r if isinstance(r, str) else (r or {}).get("role") if nom: out.append(str(nom)) return out gabarit = roles_de("playbooks/modeles_vm/debian13_proxmox_preparer.yml") couverture = set(roles_de("playbooks/groupes/serveur_debian.yml")) | set( roles_de("playbooks/groupes/serveur_durci.yml")) # CE QUI DOIT EXISTER AVANT ANSIBLE, et qui ne peut donc pas venir de lui. # Elargir cette liste est une DECISION, pas un ajustement : elle doit passer par # cette preuve, et par la question « pourquoi Ansible ne peut-il pas le faire ? ». indispensables = { "qemu_guest_agent": "l'agent invite repond avant SSH — P52 s'en sert pour " "confirmer la materialisation sans entrer chez le tenant", "cloud_init": "le seul chemin vers la premiere seconde : adresse, nom, cles", "sudo_ansible": "le compte technique et son sudo — la porte par ou tout entre", "ssh_baseline": "le serveur SSH, meme raison un cran plus bas", } fautes: list[str] = [] for r in gabarit: if r not in indispensables: fautes.append(f"`{r}` est dans le gabarit sans etre indispensable au premier " f"demarrage — le gabarit redevient un cache du socle, qui " f"perime sans le dire") manquants = [r for r in indispensables if r not in gabarit] if manquants: fautes.append("le gabarit ne porte plus " + ", ".join(f"`{m}`" for m in manquants) + " — une VM neuve serait injoignable ou muette") # LE CONTRAT AVEC LE SOCLE : ce que le gabarit ne fait plus, quelqu'un le fait. ancien_gabarit = { "common_packages", "chrony", "systemd_ssh_auto", "motd", "hardening_packages", "sysctl_hardening", "core_dumps", "unattended_upgrades", "apparmor", "auditd", "fail2ban_ssh", "journald", "ssh_hardening", "nftables_baseline", } perdus = sorted(r for r in ancien_gabarit if r not in couverture and r not in gabarit) if perdus: fautes.append("retire(s) du gabarit et repris par PERSONNE : " + ", ".join(f"`{r}`" for r in perdus) + " — les machines neuves ne l'auraient plus, sans erreur ni trace") if fautes: return False, "Gabarit :\n - " + "\n - ".join(fautes) return True, (f"Gabarit minimal : {len(gabarit)} role(s), tous indispensables au " f"premier demarrage ; {len(ancien_gabarit)} role(s) retire(s), tous " f"repris par le socle ou le durcissement.") def preuve_sondes_declarees_et_deposees() -> tuple[bool, str]: """Une sonde declaree existe-t-elle vraiment, et un role qui en depose une la declare-t-il ? CE QU'ELLE FERME (2026-09-09). `docs/dependances-groupes.yml` porte une ligne `surveillance:` pour DIX-NEUF groupes sur dix-neuf. Icinga en surveillait DEUX. Des intentions ecrites, versionnees, relues — et qu'aucun mecanisme n'executait. C'est la classe d'echec de ce depot, en version documentaire : la carte dit ce qui est surveille, et personne ne surveille. La supervision se declare desormais dans `roles//meta/supervision.yml`, et le moteur en derive les objets Icinga ET la permission d'API. Cette preuve tient les deux bouts de la declaration, parce que chacun peut se rompre seul : 1. une sonde DECLAREE dont le script n'est pas depose : le service existerait dans Icinga et n'aurait jamais de resultat. Le `ttl` le perimerait — un service « expire » qui ne l'a jamais ete, que personne ne saurait interpreter ; 2. un script DEPOSE que rien ne declare : la sonde tournerait, pousserait, et le compte d'API la refuserait (le filtre derive des declarations). Symptome : « 404 No objects found » sur un objet inexistant — une heure perdue le 2026-09-02 sur exactement ce message. CE QU'ELLE NE FAIT PAS. Elle ne juge pas si la sonde MESURE quelque chose d'utile, ni si elle peut echouer. Ca, aucune lecture statique ne le dira : c'est au controle negatif de chaque sonde, exige par `docs/supervision-conception.md` et trace au CHANGELOG. """ roles = RACINE / "roles" fautes, mesures = [], [] for meta in sorted(roles.glob("*/meta/supervision.yml")): role = meta.parent.parent.name try: data = yaml.safe_load(meta.read_text(encoding="utf-8")) or {} except yaml.YAMLError as e: fautes.append(f"{role} : `meta/supervision.yml` illisible ({e})") continue sondes = data.get("sondes") or [] if not sondes: fautes.append(f"{role} : `meta/supervision.yml` ne declare aucune sonde") continue taches = (roles / role / "tasks" / "main.yml") corps = taches.read_text(encoding="utf-8") if taches.is_file() else "" # UN ROLE DE GROUPE N'A PAS DE TACHES, ET C'EST LA STRUCTURE DU DEPOT (2026-09-10). # # `serveur_debian` et `serveur_durci` sont des roles de DECLARATION : ils portent # `flux.yml`, `authentification.yml`, `supervision.yml` — et pas une seule tache. # Le travail est fait par les roles que leur playbook de groupe APPLIQUE # (`common_packages`, `hardening_packages`, `auditd`…). # # Cette preuve exigeait que la declaration et le depot vivent dans le MEME role. # C'etait vrai des vingt-deux premieres sondes — toutes portees par des roles qui # sont a la fois groupe et travail — et faux des qu'une sonde appartient au SOCLE. # Elle refusait alors une declaration correcte, ce qui est le pire defaut d'une # garde : forcer a contourner ce qu'elle protege. # # On suit donc le playbook du groupe : ce qu'il applique compte comme depose. La # garde ne s'affaiblit pas — elle apprend ou le depot a le droit de vivre. if not corps.strip(): playbook = RACINE / "playbooks" / "groupes" / f"{role}.yml" if playbook.is_file(): for applique in re.findall(r"^\s+- ([a-z][a-z0-9_]*)\s*$", playbook.read_text(encoding="utf-8"), re.M): t = roles / applique / "tasks" / "main.yml" if t.is_file(): corps += t.read_text(encoding="utf-8") for s in sondes: nom = s.get("nom") if not nom: fautes.append(f"{role} : une sonde sans `nom`") continue if not s.get("raison"): fautes.append(f"{role}/{nom} : aucune `raison` — un service dont personne " f"ne sait ce qu'il mesure n'est pas une supervision") if not isinstance(s.get("ttl"), int): fautes.append(f"{role}/{nom} : `ttl` manquant — sans lui, le SILENCE " f"n'alerte pas, et c'est la moitie de l'interet") # Le role doit deposer le script au chemin du contrat. if f"/usr/local/lib/setops/sondes/{nom}.sh" not in corps: fautes.append(f"{role}/{nom} : declaree mais jamais deposee dans " f"`tasks/main.yml` — le service existerait sans jamais avoir " f"de resultat") mesures.append(f"{role}/{nom}") # L'AUTRE SENS : un script depose que rien ne declare. # Les sondes declarees PARTOUT, y compris par un role de groupe qui delegue son depot. _declarees_globalement = {m.split("/", 1)[1] for m in mesures} for taches in sorted(roles.glob("*/tasks/main.yml")): role = taches.parent.parent.name corps = taches.read_text(encoding="utf-8") deposees = set(re.findall(r"/usr/local/lib/setops/sondes/([A-Za-z0-9_.-]+)\.sh", corps)) meta = roles / role / "meta" / "supervision.yml" declarees = set() if meta.is_file(): data = yaml.safe_load(meta.read_text(encoding="utf-8")) or {} declarees = {s.get("nom") for s in (data.get("sondes") or [])} for orpheline in sorted(deposees - declarees - _declarees_globalement): fautes.append(f"{role}/{orpheline} : deposee mais absente de " f"`meta/supervision.yml` — le compte d'API la refuserait") if fautes: return False, "Sondes de supervision :\n - " + "\n - ".join(fautes) if not mesures: return True, ("Aucune sonde declaree — la supervision se limite a `sante` et " "`sauvegarde` (cf. docs/supervision-conception.md).") return True, (f"{len(mesures)} sonde(s) declaree(s) ET deposee(s), chacune avec sa " f"raison et son `ttl` : {', '.join(mesures)}.") def preuve_cloud_init_rendu_puis_retire() -> tuple[bool, str]: """cloud-init nait avec la VM, et ne lui survit pas. LE POUVOIR QU'IL GARDE (decision du 2026-09-09). cloud-init n'est pas un logiciel d'installation : c'est une SOURCE DE VERITE EXTERNE. A chaque demarrage il relit le lecteur attache par l'hyperviseur, qui peut redefinir comptes, cles SSH autorisees, mots de passe et reseau. Sur une machine que le plan possede, c'est un second maitre — que le plan ne decrit pas, que `make valider` ne mesure pas, et qui gagne parce qu'il parle en premier. Sa tache est pourtant finie a la premiere seconde : c'est parce qu'il a REUSSI a poser l'adresse et les cles qu'Ansible a pu entrer. TROIS MOITIES, ET ELLES SE DEFONT SEPAREMENT : 1. le GABARIT le garde. Sans lui, un clone n'a ni adresse ni nom : il ne nait pas. (P56 le tient deja comme indispensable ; on le redit ici parce que c'est la moitie qu'on serait tente de retirer « pour faire propre ».) 2. le SOCLE ne l'installe plus. Le garder produisait un va-et-vient a chaque deploiement — le socle installe, le durcissement retire, deux `changed` par passage, l'idempotence perdue et `make valider` bruyant pour rien. 3. le DURCISSEMENT le retire. C'est la moitie qui porte l'intention ; sans elle, les deux autres ne font que deplacer le probleme. Une seule des trois qui bouge, et la decision devient son contraire en silence : un gabarit sans cloud-init donne des VM mortes ; un socle qui le reinstalle rend le pouvoir a chaque passage ; un durcissement qui ne le retire plus laisse le second maitre en place sans que rien ne le dise. """ def roles_de(chemin: str) -> list[str]: data = yaml.safe_load((RACINE / chemin).read_text(encoding="utf-8")) or [] out: list[str] = [] for play in data: for r in (play.get("roles") or []): nom = r if isinstance(r, str) else (r or {}).get("role") if nom: out.append(str(nom)) return out gabarit = roles_de("playbooks/modeles_vm/debian13_proxmox_preparer.yml") socle = roles_de("playbooks/groupes/serveur_debian.yml") durci = roles_de("playbooks/groupes/serveur_durci.yml") fautes = [] if "cloud_init" not in gabarit: fautes.append("le GABARIT ne porte plus `cloud_init` — un clone naitrait sans " "adresse ni nom d'hote, donc injoignable") if "cloud_init" in socle: fautes.append("le SOCLE applique encore `cloud_init` alors que le durcissement le " "retire — va-et-vient a chaque deploiement, idempotence perdue") if "cloud_init_retrait" not in durci: fautes.append("le DURCISSEMENT n'applique plus `cloud_init_retrait` — la source de " "verite externe reste en place, et rien ne le dit") # Le role doit REELLEMENT retirer le paquet : une coquille vide passerait les trois # controles ci-dessus tout en ne fermant rien. defauts = RACINE / "roles" / "cloud_init_retrait" / "defaults" / "main.yml" taches = RACINE / "roles" / "cloud_init_retrait" / "tasks" / "main.yml" if not taches.is_file(): fautes.append("`roles/cloud_init_retrait/tasks/main.yml` est absent") else: corps = yaml.safe_load(taches.read_text(encoding="utf-8")) or [] retire = any((t.get("ansible.builtin.apt") or {}).get("state") == "absent" for t in corps if isinstance(t, dict)) if not retire: fautes.append("`cloud_init_retrait` ne retire aucun paquet (`state: absent` " "absent) — le role existe mais ne ferme rien") garde = any("assert" in cle for t in corps if isinstance(t, dict) for cle in t) if not garde: fautes.append("`cloud_init_retrait` ne verifie plus que la configuration " "reseau survit au retrait — une VM sans adresse ne se plaint " "pas, elle disparait") if defauts.is_file(): d = yaml.safe_load(defauts.read_text(encoding="utf-8")) or {} if "cloud-init" not in (d.get("cloud_init_retrait_paquets") or []): fautes.append("`cloud_init_retrait_paquets` ne nomme plus `cloud-init`") if fautes: return False, "cloud-init :\n - " + "\n - ".join(fautes) return True, ("cloud-init est au gabarit (la premiere seconde), absent du socle (pas " "de va-et-vient), et retire par le durcissement — avec la garde qui " "verifie que le reseau survit au retrait.") def preuve_cle_du_site_bornee_au_runner() -> tuple[bool, str]: """La cle du SITE ne nait que sur le runner d'un tenant. Celle du tenant, partout chez lui. LE POUVOIR SE LIT DANS LES CLES, PAS DANS LES INTENTIONS. Une VM recoit ses cles a la NAISSANCE, par cloud-init — et personne ne relit un `authorized_keys` pose il y a six mois. Si la cle du site se mettait a partir sur toutes les machines d'un locataire, l'hebergeur obtiendrait un acces SSH a sa flotte entiere, en silence, et rien dans le plan ne le dirait. C'est le pendant exact du flux d'insemination : celui-ci vise le groupe `serveur_ops_tenant`, cette preuve verifie que la CLE suit le meme critere. Une frontiere tenue a une seule couche n'est pas tenue. L'AUTRE MOITIE COMPTE AUSSI : sans la cle du TENANT sur ses machines, son runner recoit un terrain qu'il ne peut pas occuper — mesure du 2026-08-30, quinze VM et quinze « Permission denied ». """ inv = RACINE / INV if not inv.is_file(): return True, "Aucun inventaire monte : rien a verifier." sys.path.insert(0, str(RACINE / "scripts")) import inventory_host as ih data = yaml.safe_load(inv.read_text(encoding="utf-8")) or {} actifs = sorted(ih._hotes_du_groupe(data, "hotes_actifs") if hasattr(ih, "_hotes_du_groupe") else (data.get("all", {}).get("children", {}) .get("hotes_actifs", {}).get("hosts", {}) or {})) if not actifs: return True, "Aucun hote actif : rien a verifier." cle_site = "" try: import underlay as underlay_mod cle_site = str((underlay_mod.lire_plan_site("10-intrants.yml") or {}) .get("runner_cle_publique") or "").strip() except Exception: pass runners = set(ih.groupes_hote(data, h) and h for h in actifs if ih.GROUPE_RUNNER_TENANT in ih.groupes_hote(data, h)) fautes: list[str] = [] porteurs_site: list[str] = [] sans_cle_tenant: list[str] = [] for h in actifs: recues = [c for c in ih._cle_amorcage(data, h, inv).split("\n") if c.strip()] if cle_site and cle_site in recues: porteurs_site.append(h) if h not in runners: fautes.append(f"{h} recoit la cle du SITE sans porter " f"`{ih.GROUPE_RUNNER_TENANT}` — l'hebergeur y gagnerait " f"un acces qu'aucun flux ne declare") if not [c for c in recues if c != cle_site]: sans_cle_tenant.append(h) # Sans cle du tenant, le runner ne peut pas occuper le terrain qu'on lui prepare. # On ne l'exige que si l'instance en declare une : un plan qui n'en nomme aucune # se materialise comme avant, avec la seule cle de l'exploitant. if ih._cles_admin_du_plan(inv) and sans_cle_tenant: fautes.append(f"{len(sans_cle_tenant)} hote(s) ne recevraient AUCUNE cle de " f"leur tenant : son runner ne pourrait pas les configurer " f"({', '.join(sans_cle_tenant[:3])})") if fautes: return False, "Les cles de naissance debordent :\n - " + "\n - ".join(fautes) return True, (f"{len(actifs)} hote(s) : la cle du SITE ne nait que sur " f"{len(porteurs_site)} runner(s) de tenant, celle du tenant sur " f"{len(actifs) - len(sans_cle_tenant)}.") def preuve_insemination_sans_secret() -> tuple[bool, str]: """Ce que le SITE pose chez un tenant ne demande AUCUN secret de ce tenant. POURQUOI. Un ecosysteme neuf ne s'amorce pas lui-meme : quelqu'un doit poser sa premiere machine. Le runner du SITE le fait — socle, moteur, plan, plancher — puis s'arrete. Il s'arrete parce qu'il N'A PAS LA CLE, pas parce qu'une regle le lui interdit : la voute d'un tenant ne vit dans aucun depot et son mot de passe sur aucune de ses machines. Cette preuve garde la ligne a l'endroit ou elle pourrait glisser SANS BRUIT : le jour ou l'on ajouterait une couche a `COUCHES_INSEMINATION` pour « aller un peu plus loin », et ou cette couche reclamerait un `vault_*`. Le deploiement echouerait alors chez le tenant, sur une valeur vide — un message qui ne dit pas qu'un POUVOIR a ete franchi. Elle mesure la propriete, pas l'intention : les roles reellement appliques, lus dans leurs playbooks, ne doivent citer aucune variable de voute. """ mk = RACINE / "Makefile" txt = mk.read_text(encoding="utf-8") m = re.search(r"^COUCHES_INSEMINATION \?=(.*)$", txt, re.M) if not m: return False, "`COUCHES_INSEMINATION` n'est pas declare dans le Makefile." groupes = m.group(1).split() if not groupes: return False, "`COUCHES_INSEMINATION` est vide : le site ne poserait rien." fautes: list[str] = [] roles_vus: list[str] = [] for g in groupes: pb = RACINE / "playbooks" / "groupes" / f"{g}.yml" if not pb.is_file(): fautes.append(f"{g} : aucun playbook `playbooks/groupes/{g}.yml`") continue joue = yaml.safe_load(pb.read_text(encoding="utf-8")) or [] roles = [r if isinstance(r, str) else (r or {}).get("role") for r in ((joue[0] if joue else {}) or {}).get("roles") or []] for role in [r for r in roles if r]: roles_vus.append(role) d = RACINE / "roles" / role if not d.is_dir(): fautes.append(f"{g} : role introuvable `{role}`") continue for f in sorted(list(d.rglob("*.yml")) + list(d.rglob("*.j2"))): texte = f.read_text(encoding="utf-8", errors="ignore") trouves = sorted(set(re.findall(r"\bvault_[a-z0-9_]+", texte))) if trouves: fautes.append( f"{g} -> {role} cite {', '.join(trouves[:3])} dans " f"{f.relative_to(RACINE)} : le SITE ne detient pas cette voute") if fautes: return False, ("L'insemination reclamerait un secret du tenant :\n - " + "\n - ".join(fautes)) return True, (f"{len(groupes)} couche(s) d'insemination ({', '.join(groupes)}), " f"{len(set(roles_vus))} role(s) applique(s), aucun secret de tenant reclame.") def preuve_interne_parle_bordure_se_tait() -> tuple[bool, str]: """L'interne REFUSE A VOIX HAUTE, la bordure reste MUETTE. POURQUOI (2026-08-28). Sous `drop` partout, un timeout voulait dire trois choses incompatibles : aucune machine, aucune route, ou une politique. Deux erreurs de diagnostic en une journee, dont une qui a fait appliquer un pare-feu pour reparer une ABSENCE. Ce qui informe n'est pas le refus — c'est ce qu'il fait au SILENCE : quand la politique parle, un timeout ne veut plus dire qu'une chose. L'OBSCURITE ETAIT DEJA NULLE A L'INTERIEUR : chaque machine porte un `/etc/hosts` genere qui liste toutes ses voisines avec leurs adresses. Se cacher de pairs qui ont deja notre adresse ne protege de rien, et coute le diagnostic. LA BORDURE, ELLE, DOIT SE TAIRE, et c'est ce qui rend le reject interne prudent : rien d'indeclare ne franchit le perimetre, donc ce reject ne repond jamais a l'Internet. Mesure du 2026-08-27 a la frontiere : 982 000 entrees par jour, dont 82 % un balayage contre le port VNC. Y repondre serait un vecteur d'amplification, source usurpee comprise. Cette preuve garde les deux moities ENSEMBLE, parce que l'une sans l'autre est fausse : une bordure bavarde s'expose, un interne muet ment. """ fautes: list[str] = [] mesures: list[str] = [] # 1. nftables d'hote : chaque ruleset genere doit REFUSER A VOIX HAUTE. src = (RACINE / "scripts" / "resoudre_flux.py").read_text(encoding="utf-8") if "reject with icmpx type admin-prohibited" not in src: fautes.append("le gabarit nftables n'emet pas de `reject with icmpx type " "admin-prohibited` : l'interne resterait muet") # `tcp reset` serait indiscernable d'un port ferme sans service. if "reject with tcp reset" in src: fautes.append("le gabarit nftables emet un `tcp reset`, indiscernable d'un port " "ferme sans service — `admin-prohibited` dit qu'une POLITIQUE refuse") rulesets = sorted((INSTANCE / "flux-genere").glob("*.nft")) muets = [f.name for f in rulesets if "reject with icmpx type admin-prohibited" not in f.read_text(encoding="utf-8")] if muets: fautes.append(f"{len(muets)} ruleset(s) genere(s) sans refus audible : " + ", ".join(muets[:4])) mesures.append(f"{len(rulesets)} ruleset(s) nftables refusent a voix haute") # 2. Pare-feu est-ouest Proxmox : politique REJECT, et UNE SEULE source. ew = (RACINE / "scripts" / "appliquer_proxmox_fw.py").read_text(encoding="utf-8") if 'POLITIQUE_VM = "REJECT"' not in ew: fautes.append("le pare-feu est-ouest ne pose pas `POLITIQUE_VM = \"REJECT\"`") # DEUX LITTERAUX POUR LA MEME DECISION, c'est une divergence differee : l'ecriture de # la politique et la detection de son ecart doivent lire la MEME source. # # ON NE CHERCHE PAS LE MOT « DROP », ON CHERCHE UNE ASSIGNATION EN DUR. Ma premiere # version refusait toute occurrence du litteral, et elle a fait echouer un code # JUSTE : `dc["politique"].upper() in ("DROP", "REJECT")` detecte une politique posee # AU DATACENTER, ou elle vaudrait pour tout le parc — c'est un garde-fou, pas un # reglage. Une preuve qui interdit un mot au lieu de mesurer une propriete finit par # accuser ce qu'elle devrait proteger. if re.search(r'"policy_in":\s*"', ew): fautes.append("la politique de la VM est ecrite en dur : elle doit venir de " "`POLITIQUE_VM`, seule source") if re.search(r'\["politique"\]\s*!=\s*"', ew): fautes.append("la detection d'ecart compare a un litteral : elle doit lire " "`POLITIQUE_VM`, sinon les deux divergeront") mesures.append("pare-feu est-ouest en REJECT, source unique") # 3. La bordure se tait — et ce n'est PAS une preference, c'est la condition de # prudence du reject interne. Les actions du devis OPNsense restent `pass`/`block`. front = (RACINE / "scripts" / "devis_opnsense.py").read_text(encoding="utf-8") if '"reject"' in front or "'reject'" in front: fautes.append("le devis de la frontiere emet un `reject` : la bordure doit rester " "muette, sans quoi elle repond aux balayages d'Internet") actions = sorted(set(re.findall(r'"action":\s*"(\w+)"', front)) | set(re.findall(r'action.*?or\s+"(\w+)"', front))) mesures.append("frontiere muette (actions : " + ", ".join(actions or ["pass"]) + ")") if fautes: return False, "La ligne n'est plus tenue :\n - " + "\n - ".join(fautes) return True, "L'interne parle, la bordure se tait — " + " ; ".join(mesures) + "." def preuve_materialiser_n_exige_pas_d_entrer() -> tuple[bool, str]: """Creer une VM ne demande pas d'entrer dans le tenant qui l'heberge. POURQUOI (mesure du 2026-08-27). `creer-vm` confirmait son succes en attendant une reponse SSH. Le runner du SITE materialise le terrain de TOUS les tenants, mais la frontiere lui refuse d'entrer chez eux — c'est le sens meme de leur isolation. La premiere VM qu'il a creee a donc ete declaree en echec apres 600 secondes alors qu'elle tournait, avec l'adresse exacte que le plan lui destinait. La tentation etait d'ouvrir le SSH du runner vers tous les tenants. Ca aurait « repare » la mesure en detruisant ce qu'elle protege : une machine capable d'entrer chez chaque locataire est precisement ce que cette architecture refuse d'avoir. L'agent invite repond sans rien ouvrir, et prouve DAVANTAGE — le systeme, le noyau et l'adresse effectivement portee, la ou SSH ne disait que « quelque chose ecoute sur le port 22 ». Cette preuve garde le couplage ferme : materialiser et configurer sont deux metiers, et seul le second exige d'entrer. """ mk = RACINE / "Makefile" if not mk.is_file(): return False, "Makefile introuvable." txt = mk.read_text(encoding="utf-8") debut = txt.find("\ncreer-vm:") if debut < 0: return False, "La cible `creer-vm` a disparu du Makefile." # La recette court jusqu'a la prochaine cible en debut de ligne. suite = txt[debut + 1:] fin = re.search(r"\n[A-Za-z_][A-Za-z0-9_.-]*:", suite) recette = suite[:fin.start()] if fin else suite outil = RACINE / "scripts" / "attendre_materialisation.py" fautes = [] if "_attendre-hote" in recette: fautes.append("`creer-vm` appelle `_attendre-hote`, qui exige SSH : un runner de " "SITE ne peut pas entrer chez un tenant, et ne doit pas pouvoir.") if not outil.is_file(): fautes.append("scripts/attendre_materialisation.py est absent.") elif "attendre_materialisation.py" not in recette: fautes.append("`creer-vm` ne confirme plus la materialisation : ni SSH ni agent " "invite. Une creation qui ne verifie rien n'est pas une creation.") if fautes: return False, " | ".join(fautes) return True, ("`creer-vm` confirme par l'agent invite (API des hyperviseurs, deja " "utilisee pour creer), sans exiger d'entrer dans le tenant.") def preuve_collections_declarees_et_epinglees() -> tuple[bool, str]: """Toute collection appelee par le moteur est DECLAREE, et a une version EPINGLEE. DEUX PANNES, LA MEME CAUSE — le moteur ne disait pas de quoi il depend. Le 2026-08-24, `ansible.posix` etait utilisee depuis longtemps sans figurer dans `requirements.yml` : elle trainait sur le poste du mainteneur, et manquait partout ailleurs. Le runner n'aurait pas pu deployer les sauvegardes. Le 2026-08-27, la meme faiblesse a frappe d'un autre cote. Les collections etaient nommees mais SANS VERSION : le runner du site, monte un jour plus tard, a recu `community.general` 13.3.0 quand le poste porte la 10.3.0. La version 11 ayant sorti les modules Proxmox de cette collection, la premiere materialisation de VM depuis le runner a echoue sur `couldn't resolve module/action community.general.proxmox_pool` — avec le meme depot, le meme playbook et le meme plan que chez le mainteneur. Une dependance non epinglee n'est pas une dependance : c'est un pari sur l'etat d'Internet a la date du deploiement. Un moteur qui se veut portable, et exploitable par un sysadmin sans IA, doit pouvoir se reconstruire a l'identique dans six mois. On lit les MODULES REELLEMENT APPELES dans les fichiers YAML — la clef d'une tache, pas une chaine dans du Python : `ansible.parsing.vault` est un import, pas une collection, et l'y confondre ferait crier la preuve pour rien. """ req = RACINE / "requirements.yml" if not req.is_file(): return False, "requirements.yml est introuvable — le moteur ne declare aucune dependance." try: import yaml as _y decl = (_y.safe_load(req.read_text(encoding="utf-8")) or {}).get("collections") or [] except Exception as e: # noqa: BLE001 return False, f"requirements.yml est illisible : {e}" declarees = {} for c in decl: if isinstance(c, dict) and c.get("name"): declarees[str(c["name"])] = str(c.get("version") or "").strip() elif isinstance(c, str): declarees[c] = "" # Une clef de tache YAML : ` - community.general.ldap_entry:` ou ` module.x.y:` motif = re.compile(r"^\s*(?:-\s+)?([a-z0-9_]+\.[a-z0-9_]+)\.[a-z0-9_]+\s*:", re.M) # ON NE LIT QUE LES FICHIERS DE TACHES. Un `defaults/main.yml` peut contenir # `net.ipv4.ip_forward: 1` — une clef de sysctl que le motif prendrait pour un module, # et la preuve crierait a une collection manquante nommee `net.ipv4`. Un module ne # peut apparaitre que la ou il y a des taches. fichiers = list((RACINE / "playbooks").rglob("*.yml")) for sous in ("tasks", "handlers"): fichiers += list((RACINE / "roles").rglob(f"{sous}/*.yml")) utilisees: dict[str, str] = {} for f in fichiers: try: txt = f.read_text(encoding="utf-8") except Exception: # noqa: BLE001 continue for coll in motif.findall(txt): if coll == "ansible.builtin": continue utilisees.setdefault(coll, str(f.relative_to(RACINE))) manquantes = [f"{c} (ex. {utilisees[c]})" for c in sorted(utilisees) if c not in declarees] sans_epingle = [c for c in sorted(declarees) if not declarees[c]] fautes = [] if manquantes: fautes.append("appelee(s) mais NON DECLAREE(S) dans requirements.yml : " + ", ".join(manquantes)) if sans_epingle: fautes.append("declaree(s) SANS VERSION — le deploiement prendra ce qui passe : " + ", ".join(sans_epingle)) # LES BIBLIOTHEQUES PYTHON DU CONTROLEUR, MEME EXIGENCE (2026-08-27). Les collections # etaient declarees ; ce dont leurs modules dependent ne l'etait nulle part. Le venv # d'un poste recevait `ansible-core` et `pyyaml`, ecrits en dur dans le role. Le runner # a echoue sur « la bibliotheque Python proxmoxer est absente » — que le poste du # mainteneur possedait par son paquet systeme, sans que personne ne l'ait declaree. py = RACINE / "requirements-python.txt" if not py.is_file(): fautes.append("requirements-python.txt est absent : les bibliotheques du " "controleur ne sont declarees nulle part.") else: lignes = [l.strip() for l in py.read_text(encoding="utf-8").splitlines() if l.strip() and not l.strip().startswith("#")] floues = [l for l in lignes if "==" not in l] if not lignes: fautes.append("requirements-python.txt ne declare aucune bibliotheque.") if floues: fautes.append("bibliotheque(s) Python sans version exacte : " + ", ".join(floues)) # Le moteur appelle des modules Proxmox EN LOCAL : `proxmoxer` est donc exige sur # le controleur. C'est la dependance dont l'absence a bloque la premiere # materialisation depuis le runner ; on la nomme pour qu'elle ne reparte pas. if any((RACINE / "playbooks").rglob("*proxmox*")) and \ not any(l.lower().startswith("proxmoxer") for l in lignes): fautes.append("le moteur porte des playbooks Proxmox mais ne declare pas " "`proxmoxer`, exige sur le CONTROLEUR.") py_n = len(lignes) if fautes: return False, " | ".join(fautes) return True, (f"{len(utilisees)} collection(s) et {py_n} bibliotheque(s) Python " f"declarees et epinglees : " + ", ".join(f"{c}=={declarees[c]}" for c in sorted(utilisees))) def preuve_silences_declares() -> tuple[bool, str]: """Un `block` muet doit etre DECLARE, place APRES les `pass`, et porter son motif. POURQUOI (mesure du 2026-08-27). Le journal de la frontiere ecrivait 982 000 entrees par jour, dont 82 % un balayage Internet contre le port VNC. Sa fenetre utile etait tombee a QUARANTE-QUATRE SECONDES : j'y ai cherche la trace d'un flux du site vers les hyperviseurs, je n'ai rien trouve, et j'en ai conclu a tort qu'aucune regle ne bloquait. Un journal noye ment aussi surement qu'un journal mort. La reponse — taire ce qui est deja refuse — introduit dans l'outil sa PREMIERE regle de blocage. Elle amene deux dangers que cette preuve garde : 1. L'ORDRE. OPNsense evalue en `quick` : la premiere regle qui correspond gagne. Un blocage large emis avant les `pass` du devis fermerait d'un coup le courrier, le web et l'acces distant. Les silences doivent donc porter une sequence STRICTEMENT superieure a celle de toute regle `pass`. 2. LE MOTIF. Une regle `block` sans raison ecrite est indiscernable d'un oubli pour qui relira cette frontiere dans six mois. Le silence se justifie ou n'existe pas. On rejoue le devis en memoire — rien n'est ecrit, rien n'est joint sur le reseau. """ try: sys.path.insert(0, str(RACINE / "scripts")) import devis_opnsense as DO devis = DO.construire(DO.decouvrir_du_site()) except Exception as e: # noqa: BLE001 return False, (f"Le devis de la frontiere n'a pas pu etre rejoue : {e}. " f"Sans lui, on ne sait pas ce que la frontiere recevrait.") regles = devis.get("regles") or [] silences = [r for r in regles if str(r.get("action") or "pass") != "pass"] if not silences: return True, ("Aucune regle de blocage au devis — rien a garder. La preuve " "redeviendra parlante des qu'un silence sera declare.") seq_pass = [int(r.get("sequence") or 1) for r in regles if str(r.get("action") or "pass") == "pass"] plancher = max(seq_pass) if seq_pass else 1 fautes = [] for r in silences: seq = int(r.get("sequence") or 1) if seq <= plancher: fautes.append( f"{r['interface']}/{r['role']} : sequence {seq} <= {plancher}, la plus " f"haute des regles `pass`. En `quick`, ce blocage gagnerait AVANT elles.") if r.get("journaliser"): fautes.append(f"{r['interface']}/{r['role']} : consigne — ce n'est pas un silence.") raison = str(r.get("raison") or "").strip() if len(raison) < 30: fautes.append( f"{r['interface']}/{r['role']} : motif absent ou trop court " f"({len(raison)} car.). Un blocage muet sans raison ecrite est un oubli.") if fautes: return False, " | ".join(fautes) return True, (f"{len(silences)} silence(s) declare(s), tous en sequence > {plancher} " f"(la plus haute des {len(seq_pass)} regles `pass`), tous non consignes " f"et tous motives.") def preuve_registre_flux_a_jour() -> tuple[bool, str]: """La matrice d'audit des flux dit ce que le moteur declare AUJOURD'HUI. POURQUOI (mesure du 2026-08-26). `docs/registre-flux.md` est GENERE depuis les `roles/*/meta/flux.yml`, et c'est le document qu'un humain lit pour savoir ce que le pare-feu laisse passer. Son existence etait verifiee (P31) ; sa FRAICHEUR ne l'etait pas. Il avait derive sans bruit : la garde d'administration y portait encore `10.0.0.0/24` alors que le reseau d'administration vaut `10.17.0.0/24`, et deux flux `client_resolveur` ajoutes depuis n'y figuraient pas. Un lecteur y aurait lu un pare-feu qui n'existe plus. L'INVENTAIRE AVAIT DEJA SA GARDE — P03, le diff-vide du plan. Le registre des flux est le meme genre d'artefact : genere, versionne, lu par un humain. Il lui manquait la meme. `generer_registre` est une fonction PURE : on la rejoue en memoire et on compare. Rien n'est ecrit — une preuve qui repare ce qu'elle mesure ne mesure plus rien. """ fichier = RACINE / "docs" / "registre-flux.md" if not fichier.is_file(): return False, "docs/registre-flux.md est introuvable." try: sys.path.insert(0, str(RACINE / "scripts")) import resoudre_flux as RF attendu = RF.generer_registre(RF.charger_flux()) except Exception as e: # noqa: BLE001 return False, (f"Le registre des flux n'a pas pu etre rejoue : {e}. " f"Tant qu'on ne sait pas le regenerer, on ne sait pas s'il dit vrai.") actuel = fichier.read_text(encoding="utf-8") if actuel == attendu: return True, ("Le registre des flux reproduit exactement ce que les " f"`meta/flux.yml` declarent ({len(attendu.splitlines())} lignes).") a, b = actuel.splitlines(), attendu.splitlines() ecarts = [f"ligne {i + 1} : le fichier dit « {x[:70]} », le moteur « {y[:70]} »" for i, (x, y) in enumerate(zip(a, b)) if x != y][:3] if len(a) != len(b): ecarts.append(f"longueurs differentes : {len(a)} lignes contre {len(b)}") return False, ("`docs/registre-flux.md` ne dit plus ce que le moteur declare. " "Rejouer `make flux` et commiter le resultat.\n - " + "\n - ".join(ecarts)) def preuve_glossaire_enseigne() -> tuple[bool, str]: """Tout mot que le depot emploie devant l'exploitant est explique au glossaire. POURQUOI (exigence de l'exploitant, 2026-08-21). La regle fondatrice du depot est qu'un humain doit pouvoir piloter cet ecosysteme SANS IA — idealement un seul. Un vocabulaire qu'on n'explique pas est donc un defaut de conception, pas un detail de redaction : chaque mot obscur retire une personne a la liste de celles qui peuvent reprendre le systeme. Mesure du jour, qui a motive la preuve : 40 termes employes par le depot et absents du glossaire — `underlay` 106 fois, `EVPN` 66, `VRF` 33, `LDAP` 184. Le glossaire definissait le vocabulaire propre a Set-OPS (plan, index, voute) et laissait dehors tout ce qui vient du metier. CE QU'ELLE TESTE : - chaque terme de `JARGON_A_ENSEIGNER` a une entree au glossaire ; - chaque lien du glossaire vers une page du wiki mene a une page qui EXISTE ; - chaque page du wiki est atteignable depuis `_Sidebar.md` — une unite que la navigation ne cite pas n'est lue par personne. CE QU'ELLE NE PEUT PAS TESTER, et il faut le dire : qu'un mot que PERSONNE n'a declare manque a la liste. Elle garde ce qu'on lui confie ; elle ne devine pas. Et elle ne juge pas la QUALITE d'une explication — cela se lit, cela ne se mesure pas. """ wiki = RACINE / "wiki" glossaire = wiki / "Glossaire.md" if not glossaire.is_file(): return False, "wiki/Glossaire.md absent" texte = glossaire.read_text(encoding="utf-8", errors="ignore") # Comparaison SANS ACCENTS et sur des mots entiers. Le glossaire s'ecrit en francais # correct (« Frontière », « Voûte », « Rôle ») ; c'est a la preuve de s'y plier, pas # au texte de s'appauvrir. Les bornes de mot evitent le faux positif classique : # « controle » contient « role ». def _sans_accent(s: str) -> str: return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn") nu = _sans_accent(texte) manquants = [t for t in JARGON_A_ENSEIGNER if not re.search(rf"\b{re.escape(_sans_accent(t))}\b", nu, re.IGNORECASE)] pages = {p.stem for p in wiki.glob("*.md")} liens = set(re.findall(r"\]\(([A-Za-zÀ-ÿ0-9'’\-]+)\)", texte)) casses = sorted(l for l in liens if l not in pages) sidebar = (wiki / "_Sidebar.md") orphelines: list[str] = [] if sidebar.is_file(): nav = sidebar.read_text(encoding="utf-8", errors="ignore") orphelines = sorted(p for p in pages if p not in ("_Sidebar", "README", "Home") and f"({p})" not in nav) echecs = [] if manquants: echecs.append(f"{len(manquants)} terme(s) employe(s) sans entree au glossaire : " + ", ".join(manquants)) if casses: echecs.append(f"{len(casses)} lien(s) du glossaire vers une page inexistante : " + ", ".join(casses)) if orphelines: echecs.append(f"{len(orphelines)} page(s) du wiki absente(s) de la navigation : " + ", ".join(orphelines)) if echecs: return False, " | ".join(echecs) return True, (f"Glossaire complet : {len(JARGON_A_ENSEIGNER)} terme(s) du jargon " f"expliques, {len(liens)} lien(s) valides, {len(pages)} page(s) de wiki " f"toutes atteignables.") def preuve_catalogue_a_jour() -> tuple[bool, str]: """Le catalogue des services nomme TOUT ce que le moteur sait deployer, et rien d'autre. POURQUOI (mesure du 2026-08-18). `catalogue-services.md` est la carte de ce que Set-OPS FAIT — le document que lit l'hebergeur d'un second site, un futur client, un mainteneur qui arrive. Il avait quatre mois de retard : il annoncait comme « capacites futures encore a implementer » la collaboration (Nextcloud/Collabora) et la couche web (frontal/dorsal), dont les roles existent et dont les hotes sont ACTIFS ; il disait la federation LDAP de Keycloak « pas encore automatisee » alors que le role la pose ; ses tables ignoraient NEUF roles vivants (le socle, la pile courriel entiere, les sauvegardes, Icinga Web 2, oauth2-proxy, Unbound) dont deux — `serveur_backup` et `client_backup` — que le document ne nommait NULLE PART ; et il annoncait une integration `client_supervision` qui n'a jamais existe, plus deux roles inventes par une colonne « Role » decorative (`client_metriques`, `client_journaux`, au pluriel). P31 ne pouvait pas le voir : elle verifie que chaque script, cible `make` et role est NOMME et ATTEIGNABLE — pas qu'un document dise vrai. Une carte peut etre complete et perimee. CE QU'ELLE TESTE, dans les deux sens — et des DEUX cotes c'est la TABLE qui fait foi : - tout role `serveur_*` / `client_*` figure dans une ligne de table. Etre cite dans un paragraphe ne suffit pas : la pile courriel (`serveur_postfix`, `serveur_dovecot`, `serveur_rspamd`) etait racontee en prose et absente de toutes les tables — introuvable pour qui lit le catalogue comme un index, c'est-a-dire pour tout le monde ; - tout groupe cite dans une LIGNE DE TABLE du catalogue existe reellement — comme role, ou comme playbook de groupe (`serveur_durci` compose des roles de durcissement sans role homonyme). Une case de table est une affirmation d'existence : c'est ainsi que `client_supervision` a survecu des mois. CE QU'ELLE NE TESTE PAS, et c'est deliberate : - la PROSE. Le catalogue raconte son histoire, y compris les roles RETIRES (`serveur_sendmail`, `client_dns`, `client_ldap`) : les nommer hors table doit rester permis, sinon on ne peut plus ecrire d'ou l'on vient ; - que la description soit JUSTE. Qu'un service soit dit « eprouve » a bon droit se juge en revue, contre le CHANGELOG. Mecaniser ce jugement serait se mentir. """ catalogue = RACINE / "docs" / "catalogue-services.md" if not catalogue.is_file(): return False, "docs/catalogue-services.md absent" texte = catalogue.read_text(encoding="utf-8", errors="ignore") roles = {d.name for d in (RACINE / "roles").iterdir() if d.is_dir() and d.name.startswith(("serveur_", "client_"))} groupes = {p.stem for p in (RACINE / "playbooks" / "groupes").glob("*.yml")} lignes_table = [l for l in texte.splitlines() if l.lstrip().startswith("|")] cites = {m for l in lignes_table for m in re.findall(r"(?:serveur|client)_[a-z0-9_]+", l)} tus = sorted(r for r in roles if r not in cites) fantomes = sorted(c for c in cites if c not in roles and c not in groupes) echecs = [] if tus: echecs.append(f"{len(tus)} role(s) que le catalogue ne nomme pas : " + ", ".join(tus)) if fantomes: echecs.append(f"{len(fantomes)} groupe(s) annonce(s) en table sans role ni playbook : " + ", ".join(fantomes)) if echecs: return False, " | ".join(echecs) return True, (f"Catalogue a jour : {len(roles)} role(s) serveur/client tous nommes, " f"{len(cites)} groupe(s) cite(s) en table existent tous.") def preuve_documentation_outillage() -> tuple[bool, str]: """Tout ce que Set-OPS FAIT s'explique et reste atteignable. Exigence de l'exploitant, 2026-08-08 : « la doc dit et explique tout ce que Set-OPS fait, et pourquoi c'est ainsi. » Une exigence qu'on n'outille pas pourrit en silence — la carte annoncait « 28 decisions » quand il y en avait 66, et disait les acces « non construits » alors qu'ils tournaient en production. CE QU'ELLE TESTE, et pourquoi ainsi : - chaque `scripts/*.py` porte une docstring de module dont la premiere ligne explique a quoi il sert. Le premier jet verifiait plutot que le nom du script « apparaisse dans un document » : deux fois ce critere s'est revele creux — d'abord parce que le rapport d'audit GENERE recopiait les noms manquants dans son message d'echec, ensuite parce qu'un inventaire genere de l'outillage aurait fait passer la preuve au vert sans qu'une ligne soit ecrite. Un critere qu'on peut satisfaire en generant du texte ne prouve rien ; - chaque script est ATTEIGNABLE : invoque par une cible `make`, ou importe/appele par un autre script (bibliotheques partagees, scripts appeles par les preuves). Un outil que rien n'atteint est du code mort qui se documente tout seul ; - chaque cible `make` porte un texte d'aide `##`, SAUF celles prefixees `_` : convention du depot pour les cibles internes (attentes, verifications de privileges) qui ne sont pas des commandes d'exploitant. L'exemption est nommee ici pour rester un choix et non un trou ; - chaque role porte un README. CE QU'ELLE NE TESTE PAS : que l'explication soit BONNE. Le « pourquoi » se juge en revue ; il vit dans `CHANGELOG.md` (les faits mesures) et dans `decisions-architecture.md`. Pretendre le mesurer mecaniquement serait se mentir. """ manques: list[str] = [] scripts = sorted((RACINE / "scripts").glob("*.py")) textes = {f.name: f.read_text(encoding="utf-8", errors="ignore") for f in scripts} makefile = (RACINE / "Makefile").read_text(encoding="utf-8", errors="ignore") # `ast`, pas une expression reguliere : les scripts commencent par un shebang, et # mon premier motif le prenait pour l'absence de docstring — 35 faux positifs d'un # coup. Lire du Python avec le parseur de Python. muets = [] for f in scripts: try: doc = ast.get_docstring(ast.parse(textes[f.name])) or "" except SyntaxError: muets.append(f"{f.name} (illisible)") continue if len(doc.strip().splitlines()[0] if doc.strip() else "") < 30: muets.append(f.name) if muets: manques.append(f"{len(muets)} script(s) sans docstring explicative : " + ", ".join(muets[:6]) + ("…" if len(muets) > 6 else "")) # Un script peut aussi etre invoque par un ROLE ou un PLAYBOOK — `serveur_keycloak` # appelle `verifier_signature.py` pour controler la signature de son archive. Ne # regarder que le Makefile et les autres scripts declarait ce script « injoignable » # alors qu'il tourne a chaque deploiement. Trouve le 2026-08-10, en ajoutant # justement cette verification : une preuve qui ignore un chemin d'appel reel accuse # du code sain, et on apprend a passer outre — le pire sort pour une garde. ansible_yaml = "\n".join( f.read_text(encoding="utf-8", errors="ignore") for dossier in ("roles", "playbooks") for f in (RACINE / dossier).rglob("*.yml") ) injoignables = [] for f in scripts: autres = "\n".join(v for k, v in textes.items() if k != f.name) module = f.stem if f"scripts/{f.name}" in makefile: continue if module in autres: # importe ou appele par un autre outil continue if f.name in ansible_yaml: # invoque par un role ou un playbook continue injoignables.append(f.name) if injoignables: manques.append(f"{len(injoignables)} script(s) qu'aucune cible ni aucun outil " f"n'atteint : " + ", ".join(injoignables[:6])) muettes = [ ligne.split(":")[0] for ligne in makefile.splitlines() # `[A-Za-z]` et non `[a-z]` : le premier motif laissait passer TOUTE cible # contenant une majuscule. `myDay` — alias de `reconstruire`, cite dans l'aide # et dans la GUI — echappait donc au controle et n'avait aucun texte d'aide. # Trouve par une question de l'exploitant, une heure apres l'ecriture de P31 : # une preuve ne vaut que ce que vaut son motif. if re.match(r"^[A-Za-z][A-Za-z0-9_-]*:", ligne) and "##" not in ligne ] if muettes: manques.append(f"{len(muettes)} cible(s) make sans texte d'aide `##` : " + ", ".join(sorted(muettes)[:6]) + ("…" if len(muettes) > 6 else "")) sans_readme = sorted( d.name for d in (RACINE / "roles").iterdir() if d.is_dir() and not (d / "README.md").exists() ) if sans_readme: manques.append(f"{len(sans_readme)} role(s) sans README : " + ", ".join(sans_readme[:6])) if manques: return False, " | ".join(manques) n_c = len([l for l in makefile.splitlines() if re.match(r"^[A-Za-z][A-Za-z0-9_-]*:", l)]) n_r = len([d for d in (RACINE / "roles").iterdir() if d.is_dir()]) return True, (f"{len(scripts)} scripts expliques et atteignables, " f"{n_c} cibles make documentees, {n_r} roles avec README.") # --- Registre des preuves : (id, titre, refs AFF, executeur) ----------------------- # # executeur = liste de commandes argv (toutes doivent renvoyer 0), ou callable -> (ok, detail). def preuve_depots_tiers_par_le_cache() -> tuple[bool, str]: """Aucun role ne vise un depot tiers en `https://` ecrit en dur. LE CACHE NE SERT QUE CE QU'ON LUI DEMANDE EN CLAIR (2026-09-10). Les trois fournisseurs applicatifs de Set-OPS — Grafana, Icinga, Smallstep — ne publient qu'en HTTPS, et `apt-cacher-ng` ne relaie pas un tunnel. Tant qu'un role ecrit `https://`, la machine sort elle-meme sur Internet, quel que soit le mandataire configure. Le remede est en deux moities, et une seule ne sert a rien : le cache DECLARE un `Remap-*` par fournisseur, et chaque role DEMANDE en `{{ ..._depot_schema }}://`, qui vaut `http` des qu'un cache d'amorcage est declare. Cette preuve garde la seconde moitie — la premiere se voit dans `serveur_artefacts_remaps`. Elle vaut surtout pour les roles A VENIR : le jour ou l'on ajoutera un depot tiers, la copie du geste sera refusee ici plutot que decouverte a la prochaine mesure de trafic sortant. """ hotes = [r["hote"] for r in ( yaml.safe_load((RACINE / "roles" / "serveur_artefacts" / "defaults" / "main.yml") .read_text(encoding="utf-8")) or {} ).get("serveur_artefacts_remaps") or []] if not hotes: return False, "`serveur_artefacts_remaps` est vide : aucun depot tiers n'est relaye." fautifs: list[str] = [] for chemin in sorted((RACINE / "roles").glob("*/defaults/main.yml")): texte = chemin.read_text(encoding="utf-8") for ligne_no, ligne in enumerate(texte.splitlines(), 1): nu = ligne.strip() if nu.startswith("#"): continue for h in hotes: if f"https://{h}" in nu: fautifs.append(f"{chemin.relative_to(RACINE)}:{ligne_no} vise " f"https://{h} en dur") if fautifs: return False, ("Depot tiers vise en HTTPS direct — la machine sortira sur Internet " "au lieu de passer par le cache :\n - " + "\n - ".join(fautifs)) return True, (f"{len(hotes)} depot(s) tiers relaye(s) par le cache, aucun role ne les " f"vise en https:// ecrit en dur.") def preuve_clients_oidc_vises_sur_une_exposition() -> tuple[bool, str]: """Chaque client OIDC pointe un nom que le plan EXPOSE vraiment. UNE LISTE QUI SUIT UNE AUTRE PREND DU RETARD (2026-09-10). Les URI de redirection des clients Keycloak repetent, a la main, les FQDN que `plan/applications.yml` declare dans `expose:`. Rien ne les relie : renommer une exposition regenere `hosts.yml`, le certificat et la zone DNS — et laisse le client OIDC viser l'ancien nom. Le defaut ne se voit pas au deploiement. Keycloak accepte sans broncher une URI de redirection vers un nom qui ne resout plus ; c'est l'utilisateur qui le decouvre, au retour du SSO, devant un `invalid_redirect_uri` ou une page morte. Cette garde est ecrite EN MEME TEMPS que le premier renommage (`grafana` -> `observatoire`), parce que c'est le seul moment ou l'on sait encore que les deux listes existent. """ instance = instance_courante() f_app = instance / "plan" / "applications.yml" if not f_app.is_file(): return True, "Aucun plan d'applications : pas de client OIDC a verifier." apps = (yaml.safe_load(f_app.read_text(encoding="utf-8")) or {}).get("applications") or {} exposes = {str(f).strip().lower() for a in apps.values() if isinstance(a, dict) for f in (a.get("expose") or [])} from urllib.parse import urlsplit fautifs: list[str] = [] clients = 0 for gv in sorted((instance / "inventories").glob("*/group_vars/*.yml")): data = yaml.safe_load(gv.read_text(encoding="utf-8")) or {} if not isinstance(data, dict): continue for cl in (data.get("serveur_keycloak_clients") or []): if not isinstance(cl, dict): continue clients += 1 nom = str(cl.get("clientId") or "?") for champ in ("redirect_uris", "web_origins"): for uri in (cl.get(champ) or []): hote = urlsplit(str(uri)).hostname or "" if hote in ("", "*"): continue if hote.lower() not in exposes: fautifs.append(f"{gv.name}: client `{nom}` {champ} vise `{hote}`, " f"qu'aucune application n'expose") if not clients: return True, "Aucun client OIDC declare dans cette instance." if fautifs: return False, ("Client OIDC vise un nom non expose — le SSO renverra l'utilisateur " "dans le vide :\n - " + "\n - ".join(sorted(set(fautifs)))) return True, (f"{clients} client(s) OIDC, toutes leurs URI visent un FQDN que le plan " f"expose ({len(exposes)} exposition(s)).") def preuve_nom_public_vient_du_plan() -> tuple[bool, str]: """Un service servi sous un nom expose porte CE nom, pas celui que son role devine. LE DEFAUT, MESURE LE 2026-09-10. Quatre roles — grafana, forgejo, nextcloud, keycloak — portaient en defaut une devinette du nom sous lequel ils sont servis, de la forme `grafana.{{ domaine_interne }}`. Tant que le plan suit la meme convention, la devinette tombe juste et rien ne revele qu'il y a deux sources. Renommer une exposition l'a revele d'un coup : nginx servait `observatoire`, le certificat le portait, les deux zones le publiaient — et Grafana continuait de fabriquer son URL de retour OIDC avec `grafana`. Le SSO renvoyait l'utilisateur sur un nom qui n'existait plus. `instancier` derive donc `_hostname` de l'exposition UNIQUE que le plan declare. Cette preuve garde la derivation : elle refuse qu'un hote serve un groupe expose sans porter le nom du plan. Elle ne juge pas les groupes a deux expositions — aucun nom canonique n'en sort, et le role garde alors la main. """ inv = RACINE / INV if not inv.is_file(): return True, "Aucun inventaire monte : rien a verifier." instance = instance_courante() f_app = instance / "plan" / "applications.yml" if not f_app.is_file(): return True, "Aucun plan d'applications : rien a verifier." apps = (yaml.safe_load(f_app.read_text(encoding="utf-8")) or {}).get("applications") or {} attendu: dict[str, str] = {} for a in apps.values(): if not isinstance(a, dict): continue exp = [str(f).strip() for f in (a.get("expose") or []) if str(f).strip()] g = str(a.get("groupe") or "") if g and len(exp) == 1: attendu[g] = exp[0] if not attendu: return True, "Aucun groupe expose par le plan : rien a deriver." data = yaml.safe_load(inv.read_text(encoding="utf-8")) or {} enfants = (data.get("all") or {}).get("children") or {} hostvars: dict = {} for etat in ("hotes_actifs", "hotes_planifies"): for nom, v in ((enfants.get(etat) or {}).get("hosts") or {}).items(): hostvars[nom] = v or {} fautes: list[str] = [] verifies = 0 for groupe, fqdn in sorted(attendu.items()): for hote in sorted((enfants.get(groupe) or {}).get("hosts") or {}): vu = str((hostvars.get(hote) or {}).get(f"{groupe}_hostname") or "") verifies += 1 if vu != fqdn: fautes.append(f"{hote} porte {groupe} sans `{groupe}_hostname` = " f"`{fqdn}` (inventaire : {vu or 'absent'})") if fautes: return False, ("Un service ne porte pas le nom que le plan lui donne — ses URL de " "retour viseront l'ancien :\n - " + "\n - ".join(fautes[:6])) return True, (f"{verifies} service(s) expose(s) portent le nom du plan " f"({len(attendu)} groupe(s) derive(s)).") def preuve_cle_telechargee_non_vide() -> tuple[bool, str]: """Une cle de depot TELECHARGEE est mesuree avant d'etre utilisee. UNE REUSSITE N'EST PAS UN CONTENU (mesure le 2026-09-11). Sur une reconstruction a froid, `get_url` a rendu 0 octet SANS ERREUR sur une machine des quatorze : le cache a servi un 200 au corps vide. Le role a continue, satisfait. Le defaut ne s'est pas lu la. Il s'est lu deux cents lignes plus loin, dans un `apt` qui accusait la SIGNATURE : « Missing key 35BAA0B3..., which is needed to verify signature ». Un message qui envoie chercher une cle revoquee chez le fournisseur, alors que le fichier local faisait zero octet. C'est la meme famille que l'index tronque du cache la veille : l'octet manquant se denonce toujours ailleurs qu'ou il manque. La garde posee d'abord — « retirer une ressource VIDE avant de la redemander » — ne mordait qu'au passage SUIVANT : au premier, le fichier n'existe pas encore, il n'y a rien a retirer. Elle repare le second essai, elle ne protege pas le premier. Cette preuve exige donc que tout role qui TELECHARGE une cle la MESURE dans la meme execution. Les roles qui deposent une cle embarquee (`copy` depuis `files/`) n'entrent pas dans le compte : rien de reseau ne s'interpose, l'octet ne peut pas manquer. """ manquants: list[str] = [] verifies = 0 for chemin in sorted((RACINE / "roles").glob("*/tasks/main.yml")): role = chemin.parent.parent.name try: taches = yaml.safe_load(chemin.read_text(encoding="utf-8")) or [] except Exception: continue if not isinstance(taches, list): continue def _mod(t: dict, nom: str) -> dict | None: for cle in (nom, f"ansible.builtin.{nom}"): if isinstance(t.get(cle), dict): return t[cle] return None telecharge = [t for t in taches if isinstance(t, dict) and (_mod(t, "get_url") or {}).get("dest", "") and "depot_cle_fichier" in str((_mod(t, "get_url") or {}).get("dest"))] if not telecharge: continue verifies += 1 mesure = any( isinstance(t, dict) and _mod(t, "assert") and any("stat.size" in str(c) and ">" in str(c) for c in (_mod(t, "assert") or {}).get("that") or []) for t in taches) if not mesure: manquants.append(f"{role} telecharge une cle de depot sans mesurer sa taille") if not verifies: return False, ("Aucun role ne telecharge de cle de depot : la preuve a perdu son " "objet, ou le motif a change.") if manquants: return False, ("Cle de depot telechargee sans mesure — un corps vide passera, et " "apt accusera la signature :\n - " + "\n - ".join(manquants)) return True, (f"{verifies} role(s) telechargent une cle de depot, tous la mesurent " f"avant de s'en servir.") def preuve_pool_du_site_nest_pas_celui_du_tenant() -> tuple[bool, str]: """Les machines du site naissent-elles dans le pool du SITE ? MESURE DU 2026-09-13 : `Chezlepro-17` contenait VINGT ET UNE VM — les quatorze du locataire ET les sept du genome. Cause : `site-creer` appelle `cloner-vm`, qui derive son pool par `--pool-actif`, c'est-a-dire le pool du TENANT lie. Le site ne derive d'aucun index : son pool est invariable. Un appel qui ne le dit pas herite du locataire, et le rangement fait a la main se defait au prochain `site-creer` — sans un mot, parce que la VM est bien creee, bien nommee, bien adressee. LA GARDE EST TEXTUELLE, ET C'EST LE BON NIVEAU : ce qu'on protege, c'est qu'un appel NOMME son pool. Derouler make pour le lire couterait un cluster joignable, ce que ces preuves n'ont jamais. """ import re as _re mk = (RACINE / "Makefile").read_text(encoding="utf-8") m = _re.search(r"^site-creer:.*?(?=^\.PHONY|^[a-zA-Z0-9_-]+:)", mk, _re.M | _re.S) if not m: return False, "Cible `site-creer` introuvable dans le Makefile." recette = m.group(0) if "cloner-vm" not in recette: return True, "`site-creer` ne clone aucune VM : rien a nommer." if not _re.search(r"POOL=.*--pool-site", recette): return False, ("`site-creer` appelle `cloner-vm` sans nommer le pool du site : " "les machines du genome naitront dans le pool du TENANT lie.") devis = (RACINE / "scripts" / "devis_proxmox_pools.py").read_text(encoding="utf-8") if "--pool-site" not in devis: return False, "`--pool-site` est invoque par le Makefile mais le devis ne l'offre pas." return True, "`site-creer` nomme `--pool-site` ; le pool du genome ne peut plus etre celui d'un tenant." def preuve_gabarit_une_seule_declaration() -> tuple[bool, str]: """Le gabarit dore est-il declare a UN SEUL endroit ? C'est de lui que descend chaque VM de chaque ecosysteme. Il vivait a deux endroits : `plan/10-intrants.yml` (`gabarit:`) et `underlay.yml` (`materialisation.vmid_modele`). Les deux ont diverge — le plan disait 9006, l'underlay 99998, que le plan nomme justement `precedent`. CE QUE LA DIVERGENCE PRODUISAIT, et pourquoi elle etait invisible : les deux VMID designent un gabarit VALIDE, donc les deux clonent. Les machines du SITE naissaient de l'ancien, celles des LOCATAIRES du nouveau — le Makefile derivait deja du plan. Aucune erreur, aucun message : deux flottes issues de deux souches. Le champ `precedent:` est ADMIS et meme utile — il dit d'ou l'on vient. Ce qui est refuse, c'est une seconde declaration de ce qu'il faut CLONER. """ import yaml as _y try: sys.path.insert(0, str(RACINE / "scripts")) import underlay as _u c = _u.chemin() if not c: return True, "Aucun site monte : aucun gabarit a declarer." carte = _y.safe_load(c.read_text(encoding="utf-8")) or {} except Exception as e: return True, f"Carte illisible ({type(e).__name__}) : rien a comparer." mat = ((carte.get("underlay") or {}).get("materialisation") or {}) if "vmid_modele" in mat: return False, ("`underlay.yml` redeclare `materialisation.vmid_modele` — le gabarit " "se declare au PLAN du site (`gabarit.vmid`), et nulle part ailleurs. " "Deux declarations divergent, et les deux clonent : la flotte se " "retrouve issue de deux souches sans qu'aucun message ne le dise.") g = _u.gabarit() if not g.get("vmid"): return False, ("Aucun `gabarit.vmid` au plan du site — chaque VM en descend, et il " "ne peut pas etre decide par un tenant.") p = f", precedent {g['precedent']}" if g.get("precedent") else "" return True, (f"Gabarit declare une seule fois : VMID {g['vmid']} " f"« {g.get('nom', '?')} »{p}.") def preuve_locataire_suit_son_site() -> tuple[bool, str]: """Le locataire monte designe-t-il encore les services de son site REEL ? UN SITE EST UNE INTERFACE. Un locataire qui l'habite a besoin d'un petit nombre de valeurs : ou resoudre, ou prendre ses paquets et ses binaires, ou cloner le genome, ou deposer son etat. Il les ECRIT dans ses intrants — et une copie se perime. DEUX FOIS EN DEUX JOURS, avec la meme forme : - `serveur_ops_forge_amont: https://10.0.33.11` alors que la forge sert en 10.37.33.11 depuis que le site a pris son propre index. Le commentaire au-dessus expliquait encore pourquoi l'ancienne adresse etait dans les SAN du certificat : le raisonnement intact, la valeur perimee. - une carte de site dans le depot du LOCATAIRE, decrivant un index 23 quand le vrai disait 31. P69 couvrait deja `dns_amorcage` et `artefacts_amorcage`. Celle-ci couvre le reste du contrat, et se derive du site plutot que d'une liste tenue a part : ajouter un service prete au site l'ajoute ici, sans qu'on ait a y penser. """ import subprocess r = subprocess.run([sys.executable, str(RACINE / "scripts" / "site_intrants.py"), "--verifier"], capture_output=True, text=True, cwd=RACINE) sortie = (r.stdout + r.stderr).strip() if r.returncode == 0: return True, sortie.splitlines()[0] if sortie else "Aucun site monte." return False, " ".join(l.strip(" -") for l in sortie.splitlines() if l.strip()) def preuve_annuaire_sans_compte_maitre() -> tuple[bool, str]: """Aucun service ne se lie-t-il a l'annuaire avec son compte d'administration ? MESURE DU 2026-09-13 : les quatre consommateurs — Keycloak, Dovecot, Postfix, Icinga Web 2 — s'y liaient tous avec `cn=admin`. C'est le rootDN : slapd lui fait CONTOURNER TOUTES LES ACL, y compris ses propres controles de qualite de mot de passe. Un seul secret, quatre services, tous les droits sur l'arbre — pour ce qui est, trois fois sur quatre, une simple lecture. LE DEPOT PORTAIT DEJA L'INDICE. `validatePasswordPolicy` existe parce que « slapd n'applique pas ses controles de qualite au rootDN » : la consequence etait compensee, la cause intacte. CE QUI EST EXIGE ICI : tout role qui inclut `resoudre_annuaire` NOMME son compte de service. Une seule exception, et elle est nommee — `amorcage_acces` ne consomme pas l'annuaire, il le PROVISIONNE : il cree le compte d'amorcage depuis la socket locale de la machine qui porte l'annuaire. C'est le geste de l'exploitant. Sans cette garde, un role neuf heriterait du defaut du role partage et se lierait silencieusement avec ce que ce defaut designe. """ import re as _re roles_dir = RACINE / "roles" incluent: dict[str, str] = {} for role in sorted(p.name for p in roles_dir.iterdir() if p.is_dir()): td = roles_dir / role / "tasks" if not td.is_dir(): continue for tf in sorted(td.glob("*.yml")): texte = tf.read_text(encoding="utf-8") if _re.search(r"name:\s*resoudre_annuaire\b", texte): incluent[role] = texte if not incluent: return True, "Aucun role n'inclut `resoudre_annuaire` : rien a garder." muets, maitres = [], [] for role, texte in sorted(incluent.items()): cn = _re.search(r"resoudre_annuaire_bind_cn:\s*[\"']?([\w-]+)", texte) if cn is None: muets.append(role) elif cn.group(1) == "admin" and role != "amorcage_acces": maitres.append(role) if maitres: return False, ("Se lie(nt) a l'annuaire avec son compte d'administration — tous les " "droits sur l'arbre pour une lecture : " + ", ".join(maitres)) if muets: return False, ("N'a/ont pas nomme leur compte de service et heriteront du defaut du " "role partage : " + ", ".join(muets)) return True, (f"{len(incluent)} role(s) consultent l'annuaire, chacun avec SON compte de " "service ; seul `amorcage_acces` garde celui d'administration, et il " "provisionne au lieu de consommer.") def preuve_depot_binaires_complet() -> tuple[bool, str]: """Le depot du site tient-il TOUT ce que les roles vont chercher sur Internet ? UNE LISTE QUI SUIT UNE AUTRE PREND DU RETARD, et celle-ci a ete ecrite le meme jour que la garde — deliberement. Le depot des binaires directs (`serveur_artefacts_directs`) enumere ce que le cache du site tient ; les roles consommateurs, eux, declarent ce qu'ils telechargent. Deux listes, un seul fait. CE QUE LE RETARD PRODUIRAIT : une version montee dans `serveur_forgejo_version` sans l'etre dans le depot. Le runner ne trouve pas le fichier, retombe sur codeberg.org, et tout fonctionne — en sortant sur Internet, sans que rien ne le dise. C'est exactement la famille de defaut que ce depot traque : du correct qui ment. LA MESURE EST TEXTUELLE, ET C'EST SUFFISANT. On releve, dans les taches des roles, chaque `dest:` ecrit sous `_cache_local`, et on exige que son nom de fichier figure au depot. Rendre Jinja n'apporterait rien : c'est le NOM qui doit correspondre, et il est ecrit des deux cotes dans la meme forme. """ import re as _re roles_dir = RACINE / "roles" defauts = (roles_dir / "serveur_artefacts" / "defaults" / "main.yml").read_text(encoding="utf-8") tenus = set(_re.findall(r'^\s*-\s*nom:\s*"([^"]+)"', defauts, _re.M)) if not tenus: return False, "Le depot de binaires ne declare aucun artefact (serveur_artefacts_directs vide)." attendus: dict[str, str] = {} for role in sorted(p.name for p in roles_dir.iterdir() if p.is_dir()): for tf in sorted((roles_dir / role / "tasks").glob("*.yml")) if (roles_dir / role / "tasks").is_dir() else []: texte = tf.read_text(encoding="utf-8") for dest in _re.findall(r'^\s*dest:\s*"\{\{\s*\w+_cache_local\s*\}\}/([^"]+)"', texte, _re.M): attendus[dest] = f"{role}/tasks/{tf.name}" manquants = sorted(f"{nom} ({ou})" for nom, ou in attendus.items() if nom not in tenus) if manquants: return False, ("Telecharges par un role mais ABSENTS du depot du site — " "chaque runner sortira les chercher : " + " ; ".join(manquants)) orphelins = sorted(tenus - set(attendus)) detail = f" ({len(orphelins)} tenu(s) que plus aucun role ne demande : {', '.join(orphelins)})" if orphelins else "" return True, f"{len(attendus)} artefact(s) direct(s) tenus par le depot du site{detail}." def preuve_amorcage_suit_le_site() -> tuple[bool, str]: """Les adresses d'amorcage d'un tenant designent-elles le site REEL ? UNE SECONDE LISTE QUI SUIT UNE PREMIERE (2026-09-12). `dns_amorcage` et `artefacts_amorcage` sont ecrits A LA MAIN dans les intrants d'un tenant, et c'est voulu : au moment ou ils servent, la premiere machine du tenant ne resout aucun nom. Une adresse, pas un nom — la declaration le dit explicitement. Mais ces adresses designent des machines DU SITE. Quand le site a pris son propre index et que ses zones sont passees de `10.0.3x` a `10.37.3x`, elles n'ont pas suivi : la reconstruction du locataire s'est arretee sur `infra-pki-01`, apt muet, avec un `resolv.conf` pointant une machine qui n'existait plus. Le message ne disait pas « adresse perimee ». Il disait « Failed to update apt cache », a quinze couches de sa cause. CE QUE CETTE PREUVE NE FAIT PAS : juger une adresse qui ne designe pas le site. Un tenant peut legitimement s'amorcer sur un resolveur public — `OPS-Technolibre` et `OPS-Patient0` visent `9.9.9.9`, et c'est un choix, pas un oubli. On ne verifie que les valeurs qui PRETENDENT designer une machine du site : meme troisieme octet de zone, autre deuxieme octet. Une adresse etrangere au site n'est pas notre affaire. """ try: sys.path.insert(0, str(RACINE / "scripts")) import underlay as _u if not _u.charger(): return True, "Aucun underlay monte : aucun site a suivre." attendus = { "dns_amorcage": _u.adresses_site_portant("serveur_resolveur"), "artefacts_amorcage": _u.adresses_site_portant("serveur_artefacts"), } except Exception as e: return True, f"Site illisible ({type(e).__name__}) : rien a comparer." if not any(attendus.values()): return True, "Le site ne porte ni resolveur ni cache : rien a suivre." # Les troisiemes octets des zones du site — la signature d'une adresse « du site ». octets_site = {a.split(".")[2] for v in attendus.values() for a in v if a.count(".") == 3} # LE COUPLE MONTE, ET LUI SEUL (2026-09-13). # # Cette preuve balayait TOUS les depots `OPS-*` et les comparait au site MONTE. Elle # avait raison tant qu'un seul site existait : une adresse « en 10.x.3z » ne pouvait # designer que lui. # # Deux sites decoupent leurs zones de la meme facon — c'est le but, un locataire doit # pouvoir habiter l'un ou l'autre sans se renumeroter. Le troisieme octet a donc cesse # de distinguer « mon site » d'« un autre site » : `10.31.34.11`, parfaitement juste # pour un locataire de TechnoLibre, etait declare faux parce que Chezlepro etait monte. # # UN LOCATAIRE N'APPARTIENT A AUCUN SITE — il en habite un, choisi par le symlink au # moment du deploiement. La seule paire qu'on puisse juger est donc celle qui est # montee. Meme portee que P73, et pour la meme raison. from inventory_rules import dossier_inventaire, instance_courante fautes, verifies = [], 0 _f = dossier_inventaire() / "group_vars" / "all" / "10-intrants.yml" for intrants in ([_f] if _f.is_file() else []): try: d = yaml.safe_load(intrants.read_text(encoding="utf-8")) or {} except Exception: continue for cle, bons in attendus.items(): brut = str(d.get(cle) or "").strip() if not brut or not bons: continue adr = brut.split(":")[0].split(",")[0].strip() if adr.count(".") != 3 or adr.split(".")[2] not in octets_site: continue # ne pretend pas designer le site : pas notre affaire verifies += 1 if adr not in bons: fautes.append(f"{instance_courante().resolve().name} : `{cle}` vise {adr}, " f"le site porte {', '.join(bons)}") if not verifies: return True, "Aucun tenant ne s'amorce sur une adresse du site." if fautes: return False, ("Adresse d'amorcage perimee — la premiere machine du tenant ne " "resoudra rien, et l'erreur parlera d'apt :\n - " + "\n - ".join(fautes)) return True, (f"{verifies} adresse(s) d'amorcage designent bien une machine du site.") PREUVES: list[dict] = [ {"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"], "cmds": [["ansible-lint", "-q"]]}, {"id": "P02", "titre": "Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI)", "refs": [], "cmds": [[sys.executable, "scripts/tests/test_inventory_host.py"], [sys.executable, "scripts/tests/test_raser.py"], [sys.executable, "scripts/tests/test_ecriture_atomique.py"], # Le rendu, et non la seule syntaxe : `verifier_gui.py` passait au vert sur # une page qui levait `ReferenceError` a l'ouverture. [sys.executable, "scripts/tests/test_rendu_gui.py"], # Le fichier le plus dense en memoire de decision du depot : ecrire dedans # ne doit deplacer aucun commentaire. [sys.executable, "scripts/tests/test_nomenclature_ecriture.py"], # Les quatre registres du plan : sauvegarder ne doit emporter aucun # commentaire. Quarante lignes etaient detruites a chaque clic. [sys.executable, "scripts/tests/test_ecriture_plan.py"]]}, {"id": "P03", "titre": "Diff-vide du plan — TOUTES les instances", "refs": ["AFF-001", "AFF-004", "AFF-030", "AFF-031", "AFF-032"], "func": preuve_diff_vide_toutes_instances}, {"id": "P04", "titre": "Groupes <-> playbooks homonymes", "refs": ["AFF-008"], "cmds": [[sys.executable, "scripts/inventory_host.py", "--inventaire", INV, "verifier-playbooks", "--dossier-playbooks", GROUPES]]}, {"id": "P05", "titre": "Dependances causales de groupes", "refs": ["AFF-009", "AFF-084"], "cmds": [[sys.executable, "scripts/inventory_host.py", "--inventaire", INV, "--dependances", DEPENDANCES, "verifier-dependances", "--dossier-playbooks", GROUPES]]}, {"id": "P06", "titre": "Validateurs de registres (serveurs/apps/bases/domaines)", "refs": ["AFF-003"], "refs": ["AFF-003"], "cmds": [[sys.executable, "scripts/serveurs.py", "verifier"], [sys.executable, "scripts/applications.py", "verifier"], [sys.executable, "scripts/bases_donnees.py", "verifier"], [sys.executable, "scripts/domaines.py", "verifier"]]}, {"id": "P07", "titre": "GUI (node --check)", "refs": ["AFF-033"], "cmds": [[sys.executable, "scripts/verifier_gui.py"]]}, {"id": "P08", "titre": "Orchestration (couches + graphe)", "refs": ["AFF-070"], "cmds": [[sys.executable, "scripts/orchestrer.py", "verifier"]]}, {"id": "P09", "titre": "Flux reseau (schema + matrice)", "refs": ["AFF-071"], "cmds": [[sys.executable, "scripts/resoudre_flux.py", "verifier"]]}, {"id": "P10", "titre": "Handlers <-> notify", "refs": ["AFF-034", "AFF-035"], "func": preuve_handlers}, {"id": "P11", "titre": "Syntaxe des playbooks (--syntax-check)", "refs": ["AFF-083"], "cmds": [["make", "--no-print-directory", "syntaxe"]]}, {"id": "P12", "titre": "Existence des runbooks cites", "refs": ["AFF-010", "AFF-011", "AFF-012"], "refs": ["AFF-010", "AFF-011", "AFF-012", "AFF-083"], "func": preuve_runbooks}, {"id": "P13", "titre": "Invariants structurels/doctrinaux", "refs": ["AFF-015", "AFF-022", "AFF-037", "AFF-038", "AFF-062"], "refs": ["AFF-015", "AFF-022", "AFF-037", "AFF-038", "AFF-062"], "func": preuve_structurels}, {"id": "P14", "titre": "Pas de chemin lab/ code en dur", "refs": ["AFF-097"], "func": preuve_pas_lab_code_en_dur}, {"id": "P15", "titre": "Modele public socle valide", "refs": ["AFF-022", "AFF-099"], "func": preuve_modele_socle}, {"id": "P17", "titre": "Tous les modeles valident (registres + underlay)", "refs": ["AFF-105"], "refs": ["AFF-022", "AFF-099"], "cmds": [[sys.executable, "scripts/modeles.py", "verifier"]]}, {"id": "P18", "titre": "Gabarit de voute complet", "refs": ["AFF-026"], "cmds": [[sys.executable, "scripts/voute.py", "verifier"]]}, {"id": "P19", "titre": "Le GUI couvre le schema du plan", "refs": ["AFF-106"], "refs": ["AFF-002", "AFF-095"], "cmds": [[sys.executable, "scripts/couverture_gui.py", "verifier", "--tolerer", "nomenclature"]]}, {"id": "P20", "titre": "Adressage 100% derive du seed (aucun stocke)", "refs": ["AFF-101"], "refs": ["AFF-001", "AFF-003"], "func": preuve_nomenclature_derivee}, {"id": "P21", "titre": "Federation : aucun index en collision", "refs": ["AFF-102"], "cmds": [[sys.executable, "scripts/instances.py", "--verifier"]]}, {"id": "P22", "titre": "Plan de recette a jour (genere du wiki)", "refs": ["AFF-002"], "cmds": [[sys.executable, "scripts/plan_recette.py", "--verifier"]]}, {"id": "P23", "titre": "Underlay sans collision avec la plage tenant", "refs": ["AFF-103"], "cmds": [[sys.executable, "scripts/underlay.py", "--verifier"]]}, {"id": "P25", "titre": "Pare-feu Proxmox : est-ouest intra-tenant derive", "refs": ["AFF-107"], "cmds": [[sys.executable, "scripts/devis_proxmox_fw.py", "--verifier"]]}, {"id": "P24", "titre": "Frontiere nord/sud : acces d'administration declare", "refs": ["AFF-104"], "cmds": [[sys.executable, "scripts/devis_opnsense.py", "--verifier"]]}, {"id": "P26", "titre": "Integrations universelles : aucun hote laisse de cote", "refs": ["AFF-108"], "func": preuve_integrations_universelles}, {"id": "P27", "titre": "Propriete des intrants : hebergeur et tenant separes", "refs": ["AFF-109"], "func": preuve_propriete_des_intrants}, {"id": "P28", "titre": "Pools Proxmox : un par tenant, sans collision", "refs": ["AFF-110"], "cmds": [[sys.executable, "scripts/devis_proxmox_pools.py", "--verifier"]]}, {"id": "P29", "titre": "Authentification : chaque role declare sa position", "refs": ["AFF-111"], "func": preuve_authentification}, {"id": "P30", "titre": "SDN EVPN : zones, VNets et sous-reseaux derives", "refs": ["AFF-112"], "cmds": [[sys.executable, "scripts/devis_sdn.py", "--verifier"]]}, {"id": "P31", "titre": "Documentation : tout ce que le depot FAIT est nomme", "refs": [], "func": preuve_documentation_outillage}, {"id": "P32", "titre": "Intrants exiges par les roles : tous fournis", "refs": [], "cmds": [[sys.executable, "scripts/verifier_intrants.py"]]}, {"id": "P34", "titre": "Chaque document declare son lecteur", "refs": [], "func": preuve_lecteur_declare}, {"id": "P35", "titre": "Toute application exigeant une base en a une au plan", "refs": [], "func": preuve_base_par_consommateur}, {"id": "P36", "titre": "Tout detenteur d'etat porte une sauvegarde", "refs": [], "func": preuve_etat_sauvegarde}, {"id": "P37", "titre": "Le placement du tenant existe chez son hebergeur", "refs": [], "func": preuve_placement_chez_hebergeur}, {"id": "P38", "titre": "Catalogue des services : la carte dit ce que le moteur fait", "refs": [], "func": preuve_catalogue_a_jour}, {"id": "P39", "titre": "Glossaire : tout mot employe est enseigne", "refs": [], "func": preuve_glossaire_enseigne}, {"id": "P40", "titre": "Parente : l'ecosysteme sait de quoi il descend", "refs": [], "func": preuve_parente_inscrite}, {"id": "P41", "titre": "Resolution d'instance : une seule, partagee", "refs": [], "func": preuve_resolution_unique}, {"id": "P42", "titre": "L'edge porte les noms qu'il publie", "refs": [], "func": preuve_edge_porte_ses_noms}, {"id": "P44", "titre": "Integrations : le serveur avant ses clients", "refs": [], "func": preuve_ordre_des_integrations}, {"id": "P45", "titre": "Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs", "refs": [], "func": preuve_parefeu_proxmox_sdn_seulement}, {"id": "P46", "titre": "Plancher /etc/hosts : un seul role en decide", "refs": [], "func": preuve_un_seul_maitre_du_plancher}, {"id": "P47", "titre": "Zones inverses : couvrir l'occupe, et rien de plus", "refs": [], "func": preuve_zones_inverses_couvrent_l_occupe}, {"id": "P48", "titre": "La carte d'orientation designe ce qui existe, et compte juste", "refs": [], "func": preuve_carte_dit_vrai}, {"id": "P49", "titre": "Registre des flux : la matrice d'audit est a jour", "refs": [], "func": preuve_registre_flux_a_jour}, {"id": "P50", "titre": "Silences : un refus muet est declare, place en dernier, et motive", "refs": [], "func": preuve_silences_declares}, {"id": "P51", "titre": "Collections : toutes declarees, toutes epinglees", "refs": [], "func": preuve_collections_declarees_et_epinglees}, {"id": "P52", "titre": "Materialiser n'exige pas d'entrer dans le tenant", "refs": [], "func": preuve_materialiser_n_exige_pas_d_entrer}, {"id": "P53", "titre": "L'interne refuse a voix haute, la bordure se tait", "refs": [], "func": preuve_interne_parle_bordure_se_tait}, {"id": "P54", "titre": "L'insemination ne reclame aucun secret du tenant", "refs": [], "func": preuve_insemination_sans_secret}, {"id": "P55", "titre": "La cle du SITE ne nait que sur le runner d'un tenant", "refs": [], "func": preuve_cle_du_site_bornee_au_runner}, {"id": "P56", "titre": "Gabarit minimal, et rien de retire n'est perdu", "refs": [], "func": preuve_gabarit_minimal_et_repris}, {"id": "P57", "titre": "Comptes en prose : les chiffres du depot sur lui-meme", "refs": [], "func": preuve_comptes_de_la_prose}, {"id": "P58", "titre": "Habilitations : chaque service dit a quel GROUPE, et par quoi", "refs": [], "func": preuve_habilitations_declarees}, {"id": "P59", "titre": "Enumerations annoncees : le nombre correspond a ce qui suit", "refs": [], "func": preuve_enumerations_annoncees}, {"id": "P60", "titre": "Wiki publie : la forge sert ce que le depot dit", "refs": ["AFF-002"], "func": preuve_wiki_publie_a_jour}, {"id": "P61", "titre": "Schema du plan : il decrit tout ce que les plans contiennent", "refs": ["AFF-033"], "func": preuve_schema_du_plan}, {"id": "P62", "titre": "Schema du plan : il decrit tout ce que le MOTEUR accepte", "refs": ["AFF-033"], "func": preuve_schema_couvre_les_validateurs}, {"id": "P63", "titre": "cloud-init nait avec la VM et ne lui survit pas", "refs": [], "func": preuve_cloud_init_rendu_puis_retire}, {"id": "P64", "titre": "Sondes de supervision : declarees ET deposees", "refs": [], "func": preuve_sondes_declarees_et_deposees}, {"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"]]}, {"id": "P65", "titre": "Depots tiers : demandes au cache, jamais en HTTPS direct", "refs": [], "func": preuve_depots_tiers_par_le_cache}, {"id": "P66", "titre": "Clients OIDC : chaque URI vise un nom que le plan expose", "refs": [], "func": preuve_clients_oidc_vises_sur_une_exposition}, {"id": "P67", "titre": "Nom public : le service porte celui du plan, pas celui du role", "refs": [], "func": preuve_nom_public_vient_du_plan}, {"id": "P68", "titre": "Cle de depot telechargee : mesuree avant d'etre utilisee", "refs": [], "func": preuve_cle_telechargee_non_vide}, {"id": "P69", "titre": "Amorcage d'un tenant : l'adresse designe le site REEL", "refs": [], "func": preuve_amorcage_suit_le_site}, {"id": "P70", "titre": "Depot de binaires : il tient tout ce que les roles vont chercher", "refs": [], "func": preuve_depot_binaires_complet}, {"id": "P71", "titre": "Pool du site : le genome ne nait pas chez un tenant", "refs": [], "func": preuve_pool_du_site_nest_pas_celui_du_tenant}, {"id": "P72", "titre": "Annuaire : aucun service ne se lie avec le compte du maitre", "refs": [], "func": preuve_annuaire_sans_compte_maitre}, {"id": "P73", "titre": "Le locataire designe les services de son site REEL", "refs": [], "func": preuve_locataire_suit_son_site}, {"id": "P74", "titre": "Gabarit dore : une seule declaration, au plan du site", "refs": [], "func": preuve_gabarit_une_seule_declaration}, {"id": "P75", "titre": "Les parametres de clonage traversent les trois maillons", "refs": [], "func": preuve_parametres_clone_traversent}, ] # LA PREUVE CONDITIONNELLE VIT ICI, PAS DANS `main()` — POUR ETRE COMPTABLE (2026-09-06). # # P16 exige la voute : elle est SAUTEE quand la cle n'est pas la. Definie a l'interieur de # `main()`, elle echappait a `len(PREUVES)` — et la preuve des comptes en prose (P57) # imposait donc « 56 preuves » a toute la documentation, alors que le depot en porte 57 # (P01 a P57, sans trou). Un garde-fou qui fait respecter un chiffre faux est pire qu'aucun # garde-fou : il donne l'assurance en plus de l'erreur. PREUVE_CONDITIONNELLE_INVENTAIRE: dict = { "id": "P16", "titre": "Inventaire Ansible complet (--list)", "refs": ["AFF-030"], "func": preuve_inventaire_ansible} # TOUTES les preuves du depot, conditionnelles comprises. C'est ce nombre que la # documentation doit annoncer. TOUTES_LES_PREUVES: list[dict] = PREUVES + [PREUVE_CONDITIONNELLE_INVENTAIRE] def _vault_requis_absent() -> bool: """Vrai si l'inventaire chiffre un group_vars et qu'aucun mot de passe n'est fourni. DEUX FACONS DE FOURNIR UNE CLE DEPUIS LA SEPARATION DES VOUTES (2026-08-28) : l'unique `ANSIBLE_VAULT_PASSWORD_FILE`, et `ANSIBLE_VAULT_IDENTITY_LIST`, qui en porte plusieurs — indispensable des qu'une execution touche deux voutes (le tenant et l'underlay). N'en regarder qu'une faisait SAUTER P16 alors que la cle etait la, et une preuve sautee se lit trop facilement comme une preuve passee. """ if os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE") or os.environ.get("ANSIBLE_VAULT_IDENTITY_LIST"): return False gv = (RACINE / INV).parent / "group_vars" if not gv.exists(): return False for f in gv.rglob("*"): if f.is_file(): try: if f.read_text(encoding="utf-8", errors="ignore").startswith("$ANSIBLE_VAULT"): return True except OSError: continue return False def _underlay_absent() -> bool: """Vrai si aucun underlay.yml n'est defini (fabric non decrite : rien a prouver).""" import underlay return underlay.chemin() is None def _frontiere_absente() -> bool: """Vrai si aucune instance n'est active (depot public nu : aucune frontiere a prouver). L'instance est celle que designe SETOPS_INSTANCE — le symlink `instance/` n'en est que le cas courant. Lire le symlink en dur faisait SAUTER cette preuve des qu'on visait une instance autrement (CI, modele, autre tenant) : un vert de moins, et personne pour le dire. """ return not INSTANCE.exists() def _executer(preuve: dict) -> tuple[str, str]: """Renvoie (statut, detail). statut in {OK, ECHEC, SAUTE}.""" if "func" in preuve: ok, detail = preuve["func"]() return ("OK" if ok else "ECHEC"), detail dernier = "" for cmd in preuve["cmds"]: r = subprocess.run(cmd, cwd=RACINE, capture_output=True, text=True, timeout=600) sortie = [l for l in (r.stdout + r.stderr).strip().splitlines() if l.strip() and not l.lstrip().startswith("[WARNING]")] dernier = sortie[-1] if sortie else "" if r.returncode != 0: return "ECHEC", f"rc={r.returncode} : {dernier}" return "OK", dernier def main(argv: list[str] | None = None) -> int: argv = sys.argv[1:] if argv is None else argv # --verifier : execute les preuves (verdict + code de sortie) sans ecrire de rapport, # pour un appel depuis `make verifier` (evite d'ecraser la piece justificative committee). ecrire_rapport = "--verifier" not in argv date = _dt.date.today().isoformat() resultats: list[tuple[dict, str, str]] = [] # Preuve conditionnelle : inventaire Ansible complet (necessite la voute). preuve_inv = PREUVE_CONDITIONNELLE_INVENTAIRE liste = list(PREUVES) if _vault_requis_absent(): resultats.append((preuve_inv, "SAUTE", "Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026).")) else: liste.append(preuve_inv) # Preuve conditionnelle : underlay (fabric physique). Sautee si non definie. if _underlay_absent(): p23 = next((p for p in liste if p["id"] == "P23"), None) if p23: liste.remove(p23) resultats.append((p23, "SAUTE", "Aucun underlay.yml (fabric non definie) — rien a prouver.")) # Preuve conditionnelle : frontiere nord/sud. Sautee sans instance active. if _frontiere_absente(): p24 = next((p for p in liste if p["id"] == "P24"), None) if p24: liste.remove(p24) resultats.append((p24, "SAUTE", "Aucune instance active — aucune frontiere a prouver.")) for preuve in liste: statut, detail = _executer(preuve) resultats.append((preuve, statut, detail)) # Tri : garder l'ordre d'execution (P01..P19) ; la preuve sautee a ete inseree tot. resultats.sort(key=lambda r: r[0]["id"]) n_ok = sum(1 for _, s, _ in resultats if s == "OK") n_echec = sum(1 for _, s, _ in resultats if s == "ECHEC") n_saute = sum(1 for _, s, _ in resultats if s == "SAUTE") conforme = n_echec == 0 print(f"{'CONFORME' if conforme else 'NON CONFORME'} : " f"{n_ok} OK, {n_echec} echec, {n_saute} saute.") if not ecrire_rapport: return 0 if conforme else 1 icone = {"OK": "✅", "ECHEC": "❌", "SAUTE": "⚪"} lignes = [ f"# Preuve de conformite — Set-OPS — {date}", "", "> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer", "> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;", "> aucune validation n'est reimplementee ici. Voir le mode d'emploi :", "> [`docs/audit/README.md`](README.md), et le registre trace :", "> [`docs/audit/affirmations.md`](affirmations.md).", "", f"- **Instance** : `{INSTANCE}` — inventaire `{INV}`", f"- **Verdict** : {'✅ CONFORME' if conforme else '❌ NON CONFORME'} " f"({n_ok} OK · {n_echec} echec · {n_saute} saute)", "", "## Preuves", "", "| # | Preuve | Affirmations | Statut | Detail |", "|---|---|---|---|---|", ] for preuve, statut, detail in resultats: refs = ", ".join(preuve["refs"]) or "—" det = detail.replace("|", "\\|")[:160] lignes.append(f"| {preuve['id']} | {preuve['titre']} | {refs} | {icone[statut]} {statut} | {det} |") lignes += [ "", "## Couverture des affirmations ✅ du registre", "", "Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.", "Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005", "`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite", "d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;", "elles restent hors du harnais recurrent (rien d'executable a rejouer).", "", "## Declarations d'intention (⚪ invérifiables localement — assumees)", "", "Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**", "comme declarations d'intention, non comme preuves :", "", "- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement", " contre une flotte vivante.", "- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.", "- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.", "- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.", "", f"_Rapport genere le {date}._", "", ] DOSSIER_AUDIT.mkdir(parents=True, exist_ok=True) rapport = DOSSIER_AUDIT / f"preuve-{date}.md" rapport.write_text("\n".join(lignes), encoding="utf-8") print(f"Rapport : {rapport.relative_to(RACINE)}") return 0 if conforme else 1 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))