resoudre_flux publie les sorties vers l'Internet de chaque machine ; verifier_sorties les confronte aux regles de sortie de la frontiere. Le contrat site <-> locataire est entierement publie et prouve. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5220 lines
274 KiB
Python
5220 lines
274 KiB
Python
#!/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-<date>.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=<url>.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=<url>.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 <N> : » / « Il y a <N> … : » suivi d'une table ou d'une liste ;
|
||
B. « les <N> lignes du §<S> de <autre document.md> » — 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 `<role>_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")
|
||
# ── ET LE SITE, qui est une flotte comme une autre (2026-09-20) ────────────────
|
||
#
|
||
# CETTE PREUVE NE REGARDAIT QUE « l'instance liee + les modeles ». Le site a recu son
|
||
# propre index le 2026-09-12 — decision ecrite dans son underlay : « sites et
|
||
# locataires se partagent la classe A, chacun avec SON index » — et son adressage a
|
||
# continue d'etre ECRIT : `sous_reseau` et `passerelle` pour chacune de ses sept
|
||
# zones, exactement ce que les lignes ci-dessus interdisent a un locataire.
|
||
#
|
||
# Les valeurs concordaient parce qu'un humain les avait tapees juste. Rien ne le
|
||
# garantissait : une zone ecrite `10.36.x` serait passee sans un mot. Une decision
|
||
# appliquee a moitie est plus dangereuse qu'une decision non prise — on la croit tenue.
|
||
#
|
||
# LES DEUX NATURES NE SE JUGENT PAS PAREIL. Un lien de fabric PHYSIQUE (grappe, iSCSI,
|
||
# Ceph, transit, VXLAN) tient son adressage des participants du lien, pas d'un seed :
|
||
# D-02 et D-04 le disent, et il reste ecrit. Seules les zones du site derivent.
|
||
import underlay as _u
|
||
carte = _u.chemin()
|
||
zones_site = 0
|
||
if carte:
|
||
brut = (yaml.safe_load(carte.read_text(encoding="utf-8")) or {}).get("underlay") or {}
|
||
index = brut.get("index")
|
||
for reseau in brut.get("reseaux") or []:
|
||
nature = reseau.get("nature")
|
||
nom = reseau.get("nom", "?")
|
||
if nature not in ("site", "fabric"):
|
||
fautes.append(f"{carte.name}: {nom} ne declare pas sa nature "
|
||
f"(site|fabric) — on ne peut pas juger ce qu'on ne sait pas lire")
|
||
continue
|
||
if nature != "site":
|
||
continue
|
||
zones_site += 1
|
||
for champ in ("sous_reseau", "passerelle"):
|
||
if champ in reseau:
|
||
fautes.append(f"{carte.name}: zone {nom}.{champ} stocke alors "
|
||
f"qu'elle derive de l'index {index}")
|
||
if reseau.get("vlan") is None:
|
||
fautes.append(f"{carte.name}: zone {nom} sans vlan — rien dont deriver")
|
||
|
||
if fautes:
|
||
return False, f"{len(fautes)} champ(s) d'adressage stockes : " + " | ".join(fautes[:4])
|
||
return True, (f"{len(cibles)} nomenclature(s) et {zones_site} zone(s) de site : "
|
||
f"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
|
||
# un autre ecosysteme — 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 `<role>_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_<T>_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 d'un ecosysteme 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/<client>/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.<distro>.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. `remise.yml` (2026-09-16) est
|
||
# de la meme famille — ecrit par `make remise-inscrire` chez le LOCATAIRE, a cote de
|
||
# `parente.yml` : de qui il descend d'un cote, a qui il appartient de l'autre.
|
||
ARTEFACTS_GENERES = {"hosts.yml", "remise.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()]),
|
||
# UNE FICHE GENEREE N'EST PAS UNE UNITE D'APPRENTISSAGE (2026-09-14). Les
|
||
# unites suivent le moule en quatre temps — le concept, comment Set-OPS le fait,
|
||
# pourquoi c'est transferable, a toi de jouer. Les fiches de role sont de la
|
||
# REFERENCE : generees, consultees pour agir, jamais lues pour apprendre.
|
||
#
|
||
# Les compter ensemble ferait annoncer « 96 unites » a un wiki qui en enseigne
|
||
# vingt-sept. Le nombre serait exact et l'affirmation fausse.
|
||
"unites de wiki": len([p for p in (RACINE / "wiki").glob("*.md")
|
||
# On ne retire QUE ce que la generation a ajoute. Que
|
||
# `_Sidebar` soit compte ou non est un autre debat, et le
|
||
# melanger a celui-ci rendrait le chiffre incomparable
|
||
# avec celui d'hier.
|
||
if not p.name.startswith("Rôle")]),
|
||
"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/<groupe>.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"(?<![\d.])(\d{2,3})\s+(preuves|rôles|roles|groupes)\b", ligne):
|
||
valeur = int(m.group(1))
|
||
mot = m.group(2)
|
||
quoi = ("preuves" if mot == "preuves"
|
||
else "groupes" if mot == "groupes" else "roles")
|
||
if valeur != mesures[quoi]:
|
||
ecarts.append(
|
||
f"{f.relative_to(RACINE)}:{n} annonce {valeur} {quoi}, "
|
||
f"le depot en compte {mesures[quoi]}")
|
||
if ecarts:
|
||
return False, ("Des comptes ecrits en prose ne disent plus vrai :\n - "
|
||
+ "\n - ".join(ecarts)
|
||
+ "\n (dater la ligne la rend historique et l'exempte)")
|
||
return True, (f"Les comptes ecrits en prose correspondent a la mesure "
|
||
f"({mesures['preuves']} preuves, {mesures['roles']} roles, "
|
||
f"{mesures['groupes']} groupes).")
|
||
|
||
|
||
def preuve_carte_dit_vrai() -> 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-<date>.md` decrit
|
||
# une forme de fichier, pas un fichier. L'exiger rendrait la preuve fausse.
|
||
# Ni un gabarit de nom (`preuve-<date>.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_panneaux_assembles() -> tuple[bool, str]:
|
||
"""Chaque panneau declare porte de quoi etre assemble, et son unite est traduisible.
|
||
|
||
LES DEUX MOITIES DE `meta/metriques.yml` ONT CHACUNE LEUR CONSOMMATEUR : l'
|
||
`exportateur` devient une cible de scrutation chez `serveur_prometheus`, les
|
||
`panneaux` deviennent un tableau chez `serveur_grafana`. P64 fait ce travail pour les
|
||
sondes ; celle-ci le fait pour les series.
|
||
|
||
CE QU'ELLE EXIGE DE CHAQUE PANNEAU. Un `titre`, une `expr`, une `unite`, et une
|
||
`raison`. La `raison` est le seul champ qui ne produit aucun pixel, et c'est le plus
|
||
important : elle devient la description du panneau dans Grafana. Sans elle, celui qui
|
||
regarde a six mois de distance voit une courbe sans savoir ce qu'elle annonce.
|
||
|
||
ET SURTOUT : L'UNITE DOIT ETRE TRADUISIBLE. `serveur_grafana` tient une table qui
|
||
traduit la langue des roles — « octets », « connexions » — vers celle de Grafana.
|
||
C'est UNE LISTE QUI EN SUIT UNE AUTRE, et une liste qui suit une autre prend du
|
||
retard : un role qui invente une unite ne casse rien, son panneau retombe simplement
|
||
sur `short`. Un graphe d'octets gradue en unites brutes reste parfaitement lisible et
|
||
parfaitement faux.
|
||
|
||
CE QU'ELLE NE FAIT PAS. Elle ne dit pas si l'expression REPOND — aucune lecture
|
||
statique ne le dira, il faut un Prometheus qui a des donnees. C'est au controle de
|
||
chaque panneau, trace au CHANGELOG, comme le controle negatif d'une sonde.
|
||
"""
|
||
roles = RACINE / "roles"
|
||
table = roles / "serveur_grafana" / "defaults" / "main.yml"
|
||
try:
|
||
unites = set((yaml.safe_load(table.read_text(encoding="utf-8")) or {})
|
||
.get("serveur_grafana_unites") or {})
|
||
except (OSError, yaml.YAMLError) as e:
|
||
return False, f"Table des unites de serveur_grafana illisible ({e})."
|
||
if not unites:
|
||
return False, ("`serveur_grafana_unites` est vide : aucun panneau ne pourrait "
|
||
"etre gradue.")
|
||
|
||
fautes, mesures = [], []
|
||
for meta in sorted(roles.glob("*/meta/metriques.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/metriques.yml` illisible ({e})")
|
||
continue
|
||
panneaux = data.get("panneaux")
|
||
expo = data.get("exportateur")
|
||
if not panneaux and not expo:
|
||
fautes.append(f"{role} : `meta/metriques.yml` ne declare ni exportateur "
|
||
f"ni panneau — personne ne le lira")
|
||
continue
|
||
for p in (panneaux or []):
|
||
titre = p.get("titre") or "(sans titre)"
|
||
for champ in ("titre", "expr", "unite", "raison"):
|
||
if not str(p.get(champ) or "").strip():
|
||
fautes.append(f"{role}/{titre} : `{champ}` manquant"
|
||
+ (" — un panneau dont personne ne sait ce qu'il "
|
||
"annonce n'est pas une observabilite"
|
||
if champ == "raison" else ""))
|
||
unite = str(p.get("unite") or "").strip()
|
||
if unite and unite not in unites:
|
||
fautes.append(f"{role}/{titre} : unite « {unite} » absente de "
|
||
f"`serveur_grafana_unites` — le panneau retomberait sur "
|
||
f"`short`, lisible et faux")
|
||
mesures.append(f"{role}/{titre}")
|
||
|
||
if fautes:
|
||
return False, "Panneaux declares :\n - " + "\n - ".join(fautes)
|
||
if not mesures:
|
||
return True, ("Aucun panneau declare — rien a assembler "
|
||
"(cf. docs/supervision-conception.md).")
|
||
return True, (f"{len(mesures)} panneau(x) declare(s) dans "
|
||
f"{len({m.split('/')[0] for m in mesures})} role(s), tous avec titre, "
|
||
f"expression, raison et une unite que la table sait traduire.")
|
||
|
||
|
||
def preuve_consommateurs_suivent_le_verrou_tls() -> tuple[bool, str]:
|
||
"""Un consommateur de base ne decide pas seul s'il chiffre : il suit son serveur.
|
||
|
||
LE DEFAUT, MESURE LE 2026-09-14 A 11:10. `serveur_postgresql_tls_force` pose `hostssl`
|
||
dans `pg_hba` : toute connexion NON chiffree est refusee. Chaque consommateur avait
|
||
alors SON interrupteur — `serveur_icinga_db_tls`, `serveur_keycloak_db_sslmode`,
|
||
`serveur_forgejo_db_sslmode` — a allumer A LA MAIN, dans les `group_vars`, en meme
|
||
temps que celui du serveur.
|
||
|
||
Chezlepro les avait tous les trois. Le SITE n'avait que celui du serveur :
|
||
|
||
aucune entree dans pg_hba.conf pour l'hote « 10.37.36.11 »,
|
||
utilisateur « icingadb », base « icingadb », aucun chiffrement
|
||
|
||
`icingadb` est tombe et n'est jamais remonte. Le moteur de supervision tournait,
|
||
Redis tournait, et plus rien n'atteignait la base : les verdicts se calculaient dans
|
||
le vide. Une panne qui ne fait echouer aucun deploiement.
|
||
|
||
ET `serveur_forgejo` DISAIT PIRE QUE RIEN : `sslmode: "disable"` en dur. Le
|
||
consommateur affirmait le contraire de ce que le serveur imposait.
|
||
|
||
CE QUE CETTE PREUVE EXIGE : que l'interrupteur de chaque consommateur DERIVE de
|
||
`resoudre_base_db_tls_force` — la posture lue chez le serveur — au lieu de porter une
|
||
valeur ecrite. Une valeur ecrite est une seconde liste, et une liste qui suit une
|
||
autre prend du retard.
|
||
|
||
CE QU'ELLE NE FAIT PAS. Elle ne verifie pas qu'une connexion CHIFFREE aboutit : ca
|
||
demande un serveur, un certificat et une AC. C'est au deploiement de le dire, et il
|
||
le dit — un consommateur qui n'y arrive pas ne demarre pas.
|
||
"""
|
||
roles = RACINE / "roles"
|
||
consommateurs: list[str] = []
|
||
for taches in sorted(roles.glob("*/tasks/main.yml")):
|
||
role = taches.parent.parent.name
|
||
if role == "resoudre_base":
|
||
continue
|
||
corps = taches.read_text(encoding="utf-8")
|
||
meta = roles / role / "meta" / "main.yml"
|
||
if meta.is_file():
|
||
corps += meta.read_text(encoding="utf-8")
|
||
if "resoudre_base" in corps:
|
||
consommateurs.append(role)
|
||
|
||
if not consommateurs:
|
||
return True, "Aucun role ne consomme de base : rien a verifier."
|
||
|
||
fautes, suivis, sans_reglage = [], [], []
|
||
for role in consommateurs:
|
||
f = roles / role / "defaults" / "main.yml"
|
||
brut = f.read_text(encoding="utf-8") if f.is_file() else ""
|
||
# Les lignes de reglage TLS, quelle que soit la forme du nom.
|
||
lignes = [l for l in brut.splitlines()
|
||
if re.match(r"\s*[a-z_]*_db_(tls|sslmode|ssl_mode)\s*:", l)]
|
||
if not lignes:
|
||
# PAS UNE FAUTE : un consommateur peut n'avoir aucun reglage parce que son
|
||
# client chiffre par defaut, ou parce qu'il parle a SQLite. On le DIT, ce qui
|
||
# vaut mieux qu'un vert muet — c'est la carte de ce qui reste a regarder.
|
||
sans_reglage.append(role)
|
||
continue
|
||
# La valeur derivee peut etre sur la ligne ou sur la suivante (`>-`).
|
||
bloc = brut
|
||
if "resoudre_base_db_tls_force" in bloc:
|
||
suivis.append(role)
|
||
else:
|
||
fautes.append(f"{role} : {lignes[0].strip()} — valeur ecrite, elle ne suit "
|
||
f"pas `resoudre_base_db_tls_force`")
|
||
|
||
if fautes:
|
||
return False, ("Un consommateur decide seul s'il chiffre, au lieu de suivre son "
|
||
"serveur :\n - " + "\n - ".join(fautes)
|
||
+ "\n Un `hostssl` allume cote serveur le fera tomber, sans "
|
||
"qu'aucun deploiement n'echoue.")
|
||
detail = f"{len(suivis)} consommateur(s) suivent la posture de leur serveur"
|
||
if sans_reglage:
|
||
detail += f" ; {len(sans_reglage)} sans reglage TLS ({', '.join(sans_reglage)})"
|
||
return True, detail + "."
|
||
|
||
|
||
def preuve_gabarits_shell_rendent() -> tuple[bool, str]:
|
||
"""Aucun gabarit shell ne contient de sequence que Jinja lirait autrement.
|
||
|
||
LE DEFAUT EST INVISIBLE JUSQU'AU DEPLOIEMENT, ET IL EST FACILE A REFAIRE.
|
||
|
||
`${#tableau[@]}` est la SEULE facon d'obtenir la longueur d'un tableau en bash. Elle
|
||
contient `{#`, que Jinja lit comme un debut de commentaire — et le rendu s'arrete sur
|
||
|
||
TemplateSyntaxError: Missing end of comment tag
|
||
|
||
Rien ne le signale avant : le fichier est valide en shell, valide a la lecture, et
|
||
`--syntax-check` ne rend pas les gabarits. La panne arrive sur la machine, pendant un
|
||
deploiement, sur une tache `template` qui marchait la veille.
|
||
|
||
LE DEPOT CONNAIT DEJA LE REMEDE, et l'applique trois fois : une en-tete
|
||
`#jinja2: comment_start_string:'{=#', comment_end_string:'#=}'` en toute premiere
|
||
ligne. Le shell garde `${#...}`, c'est Jinja qui cede.
|
||
|
||
CE QUI MANQUAIT, C'EST LA GARDE. Le remede etait pose la ou quelqu'un s'etait fait
|
||
prendre, et nulle part ailleurs. Le 2026-09-14, quatre sondes neuves l'ont refait
|
||
d'un coup — quatre gabarits que le harnais declarait conformes et qu'Ansible aurait
|
||
refuse de rendre.
|
||
|
||
ELLE MESURE LE RENDU, PAS LE MOTIF. Chercher `${#` suffirait a attraper ce cas-la,
|
||
mais pas le suivant : Jinja a d'autres sequences (`{%`, `{{`) qu'un script peut
|
||
produire par accident. On rend donc chaque gabarit pour de vrai, avec les memes
|
||
delimiteurs qu'Ansible en tirerait — et on ne juge pas les VARIABLES manquantes, qui
|
||
sont normales hors d'un play.
|
||
"""
|
||
import re as _re
|
||
try:
|
||
import jinja2
|
||
except ImportError:
|
||
return True, "jinja2 absent du controleur — rendu des gabarits non mesure."
|
||
|
||
fautes, mesures = [], 0
|
||
for gabarit in sorted((RACINE / "roles").glob("*/templates/*.j2")):
|
||
brut = gabarit.read_text(encoding="utf-8", errors="replace")
|
||
# ANSIBLE LIT L'EN-TETE `#jinja2:` ET L'APPLIQUE. On fait pareil, sinon la garde
|
||
# refuserait precisement les gabarits qui portent deja le remede.
|
||
options: dict = {}
|
||
entete = _re.match(r"#jinja2:\s*(.*)\n", brut)
|
||
if entete:
|
||
for cle, val in _re.findall(r"(\w+):\s*'([^']*)'", entete.group(1)):
|
||
options[cle] = val
|
||
brut = brut[entete.end():]
|
||
try:
|
||
jinja2.Environment(**options).parse(brut)
|
||
except jinja2.TemplateSyntaxError as e:
|
||
role = gabarit.parent.parent.name
|
||
fautes.append(f"{role}/{gabarit.name} l.{e.lineno} : {e.message}")
|
||
mesures += 1
|
||
|
||
if fautes:
|
||
return False, ("Gabarits qu'Ansible refuserait de rendre :\n - "
|
||
+ "\n - ".join(fautes)
|
||
+ "\n Remede : poser en PREMIERE ligne du gabarit"
|
||
"\n #jinja2: comment_start_string:'{=#', comment_end_string:'#=}'")
|
||
return True, f"{mesures} gabarits de role : tous se rendent."
|
||
|
||
|
||
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/<role>/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.
|
||
# `sondes-minute/` : les sondes que le porteur joue chaque minute (2026-09-30).
|
||
if (f"/usr/local/lib/setops/sondes/{nom}.sh" not in corps
|
||
and f"/usr/local/lib/setops/sondes-minute/{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(?:-minute)?/([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 sur l'Internet.
|
||
|
||
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 SUR LE WAN — c'est la condition de prudence de tout reject
|
||
# interne. Mais ses interfaces INTERNES refusent a voix haute depuis le 2026-10-01 :
|
||
# elles ne voient que nos zones, et un flux mal declare entre deux zones expirait au
|
||
# lieu d'echouer. Le devis emet donc des `reject`, et JAMAIS sur l'interface WAN.
|
||
front = (RACINE / "scripts" / "devis_opnsense.py").read_text(encoding="utf-8")
|
||
if '"action": "reject"' not in front:
|
||
fautes.append("le devis de la frontiere n'emet aucun refus audible interne : un "
|
||
"flux mal declare entre deux zones expirerait au lieu d'echouer")
|
||
if "i != if_wan" not in front:
|
||
fautes.append("le refus audible de la frontiere n'exclut pas le WAN : la bordure "
|
||
"repondrait 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 : WAN muet, interfaces internes audibles (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 []
|
||
# UN SILENCE EST UN `block` (2026-10-01). Les `reject` sont les refus AUDIBLES des
|
||
# interfaces internes (P53) : journalises par conception, ils ne sont pas des silences.
|
||
silences = [r for r in regles if str(r.get("action") or "pass") == "block"]
|
||
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")}
|
||
|
||
# `_` EST UN CARACTERE DE NOM DE PAGE (2026-09-14). La classe n'en contenait pas, donc
|
||
# un lien vers `Role-serveur_postgresql` n'etait NI suivi, NI signale casse : il
|
||
# n'existait tout simplement pas pour cette preuve. Un motif trop etroit ne rend pas
|
||
# une garde prudente, il la rend aveugle.
|
||
MOTIF = r"\]\(([A-Za-zÀ-ÿ0-9'’_\-]+)\)"
|
||
liens = set(re.findall(MOTIF, texte))
|
||
casses = sorted(l for l in liens if l not in pages)
|
||
|
||
# ATTEIGNABLE, PAS CITE (2026-09-14). La version d'avant exigeait que `_Sidebar.md`
|
||
# cite CHAQUE page. Son propre motif dit pourtant « n'est lue par personne » — or une
|
||
# page citee par une page citee EST lue.
|
||
#
|
||
# CE QUE L'EXIGENCE LITTERALE COUTAIT : elle interdisait toute page d'index. Les
|
||
# soixante-huit fiches de role passent par `Roles`, qui les rassemble ; les citer une
|
||
# a une dans la navigation en ferait un mur de noms ou plus personne ne trouverait les
|
||
# vingt-sept unites redigees. La garde forcait donc a degrader ce qu'elle protegeait.
|
||
#
|
||
# On suit maintenant les liens de proche en proche depuis la navigation. Une page
|
||
# qu'AUCUN chemin n'atteint reste orpheline, et c'est bien ce qu'on voulait mesurer.
|
||
sidebar = (wiki / "_Sidebar.md")
|
||
orphelines: list[str] = []
|
||
if sidebar.is_file():
|
||
nav = sidebar.read_text(encoding="utf-8", errors="ignore")
|
||
atteintes = {l for l in re.findall(MOTIF, nav) if l in pages}
|
||
a_suivre = list(atteintes)
|
||
while a_suivre:
|
||
courante = a_suivre.pop()
|
||
f = wiki / f"{courante}.md"
|
||
if not f.is_file():
|
||
continue
|
||
for l in re.findall(MOTIF, f.read_text(encoding="utf-8", errors="ignore")):
|
||
if l in pages and l not in atteintes:
|
||
atteintes.add(l)
|
||
a_suivre.append(l)
|
||
orphelines = sorted(p for p in pages
|
||
if p not in ("_Sidebar", "README", "Home")
|
||
and p not in atteintes)
|
||
|
||
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 `<groupe>_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.
|
||
|
||
ELLE REGARDE LES DEUX COTES DE LA CLOTURE DEPUIS LE 2026-09-14, et elle n'en regardait
|
||
qu'un. Ecrite pour l'instance MONTEE, elle ne voyait que des locataires. L'inventaire
|
||
du SITE ne derivait donc aucun nom — cinq groupes exposes, cinq devinettes de role.
|
||
|
||
Quatre tombaient juste par convention. Le cinquieme non : le plan du site expose
|
||
`observatoire.genese.internal`, `serveur_grafana` devinait `grafana.…`, et Grafana
|
||
fabriquait son `root_url` et ses liens avec un nom que rien ne sert. Exactement le
|
||
defaut du 2026-09-10, quatre jours plus tard, de l'autre cote — parce que la garde
|
||
avait ete posee la ou il s'etait montre, et pas la ou il pouvait se montrer aussi.
|
||
"""
|
||
lots: list[tuple[str, dict, dict]] = []
|
||
|
||
inv = RACINE / INV
|
||
if inv.is_file():
|
||
instance = instance_courante()
|
||
f_app = instance / "plan" / "applications.yml"
|
||
if f_app.is_file():
|
||
data = yaml.safe_load(inv.read_text(encoding="utf-8")) or {}
|
||
enfants = (data.get("all") or {}).get("children") or {}
|
||
hv: dict = {}
|
||
for etat in ("hotes_actifs", "hotes_planifies"):
|
||
for nom, v in ((enfants.get(etat) or {}).get("hosts") or {}).items():
|
||
hv[nom] = v or {}
|
||
lots.append((instance.name, _noms_exposes(f_app), {"enfants": enfants, "hv": hv}))
|
||
|
||
# LE SITE A UN INVENTAIRE DYNAMIQUE : on le lui demande, on ne lit pas un fichier.
|
||
site = _plan_du_site_applications()
|
||
if site:
|
||
rendu = _inventaire_du_site()
|
||
if rendu:
|
||
lots.append(("SITE", _noms_exposes(site), rendu))
|
||
|
||
if not lots:
|
||
return True, "Ni instance montee ni plan de site : rien a verifier."
|
||
|
||
fautes: list[str] = []
|
||
verifies = 0
|
||
for etiquette, attendu, rendu in lots:
|
||
enfants, hv = rendu["enfants"], rendu["hv"]
|
||
for (hote, groupe), fqdn in sorted(attendu.items()):
|
||
if hote not in ((enfants.get(groupe) or {}).get("hosts") or {}):
|
||
continue # le plan declare l'app ailleurs que la ou le groupe est pose
|
||
vu = str((hv.get(hote) or {}).get(f"{groupe}_hostname") or "")
|
||
verifies += 1
|
||
if vu != fqdn:
|
||
fautes.append(f"[{etiquette}] {hote} porte {groupe} sans "
|
||
f"`{groupe}_hostname` = `{fqdn}` "
|
||
f"(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]))
|
||
if not verifies:
|
||
return True, "Aucun groupe expose : rien a deriver."
|
||
return True, (f"{verifies} service(s) expose(s) portent le nom du plan, "
|
||
f"sur {len(lots)} inventaire(s) : "
|
||
+ ", ".join(e for e, _, _ in lots) + ".")
|
||
|
||
|
||
def _noms_exposes(f_app) -> dict:
|
||
"""(machine, groupe) -> le nom unique que le plan lui donne.
|
||
|
||
PAR HOTE, ET PAS PAR GROUPE SEUL (2026-09-15). Cette preuve indexait par groupe, comme
|
||
la derivation qu'elle garde — elle validait donc la meme erreur des deux cotes. Sur
|
||
Chezlepro, `serveur_oauth2_proxy` sert la vigie sur un hote et la console sur un
|
||
autre : les deux ont recu le DERNIER nom, et la preuve a rendu vert.
|
||
|
||
Une garde qui reproduit le raisonnement qu'elle verifie ne verifie rien.
|
||
"""
|
||
apps = (yaml.safe_load(f_app.read_text(encoding="utf-8")) or {}).get("applications") or {}
|
||
attendu: dict[tuple[str, 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, h = str(a.get("groupe") or ""), str(a.get("hote") or "")
|
||
if g and h and len(exp) == 1:
|
||
attendu[(h, g)] = exp[0]
|
||
return attendu
|
||
|
||
|
||
def _plan_du_site_applications():
|
||
"""Le `applications.yml` du site MONTE, ou None s'il n'y en a pas."""
|
||
lien = RACINE / "underlay.yml"
|
||
if not lien.exists():
|
||
return None
|
||
f = lien.resolve().parent / "plan" / "applications.yml"
|
||
return f if f.is_file() else None
|
||
|
||
|
||
def _inventaire_du_site():
|
||
"""L'inventaire DYNAMIQUE du site, dans la forme que cette preuve consomme.
|
||
|
||
On l'EXECUTE au lieu de lire un fichier : c'est un script, et son resultat est la
|
||
seule chose qu'Ansible verra. Juger sa source reviendrait a relire le raisonnement
|
||
au lieu du resultat.
|
||
"""
|
||
import json
|
||
import subprocess
|
||
try:
|
||
sortie = subprocess.run(
|
||
[sys.executable, str(RACINE / "scripts" / "site_inventaire.py"), "--list"],
|
||
capture_output=True, text=True, timeout=120, cwd=RACINE)
|
||
except (OSError, subprocess.SubprocessError):
|
||
return None
|
||
if sortie.returncode != 0:
|
||
return None
|
||
try:
|
||
d = json.loads(sortie.stdout)
|
||
except ValueError:
|
||
return None
|
||
return {"enfants": {g: {"hosts": {h: None for h in (v.get("hosts") or [])}}
|
||
for g, v in d.items() if g != "_meta"},
|
||
"hv": (d.get("_meta") or {}).get("hostvars") or {}}
|
||
|
||
|
||
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 `<role>_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`
|
||
vise `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.")
|
||
|
||
|
||
# --- LES REPLIS SILENCIEUX (P79) -----------------------------------------------------
|
||
#
|
||
# Chaque temoin ci-dessous prend ses donnees en PARAMETRE : on peut lui remettre la faute
|
||
# sous les yeux sans toucher au depot, et c'est ainsi qu'ils ont ete eprouves.
|
||
|
||
def _yaml_ou_vide(chemin: Path) -> dict:
|
||
"""Le contenu d'un fichier YAML, ou {} s'il n'existe pas (registre facultatif)."""
|
||
if not chemin.is_file():
|
||
return {}
|
||
return yaml.safe_load(chemin.read_text(encoding="utf-8")) or {}
|
||
|
||
|
||
def _repli_pattes(serveurs: dict, if_zones: dict) -> list[str]:
|
||
"""Chaque zone OCCUPEE par une machine active du site a sa patte a la frontiere.
|
||
|
||
Un site qui n'a pas encore de table par zone est plat, et la patte unique est alors
|
||
juste. Des que la table existe, une zone qui y manque retombe sur la patte plate.
|
||
"""
|
||
if not if_zones:
|
||
return []
|
||
occupees: dict[str, list[str]] = {}
|
||
for nom, s in (serveurs or {}).items():
|
||
if isinstance(s, dict) and str(s.get("etat", "actif")) == "actif" and s.get("reseau"):
|
||
occupees.setdefault(str(s["reseau"]), []).append(str(nom))
|
||
return [f"patte : la zone `{z}` ({', '.join(sorted(n))}) manque a `opnsense_if_zones` "
|
||
f"— ses regles tomberaient sur la patte plate, justes et jamais rencontrees"
|
||
for z, n in sorted(occupees.items()) if z not in if_zones]
|
||
|
||
|
||
def _repli_edge(nom: str, applications: dict, domaines: dict, a_un_edge: bool) -> list[str]:
|
||
"""Dans un ecosysteme qui A un edge, aucune exposition ne retombe sur son propre groupe.
|
||
|
||
Le temoin n'est pas la derivation : la presence d'un edge vient de l'inventaire, et les
|
||
edges legitimes de ce que `domaines.yml` ECRIT. Le repli « le service se sert lui-meme »
|
||
n'est juste que la ou il n'y a personne pour le servir.
|
||
"""
|
||
if not a_un_edge:
|
||
return []
|
||
from inventory_rules import expositions_des_applications
|
||
doms = (domaines or {}).get("domaines_publics") or {}
|
||
ecrits = {str(c["edge"]) for c in doms.values() if isinstance(c, dict) and c.get("edge")}
|
||
return [f"edge : {nom} — `{e['fqdn']}` retombe sur son propre groupe (`{e['edge']}`) "
|
||
f"alors que l'ecosysteme a un edge : ni vhost ni SAN ne le retiendront"
|
||
for e in expositions_des_applications(applications, domaines)
|
||
# Sans port, aucun vhost ne peut la servir : elle se sert elle-meme, a juste
|
||
# titre (`sauvegarde`, `pki`, `dns` au site — voir `expositions_des_applications`).
|
||
if e.get("port") and e["edge"] != "serveur_nginx" and str(e["edge"]) not in ecrits]
|
||
|
||
|
||
def _repli_hotes_du_site(enfants: dict, hv: dict) -> list[str]:
|
||
"""Ce que l'inventaire DYNAMIQUE du site donne a ses machines, sans group_vars pour
|
||
rattraper un defaut de role."""
|
||
def membres(g: str) -> set[str]:
|
||
return set(((enfants.get(g) or {}).get("hosts")) or {})
|
||
|
||
fautes: list[str] = []
|
||
pki = membres("client_pki")
|
||
for h in sorted(membres("serveur_nginx")):
|
||
v = hv.get(h) or {}
|
||
cert = str(v.get("serveur_nginx_certificat") or "")
|
||
if not cert or "snakeoil" in cert:
|
||
fautes.append(f"bouchon : {h} sert `{cert or 'le defaut du role, snakeoil'}` "
|
||
f"— du TLS qui ressemble a du TLS")
|
||
if h in pki and "nginx" not in (v.get("client_pki_reload_services") or []):
|
||
fautes.append(f"rechargement : {h} renouvelle son certificat sans recharger "
|
||
f"nginx — le nouveau reste sur disque, l'ancien est servi")
|
||
if membres("serveur_artefacts"):
|
||
for h, v in sorted(hv.items()):
|
||
if (v or {}).get("dns_amorcage") and not (v or {}).get("artefacts_amorcage"):
|
||
fautes.append(f"jumeaux : {h} recoit `dns_amorcage` sans "
|
||
f"`artefacts_amorcage` — le gabarit garde son ancien proxy apt")
|
||
return fautes
|
||
|
||
|
||
def _repli_devis_internet(flux: dict, roles_presents: set[str], regles: list[dict]) -> list[str]:
|
||
"""Une sortie qui NOMME un role absent du site ne devient pas une sortie vers l'Internet.
|
||
|
||
Le temoin lit les `meta/flux.yml` : les ports qu'un role n'emploie QUE vers des roles
|
||
absents ne doivent porter aucune regle `!SETOPS_INTERNES`. Un port qu'il emploie aussi
|
||
vers un pair lointain (`externe`, un depot) y a sa place, et il est ecarte.
|
||
"""
|
||
connus = {d.name for d in (RACINE / "roles").iterdir() if d.is_dir()}
|
||
fautes: set[str] = set()
|
||
for role in sorted(roles_presents):
|
||
absents, lointains = set(), set()
|
||
for fl in flux.get(role, []):
|
||
if fl.get("sens") != "egress":
|
||
continue
|
||
pairs = fl.get("pair") if isinstance(fl.get("pair"), list) else [fl.get("pair")]
|
||
ports = fl["port"] if isinstance(fl.get("port"), list) else [fl.get("port")]
|
||
nommes = {str(p) for p in pairs} & connus
|
||
if nommes and not nommes & roles_presents:
|
||
absents.update(str(p) for p in ports)
|
||
elif not nommes:
|
||
lointains.update(str(p) for p in ports)
|
||
suspects = absents - lointains
|
||
for r in regles:
|
||
if (str(r.get("tenant")) == "SITE" and r.get("role") == role
|
||
and str(r.get("destination")) == "!SETOPS_INTERNES"):
|
||
for port in sorted(suspects & {str(p) for p in (r.get("ports") or [])}):
|
||
fautes.add(f"internet : {role} sort vers l'Internet sur {port}, un port "
|
||
f"qu'il n'emploie que vers un role absent du site")
|
||
return sorted(fautes)
|
||
|
||
|
||
def _flux_genere_perime(base: Path) -> list[str]:
|
||
"""Les regles d'hote d'un ecosysteme, rejouees pour LUI — pas pour l'instance montee.
|
||
|
||
`make flux` n'ecrit que pour l'instance montee. On rejoue la generation dans un dossier
|
||
temporaire qui reprend l'instance par liens, et l'on compare : rien n'est ecrit dans le
|
||
depot.
|
||
"""
|
||
import os
|
||
import subprocess
|
||
import tempfile
|
||
reel = base / "flux-genere"
|
||
with tempfile.TemporaryDirectory(prefix="p79-") as tmp:
|
||
for e in base.iterdir():
|
||
if e.name not in ("flux-genere", ".git"):
|
||
os.symlink(e, Path(tmp) / e.name)
|
||
r = subprocess.run([sys.executable, str(RACINE / "scripts" / "resoudre_flux.py"), "nftables"],
|
||
env=dict(os.environ, SETOPS_INSTANCE=tmp), capture_output=True,
|
||
text=True, cwd=RACINE, timeout=300)
|
||
if r.returncode != 0:
|
||
derniere = (r.stderr.strip().splitlines() or ["sans message"])[-1]
|
||
return [f"flux : {base.name} — la generation echoue ({derniere[:100]})"]
|
||
gen = Path(tmp) / "flux-genere"
|
||
a = {p.name for p in reel.glob("*.nft")} if reel.is_dir() else set()
|
||
b = {p.name for p in gen.glob("*.nft")} if gen.is_dir() else set()
|
||
differents = sorted(n for n in a & b if (reel / n).read_bytes() != (gen / n).read_bytes())
|
||
fautes = []
|
||
if b - a:
|
||
fautes.append(f"flux : {base.name} — aucune regle generee pour {', '.join(sorted(b - a))}")
|
||
if a - b:
|
||
fautes.append(f"flux : {base.name} — regles d'une machine retiree du plan : "
|
||
f"{', '.join(sorted(a - b))}")
|
||
if differents:
|
||
fautes.append(f"flux : {base.name} — perimees par rapport au plan : {', '.join(differents)}")
|
||
return fautes
|
||
|
||
|
||
def preuve_une_derivation_vide_se_dit() -> tuple[bool, str]:
|
||
"""Une derivation qui ne trouve rien ne passe pas pour une derivation qui n'a rien a trouver.
|
||
|
||
POURQUOI (2026-09-14 et 15). En deux jours, pour monter l'edge du site puis les trois
|
||
consoles, les memes defauts sont revenus sous des noms differents. Chaque fois, un
|
||
repli a rendu un SUCCES au lieu d'un refus : deploiement vert, devis muet, harnais vert.
|
||
|
||
1. `site_inventaire` derivait `dns_amorcage` sans son jumeau `artefacts_amorcage` : les
|
||
machines nees avant le renumerotage gardaient le proxy apt du gabarit.
|
||
2. Une zone manquait a `opnsense_if_zones` : `_if_de()` retombait sur la patte plate,
|
||
20 regles posees et jamais rencontrees.
|
||
3. L'edge d'une exposition retombait sur le groupe de l'application : vhost de 43
|
||
octets, certificat sans les noms publies.
|
||
4. L'edge du site servait `ssl-cert-snakeoil.pem`, le defaut du role, faute de
|
||
group_vars dans un inventaire dynamique.
|
||
5. Un certificat renouvele sans `client_pki_reload_services` : servi perime.
|
||
6. Une sortie vers un role absent du site devenait une sortie vers l'Internet.
|
||
7. `make flux`, lance avec un locataire monte, n'a rien regenere pour l'autre : 502
|
||
derriere un TLS parfait.
|
||
|
||
Chaque temoin lit sa source DIRECTEMENT (inventaire, plan, registre, table ecrite) et
|
||
ne passe pas par la derivation qu'il juge : une garde qui reproduit le raisonnement
|
||
qu'elle verifie ne verifie rien (cf. P43, P67).
|
||
|
||
CE QU'ELLE NE COUVRE PAS. La collision des noms publics par groupe (P67 la garde).
|
||
`client_pki` qui rend `changed=0` sans comparer ses SAN a ceux qu'il derive : cela se
|
||
mesure sur la machine (`make certificats-plan`), pas dans le depot. Et un registre
|
||
facultatif exige par `include_vars` : celui-la echouait bruyamment, ce n'etait pas un
|
||
repli.
|
||
"""
|
||
sys.path.insert(0, str(RACINE / "scripts"))
|
||
import instances as mod_instances
|
||
|
||
fautes: list[str] = []
|
||
couverts: list[str] = []
|
||
|
||
for i in mod_instances.decouvrir():
|
||
if i.get("site"):
|
||
continue
|
||
base = RACINE.parent / i["nom"]
|
||
inv = next((base / "inventories" / x / "hosts.yml" for x in ("principal", "production")
|
||
if (base / "inventories" / x / "hosts.yml").is_file()), None)
|
||
if inv is None:
|
||
continue
|
||
enfants = ((_yaml_ou_vide(inv).get("all") or {}).get("children")) or {}
|
||
a_un_edge = bool(((enfants.get("serveur_nginx") or {}).get("hosts")) or {})
|
||
fautes += _repli_edge(i["nom"], _yaml_ou_vide(base / "plan" / "applications.yml"),
|
||
_yaml_ou_vide(base / "plan" / "domaines.yml"), a_un_edge)
|
||
gv_nginx = inv.parent / "group_vars" / "serveur_nginx.yml"
|
||
if a_un_edge and "nginx" not in (_yaml_ou_vide(gv_nginx).get("client_pki_reload_services") or []):
|
||
fautes.append(f"rechargement : {i['nom']} — l'edge renouvelle son certificat sans "
|
||
f"recharger nginx")
|
||
if (base / "flux-genere").is_dir():
|
||
fautes += _flux_genere_perime(base)
|
||
couverts.append(i["nom"])
|
||
|
||
lien = RACINE / "underlay.yml"
|
||
if lien.exists():
|
||
site = lien.resolve().parent
|
||
inventaire = _inventaire_du_site()
|
||
if inventaire is None:
|
||
fautes.append(f"site : l'inventaire dynamique de {site.name} ne s'execute pas — "
|
||
f"rien de ce qu'il derive ne peut etre juge")
|
||
else:
|
||
enfants, hv = inventaire["enfants"], inventaire["hv"]
|
||
fautes += _repli_edge(site.name, _yaml_ou_vide(site / "plan" / "applications.yml"),
|
||
_yaml_ou_vide(site / "plan" / "domaines.yml"),
|
||
bool(((enfants.get("serveur_nginx") or {}).get("hosts")) or {}))
|
||
fautes += _repli_hotes_du_site(enfants, hv)
|
||
fautes += _repli_pattes(_yaml_ou_vide(site / "plan" / "serveurs.yml").get("serveurs") or {},
|
||
_yaml_ou_vide(site / "opnsense.yml").get("opnsense_if_zones") or {})
|
||
import contextlib
|
||
import io
|
||
import devis_opnsense as D
|
||
import resoudre_flux as RF
|
||
with contextlib.redirect_stderr(io.StringIO()):
|
||
regles = D.construire(D.decouvrir_du_site()).get("regles") or []
|
||
presents = {g for g, v in enfants.items() if (v.get("hosts") or {})}
|
||
fautes += _repli_devis_internet(RF.charger_flux(), presents, regles)
|
||
couverts.append(site.name)
|
||
|
||
if fautes:
|
||
return False, (f"{len(fautes)} repli(s) silencieux — une derivation vide rend un "
|
||
f"succes :\n - " + "\n - ".join(fautes[:10])
|
||
+ ("\n - …" if len(fautes) > 10 else ""))
|
||
if not couverts:
|
||
return True, "Aucun ecosysteme decouvert : aucune derivation a juger."
|
||
return True, (f"{len(couverts)} ecosysteme(s) ({', '.join(couverts)}) : pattes, edges, "
|
||
f"certificats, rechargements, jumeaux d'amorcage, sorties du devis et regles "
|
||
f"d'hote par instance — aucune derivation vide ne passe pour un succes.")
|
||
|
||
|
||
def preuve_remise_tenue() -> tuple[bool, str]:
|
||
"""Un ecosysteme remis l'est vraiment — et son second temps ne s'oublie pas.
|
||
|
||
POURQUOI (2026-09-16). Remettre un ecosysteme se terminait par une phrase — « tes cles
|
||
te seront remises separement » — et rien n'ecrivait ce qui se passait ensuite. Le geste
|
||
qui donne le controle d'une organisation etait le seul geste lourd du depot sans
|
||
procedure, sans outil et sans garde.
|
||
|
||
LA REMISE SE FAIT EN DEUX TEMPS (`docs/remise-au-client.md`) : l'IDENTITE d'abord — le
|
||
client gouverne ses gens des le premier jour — puis la MACHINE, a une echeance
|
||
convenue : sa cle SSH entre, celle de l'hebergeur sort, la voute change de mot de
|
||
passe.
|
||
|
||
CE QUE CETTE PREUVE REFUSE, et chaque refus ferme un etat qui s'installerait tout seul :
|
||
|
||
- un registre INCOMPLET : remis a personne, ou sans echeance. Une remise dont on ne
|
||
sait pas a qui elle a ete faite n'est pas une remise, c'est un depot ;
|
||
- un second temps ECHU et non fait. C'est le defaut qui se produit par simple
|
||
ecoulement du temps : personne ne decide de garder l'acces machine d'un client —
|
||
on oublie de le rendre, et le silence transforme l'oubli en etat de fait ;
|
||
- un second temps DECLARE FAIT pendant que le plan ne revoque AUCUNE cle
|
||
d'administration. C'est le seul mensonge que ce fichier puisse porter sans qu'on
|
||
s'en apercoive, parce qu'il flatte tout le monde ;
|
||
- un SECRET dans le registre. Il est versionne et pousse sur trois forges : il porte
|
||
des empreintes, jamais des valeurs.
|
||
|
||
CE QU'ELLE NE JUGE PAS. Un ecosysteme SANS registre : tous ne sont pas remis, et
|
||
plusieurs ne le seront jamais — le lab, l'ecosysteme de l'hebergeur
|
||
lui-meme. Exiger un registre partout ferait du bruit la ou il n'y a rien a tenir.
|
||
|
||
ET CE QU'ELLE NE PEUT PAS PROUVER : que l'hebergeur ne puisse PLUS entrer. Le plan
|
||
declare la revocation, le deploiement l'applique ; le verifier depuis l'exterieur
|
||
demande d'essayer d'entrer, donc une machine vivante. Meme partage que partout ici.
|
||
"""
|
||
sys.path.insert(0, str(RACINE / "scripts"))
|
||
import datetime as _dt2
|
||
|
||
import instances as mod_instances
|
||
|
||
# CE QUI DISTINGUE UN SECRET D'UN NOM LONG (mesure du 2026-09-16, sur le premier
|
||
# registre sain). Mon premier motif prenait toute chaine de 24 caracteres sans espace
|
||
# pour un secret — donc `setops-vault-ops-technolibre`, qui est un NOM DE FICHIER, et
|
||
# que le registre doit nommer. La preuve refusait alors la remise la plus normale, et
|
||
# ce faux positif masquait les cinq autres verdicts.
|
||
#
|
||
# Ce qu'on cherche vraiment : de l'ENTROPIE — un melange de casses et de chiffres, ou
|
||
# l'alphabet de base64. Un nom de fichier, une organisation, une date et un courriel
|
||
# n'en ont aucune ; une empreinte SHA256 est le contenu legitime de ce fichier.
|
||
motif_empreinte = re.compile(r"^[0-9a-f]{64}$")
|
||
|
||
def _opaque(v: str) -> bool:
|
||
if motif_empreinte.match(v) or len(v) < 20:
|
||
return False
|
||
if not re.fullmatch(r"[A-Za-z0-9+/=_.-]+", v):
|
||
return False
|
||
melange = (any(c.islower() for c in v) and any(c.isupper() for c in v)
|
||
and any(c.isdigit() for c in v))
|
||
return melange or bool(re.fullmatch(r"[A-Za-z0-9+/]{24,}={0,2}", v))
|
||
|
||
def _valeurs(n):
|
||
if isinstance(n, dict):
|
||
for k, v in n.items():
|
||
yield from _valeurs(k)
|
||
yield from _valeurs(v)
|
||
elif isinstance(n, list):
|
||
for v in n:
|
||
yield from _valeurs(v)
|
||
elif isinstance(n, str):
|
||
yield n
|
||
|
||
fautes: list[str] = []
|
||
tenus: list[str] = []
|
||
aujourd_hui = _dt2.date.today()
|
||
|
||
for i in mod_instances.decouvrir():
|
||
base = RACINE.parent / i["nom"]
|
||
f = base / "remise.yml"
|
||
if not f.is_file():
|
||
continue
|
||
try:
|
||
r = (yaml.safe_load(f.read_text(encoding="utf-8")) or {}).get("remise") or {}
|
||
except Exception as e: # noqa: BLE001
|
||
fautes.append(f"{i['nom']} : registre de remise illisible ({type(e).__name__})")
|
||
continue
|
||
|
||
t1 = r.get("temps_1") or {}
|
||
t2 = r.get("temps_2") or {}
|
||
recu = (t1.get("recu_par") or {})
|
||
if not t1.get("remis_le"):
|
||
fautes.append(f"{i['nom']} : registre sans date de remise")
|
||
if not (recu.get("nom") and recu.get("courriel")):
|
||
fautes.append(f"{i['nom']} : remis a personne — le responsable designe (D-18) "
|
||
f"n'est pas nomme")
|
||
if not t2.get("du_le"):
|
||
fautes.append(f"{i['nom']} : aucune echeance pour le second temps — la "
|
||
f"revocation n'a alors jamais de retard, donc jamais lieu")
|
||
|
||
for v in _valeurs(r):
|
||
if _opaque(v):
|
||
fautes.append(f"{i['nom']} : le registre porte une valeur opaque "
|
||
f"({v[:8]}…) — il est versionne et pousse ; il porte des "
|
||
f"empreintes, jamais des secrets")
|
||
break
|
||
|
||
# Les cles d'administration declarees au plan — lues ici, pas demandees a l'outil
|
||
# de remise : une garde qui interroge ce qu'elle surveille ne surveille rien.
|
||
revoquees = 0
|
||
for nom in ("principal", "production", "lab"):
|
||
intrants = base / "inventories" / nom / "group_vars" / "all" / "10-intrants.yml"
|
||
if not intrants.is_file():
|
||
continue
|
||
try:
|
||
d = yaml.safe_load(intrants.read_text(encoding="utf-8")) or {}
|
||
except Exception: # noqa: BLE001
|
||
continue
|
||
if "ssh_baseline_cles_admin" in d:
|
||
revoquees = sum(1 for e in (d.get("ssh_baseline_cles_admin") or [])
|
||
if isinstance(e, dict) and str(e.get("etat", "present")) == "absent")
|
||
break
|
||
|
||
if t2.get("fait_le"):
|
||
if not revoquees:
|
||
fautes.append(f"{i['nom']} : le registre dit le second temps fait le "
|
||
f"{t2['fait_le']}, et le plan ne revoque AUCUNE cle "
|
||
f"d'administration")
|
||
tenus.append(f"{i['nom']} (remis et re-clete)")
|
||
elif t2.get("du_le"):
|
||
try:
|
||
echeance = _dt2.date.fromisoformat(str(t2["du_le"]))
|
||
except ValueError:
|
||
fautes.append(f"{i['nom']} : echeance du second temps illisible "
|
||
f"({t2['du_le']})")
|
||
continue
|
||
if echeance < aujourd_hui:
|
||
fautes.append(f"{i['nom']} : second temps DU DEPUIS "
|
||
f"{(aujourd_hui - echeance).days} jour(s) ({echeance}) — "
|
||
f"l'hebergeur garde l'acces machine d'un client qui se croit "
|
||
f"chez lui")
|
||
else:
|
||
tenus.append(f"{i['nom']} (second temps du le {echeance})")
|
||
|
||
if fautes:
|
||
return False, ("Remise non tenue :\n - " + "\n - ".join(fautes[:6])
|
||
+ ("\n - …" if len(fautes) > 6 else ""))
|
||
if not tenus:
|
||
return True, "Aucun ecosysteme remis a un client : rien a tenir."
|
||
return True, f"{len(tenus)} remise(s) tenue(s) : {', '.join(tenus)}."
|
||
|
||
|
||
def preuve_console_dit_sa_portee() -> tuple[bool, str]:
|
||
"""Une console ne sert jamais un inventaire vide en silence, et rien n'echappe a sa garde.
|
||
|
||
POURQUOI (2026-09-16). Servie par le runner d'un SITE, la console d'exploitation
|
||
affichait ZERO serveur, ZERO application, ZERO base — sans une erreur. `serveur_ops`
|
||
retire le lien `instance` sur un runner d'hebergeur (a juste titre), `charger_yaml`
|
||
rend un inventaire vide sur un fichier absent, et la page dessinait ce vide comme un
|
||
plan vide. Le site a sept machines ; sa console n'en montrait aucune.
|
||
|
||
CE QU'ELLE VERIFIE, hors ligne :
|
||
|
||
1. La portee se DERIVE de ce que la machine porte, et jamais d'un reglage declare.
|
||
Un reglage serait une seconde liste, et une liste qui suit une autre prend du
|
||
retard. Depuis le 2026-09-20 elle se derive des VOUTES et non des seuls symlinks :
|
||
`configurer` exige la voute du tenant, `materialiser` celle du site. Une carte
|
||
lue ne vaut pas une voute ouverte — cf. `scripts/tests/test_portee_console.py`.
|
||
2. Une console qui a quelque chose a piloter nomme sa SOURCE d'inventaire. Aucune
|
||
portee pilotable ne rend `inventaire: null`.
|
||
3. La source du SITE rend vraiment des machines quand le site en declare : c'est le
|
||
defaut d'origine, et il ne doit pas pouvoir revenir.
|
||
4. TOUTE route POST figure dans `POUVOIR_REQUIS`. C'est la garde anti-derive : une
|
||
route d'ecriture ajoutee demain sans y penser s'executerait sur une console qui
|
||
n'en a pas le pouvoir, et personne ne le verrait avant l'incident.
|
||
|
||
CE QU'ELLE NE PROUVE PAS : qu'une console REFUSE vraiment. Cela demande de la lancer et
|
||
de lui parler ; ici on verifie qu'elle a de quoi refuser, et que rien n'y echappe.
|
||
"""
|
||
sys.path.insert(0, str(RACINE / "scripts"))
|
||
import inventory_gui as gui
|
||
|
||
fautes: list[str] = []
|
||
ctx = gui.contexte()
|
||
|
||
if ctx["portee"] not in ("poste", "tenant", "site", "orphelin"):
|
||
fautes.append(f"portee inconnue : {ctx['portee']}")
|
||
if ctx["portee"] != "orphelin" and not ctx.get("inventaire"):
|
||
fautes.append(f"portee `{ctx['portee']}` sans source d'inventaire nommee — c'est "
|
||
f"exactement le vide silencieux que cette preuve existe pour refuser")
|
||
|
||
# La source du site, verifiee CHEZ le site, quand un underlay est monte.
|
||
if gui.FICHIER_UNDERLAY.exists():
|
||
try:
|
||
data = gui.inventaire_du_site()
|
||
hotes = {h for g in (data.get("all") or {}).get("children", {}).values()
|
||
for h in (g.get("hosts") or {})}
|
||
except Exception as e: # noqa: BLE001
|
||
hotes = set()
|
||
fautes.append(f"l'inventaire du site ne s'execute pas ({type(e).__name__}) : "
|
||
f"une console de site n'aurait rien a montrer")
|
||
if not hotes:
|
||
fautes.append("un underlay est monte et l'inventaire du site ne rend AUCUNE "
|
||
"machine — la console de site afficherait un ecran vide")
|
||
|
||
# Toute route POST doit exiger un pouvoir. On lit la SOURCE, pas la table : une table
|
||
# qui se decrirait elle-meme ne prouverait rien.
|
||
source = (RACINE / "scripts" / "inventory_gui.py").read_text(encoding="utf-8")
|
||
debut = source.find("def do_POST")
|
||
routes = set(re.findall(r'chemin == "(/api/[a-z-]+)"', source[debut:])) if debut >= 0 else set()
|
||
if not routes:
|
||
fautes.append("aucune route POST trouvee dans le GUI : la lecture de la source a "
|
||
"change de forme, et cette preuve ne prouve plus rien")
|
||
manquantes = sorted(routes - set(gui.POUVOIR_REQUIS))
|
||
if manquantes:
|
||
fautes.append(f"route(s) POST sans pouvoir exige : {', '.join(manquantes)} — elles "
|
||
f"s'executeraient sur une console qui n'en a pas le droit")
|
||
inutiles = sorted(set(gui.POUVOIR_REQUIS) - routes)
|
||
if inutiles:
|
||
fautes.append(f"`POUVOIR_REQUIS` nomme des routes qui n'existent plus : "
|
||
f"{', '.join(inutiles)}")
|
||
|
||
# LA PAGE DOIT PORTER LA MEME COUPURE. Un serveur qui refuse pendant qu'une page
|
||
# propose fait decouvrir l'interdit AU CLIC — au milieu d'un geste, sur une console
|
||
# qu'on croyait la bonne. Ce n'est pas la garde, c'est ce qui evite d'y aller.
|
||
bloc = re.search(r"const POUVOIR_PAR_MODE = \{([^}]*)\}", source, re.S)
|
||
if not bloc:
|
||
fautes.append("la page ne porte plus `POUVOIR_PAR_MODE` : les boutons "
|
||
"proposeraient des gestes que le serveur refuse")
|
||
else:
|
||
for mode in ("creer", "verifier", "deployer", "pousser"):
|
||
if mode not in bloc.group(1):
|
||
fautes.append(f"le mode `{mode}` n'est rattache a aucun pouvoir dans la page")
|
||
|
||
if fautes:
|
||
return False, ("La console ne dit pas ce qu'elle peut :\n - " + "\n - ".join(fautes))
|
||
return True, (f"Portee `{ctx['portee']}` derivee des voutes portees, source d'inventaire "
|
||
f"`{ctx['inventaire']}`, et {len(routes)} route(s) POST exigent toutes "
|
||
f"un pouvoir.")
|
||
|
||
|
||
# --- LE DNS PUBLIC (P82) : chaque temoin prend ses donnees en parametre --------------
|
||
|
||
_PRIVEE = ("10.", "127.", "192.168.", "169.254.") + tuple(f"172.{i}." for i in range(16, 32))
|
||
|
||
|
||
def _dns_zones_declarees(plans: dict) -> tuple[dict, list[str]]:
|
||
"""{zone: ecosysteme} des zones `primaire-cache`, et les fautes de declaration."""
|
||
attendu, fautes = {}, []
|
||
for eco, doms in plans.items():
|
||
for zone, conf in (doms or {}).items():
|
||
if isinstance(conf, dict) and str(conf.get("autorite")) == "primaire-cache":
|
||
if zone.rstrip(".").endswith(".internal"):
|
||
fautes.append(f"{eco} : `{zone}` est declaree `primaire-cache` — une zone "
|
||
f"interne publiee serait la carte de ses machines offerte au monde")
|
||
attendu[zone] = eco
|
||
return attendu, fautes
|
||
|
||
|
||
def _dns_zones_servies(attendu: dict, servies: set, a_un_serveur: bool) -> list[str]:
|
||
fautes = []
|
||
if attendu and not a_un_serveur:
|
||
fautes.append(f"{len(attendu)} zone(s) `primaire-cache` declaree(s) et aucun serveur public "
|
||
f"au site : l'autorite est declaree, jamais exercee")
|
||
return fautes
|
||
for z in sorted(set(attendu) - servies):
|
||
fautes.append(f"`{z}` ({attendu[z]}) n'est servie par aucun serveur public du site")
|
||
for z in sorted(servies - set(attendu)):
|
||
fautes.append(f"`{z}` est servie par le serveur public sans etre declaree `primaire-cache`")
|
||
return fautes
|
||
|
||
|
||
def _dns_paires(declarations: dict) -> list[str]:
|
||
"""{site: [(pair, etat)]} -> fautes. Une relation vaut des deux cotes, ou pas du tout."""
|
||
fautes = []
|
||
for site, liens in declarations.items():
|
||
for pair, etat in liens:
|
||
retour = dict(declarations.get(pair, []))
|
||
if site not in retour:
|
||
fautes.append(f"{site} declare une replication avec {pair}, qui ne la declare pas — "
|
||
f"le second serveur de noms serait vide sans que personne le voie")
|
||
elif retour[site] != etat:
|
||
fautes.append(f"{site} -> {pair} est `{etat}`, {pair} -> {site} est `{retour[site]}`")
|
||
return fautes
|
||
|
||
|
||
def _dns_exposition(flux: list, dnssec_par_zone: dict) -> list[str]:
|
||
"""Exposer le serveur public a Internet exige DNSSEC sur TOUTES ses zones."""
|
||
expose = any(f.get("sens") == "ingress" and "externe" in
|
||
(f.get("pair") if isinstance(f.get("pair"), list) else [f.get("pair")])
|
||
for f in (flux or []))
|
||
if not expose:
|
||
return []
|
||
return [f"`{z}` : le serveur public est expose a Internet et la zone n'est pas signee — son "
|
||
f"hebergeur, ou le site pair, pourrait la falsifier sans que personne le detecte"
|
||
for z, signe in sorted(dnssec_par_zone.items()) if not signe]
|
||
|
||
|
||
def _dns_question_avant_transfert(flux: list) -> list[str]:
|
||
"""La sortie vers les primaires porte l'UDP (le SOA) ET le TCP (le transfert)."""
|
||
protos = {str(f.get("protocole")) for f in (flux or [])
|
||
if f.get("sens") == "egress" and "primaires_dns_locataires" in
|
||
(f.get("pair") if isinstance(f.get("pair"), list) else [f.get("pair")])}
|
||
if not protos:
|
||
return []
|
||
manque = sorted({"udp", "tcp"} - protos)
|
||
return [f"la sortie vers les primaires ne declare pas `{m}` — "
|
||
+ ("le secondaire ne peut jamais demander le SOA, donc jamais savoir qu'une zone a change"
|
||
if m == "udp" else "le transfert lui-meme ne peut pas passer")
|
||
for m in manque]
|
||
|
||
|
||
def preuve_dns_public_tient() -> tuple[bool, str]:
|
||
"""Le DNS public du site sert exactement les zones que ses locataires publient.
|
||
|
||
POURQUOI (2026-09-16). Le locataire ecrit sa zone publique (`autorite: primaire-cache`),
|
||
le serveur public du site la sert, et un site pair la replique. Chaque maillon peut se
|
||
rompre EN SILENCE : une zone que personne ne sert se lit « serveur de noms defaillant »
|
||
chez le registraire, et une zone `.internal` servie publie la carte d'un locataire.
|
||
|
||
CE QU'ELLE REFUSE, chaque temoin lisant sa source directement :
|
||
|
||
1. une zone `.internal` declaree `primaire-cache` ;
|
||
2. une zone `primaire-cache` d'un locataire du site que le serveur public ne sert pas,
|
||
ou un serveur public qui sert une zone que personne ne publie — la liste servie
|
||
est lue dans l'inventaire du site, la liste attendue dans les plans des locataires ;
|
||
3. une adresse publique PRIVEE transmise a un locataire ;
|
||
4. une replication entre sites declaree d'un seul cote ;
|
||
5. la regle apprise en eprouvant PowerDNS : l'instance publique n'autorise que la boucle
|
||
locale au transfert (`allow-axfr-ips` et TSIG sont ALTERNATIFS — y lister le
|
||
secondaire rendrait la signature decorative) ;
|
||
7. une sortie vers les primaires sans UDP : le secondaire demande le SOA en UDP avant
|
||
de transferer en TCP. Mesure en production le 2026-09-16 — le TCP seul laissait
|
||
passer un tirage force et empechait tous les tirages normaux ;
|
||
6. LA CONDITION DE LA PHASE 2, ECRITE COMME UNE GARDE : exposer le serveur public a
|
||
Internet sans DNSSEC sur toutes ses zones. Sans signature, l'hebergeur ou le site
|
||
pair pourraient falsifier la zone d'un client sans que personne le detecte.
|
||
"""
|
||
lien = RACINE / "underlay.yml"
|
||
if not lien.exists():
|
||
return True, "Aucun site monte : aucun DNS public a tenir."
|
||
sys.path.insert(0, str(RACINE / "scripts"))
|
||
import devis_reseau
|
||
|
||
fautes: list[str] = []
|
||
plans, dnssec, ips = {}, {}, {}
|
||
for nom, _p, _n in devis_reseau.decouvrir_du_site():
|
||
base = devis_reseau.DOSSIER_INSTANCES / nom
|
||
doms = _yaml_ou_vide(base / "plan" / "domaines.yml").get("domaines_publics") or {}
|
||
plans[nom] = doms
|
||
for z, c in doms.items():
|
||
if isinstance(c, dict) and str(c.get("autorite")) == "primaire-cache":
|
||
dnssec[z] = bool(c.get("dnssec"))
|
||
for inv in ("principal", "production"):
|
||
intr = _yaml_ou_vide(base / "inventories" / inv / "group_vars" / "all" / "10-intrants.yml")
|
||
if intr.get("ip_publique_site"):
|
||
ips[nom] = str(intr["ip_publique_site"])
|
||
|
||
attendu, f1 = _dns_zones_declarees(plans)
|
||
fautes += f1
|
||
|
||
inventaire = _inventaire_du_site()
|
||
if inventaire is None:
|
||
fautes.append("l'inventaire du site ne s'execute pas : impossible de savoir ce qui est servi")
|
||
else:
|
||
enfants, hv = inventaire["enfants"], inventaire["hv"]
|
||
serveurs = set(((enfants.get("serveur_dns_public") or {}).get("hosts")) or {})
|
||
servies = {z for h in serveurs
|
||
for r in ((hv.get(h) or {}).get("serveur_dns_public_relations") or [])
|
||
for z in (r.get("zones") or [])}
|
||
fautes += _dns_zones_servies(attendu, servies, bool(serveurs))
|
||
|
||
for eco, ip in sorted(ips.items()):
|
||
if ip.startswith(_PRIVEE):
|
||
fautes.append(f"{eco} : `ip_publique_site` vaut {ip}, une adresse privee")
|
||
|
||
declarations = {}
|
||
for d in sorted(RACINE.parent.glob("SITE-*")):
|
||
liens = _yaml_ou_vide(d / "plan" / "10-intrants.yml").get("dns_public_pairs") or []
|
||
if liens:
|
||
declarations[d.name] = [(str(x.get("site")), str(x.get("etat", "actif")))
|
||
for x in liens if isinstance(x, dict)]
|
||
fautes += _dns_paires(declarations)
|
||
|
||
gabarit = RACINE / "roles" / "serveur_powerdns" / "templates" / "pdns-public.conf.j2"
|
||
lignes = [l.strip() for l in gabarit.read_text(encoding="utf-8").splitlines()
|
||
if l.strip().startswith("allow-axfr-ips")] if gabarit.is_file() else []
|
||
if lignes != ["allow-axfr-ips=127.0.0.1/32"]:
|
||
fautes.append(f"l'instance publique autorise au transfert autre chose que la boucle locale "
|
||
f"({lignes or 'rien de declare : le defaut de PowerDNS s applique'}) — "
|
||
f"TSIG deviendrait decoratif")
|
||
|
||
flux_public = _yaml_ou_vide(RACINE / "roles" / "serveur_dns_public" / "meta" / "flux.yml").get("flux") or []
|
||
fautes += _dns_exposition(flux_public, dnssec)
|
||
fautes += _dns_question_avant_transfert(flux_public)
|
||
|
||
if fautes:
|
||
return False, "DNS public :\n - " + "\n - ".join(fautes)
|
||
return True, (f"{len(attendu)} zone(s) publique(s) declaree(s), toutes servies par le site ; "
|
||
f"{sum(len(v) for v in declarations.values())} replication(s) declaree(s) des deux "
|
||
f"cotes ; transfert ouvert a la cle seule ; aucune exposition sans DNSSEC.")
|
||
|
||
|
||
# --- LES ASSISTANTS (P83) : un registre qui ne peut pas prendre de retard ------------
|
||
|
||
|
||
def preuve_runbooks_suivent_le_makefile() -> tuple[bool, str]:
|
||
"""Le registre des runbooks decrit-il encore le moteur reel ?
|
||
|
||
POURQUOI (2026-09-20). Le Makefile porte 132 cibles documentees ; aucune ne dit dans
|
||
quel ORDRE elles se jouent. Cet ordre vivait en prose dans les documents, et la
|
||
console offrait des boutons sans sequence. `docs/runbooks-construction.yml` le declare
|
||
— et devient, par construction, une SECONDE LISTE a cote du Makefile.
|
||
|
||
Une liste qui suit une autre prend du retard sur elle : c'est arrive quatre fois dans
|
||
ce depot en une seule journee. La regle qui en est sortie est d'ecrire la garde EN
|
||
MEME TEMPS que la seconde liste. Cette preuve EST cette garde.
|
||
|
||
CE QU'ELLE REFUSE :
|
||
|
||
1. une etape qui vise une cible que le Makefile ne porte pas (cible renommee) ;
|
||
2. une cible documentee que nul runbook ne porte et que nul motif n'exempte — la
|
||
console cacherait alors un pouvoir que le moteur possede ;
|
||
3. une cible a la fois portee et exemptee, ou une exemption sans motif ;
|
||
4. une portee de runbook que la console ne saurait pas traduire en pouvoir : la
|
||
garde fine de `/api/runbook-etape` laisserait passer un geste qu'elle ne sait
|
||
pas peser.
|
||
|
||
CE QU'ELLE NE PROUVE PAS : qu'une etape REUSSIT. Elle lit le depot, elle ne lance
|
||
rien — comme les 82 autres.
|
||
"""
|
||
sys.path.insert(0, str(RACINE / "scripts"))
|
||
import runbooks as _rb
|
||
import inventory_gui as _gui
|
||
|
||
ecarts = _rb.verifier(RACINE)
|
||
|
||
# La console doit savoir peser CHAQUE portee que le registre emploie.
|
||
portees = {rb["portee"] for rb in _rb.assembler(RACINE)}
|
||
inconnues = sorted(portees - set(_gui.POUVOIR_PAR_PORTEE))
|
||
for p in inconnues:
|
||
ecarts.append(f"portee « {p} » employee par un runbook mais absente de "
|
||
f"POUVOIR_PAR_PORTEE : la garde de /api/runbook-etape ne saurait "
|
||
f"pas quoi exiger.")
|
||
|
||
if ecarts:
|
||
return False, (f"{len(ecarts)} ecart(s) entre le registre des runbooks et le "
|
||
f"moteur : " + " | ".join(ecarts[:4]))
|
||
|
||
rbs = _rb.assembler(RACINE)
|
||
etapes = sum(len(r["etapes"]) for r in rbs)
|
||
cibles = len(_rb.cibles_du_makefile(RACINE))
|
||
return True, (f"{len(rbs)} runbooks, {etapes} etapes, {cibles} cibles documentees : "
|
||
f"chacune portee par un assistant ou exemptee avec son motif.")
|
||
|
||
|
||
def preuve_fiche_du_site() -> tuple[bool, str]:
|
||
"""La fiche que chaque site destine a ses locataires dit-elle ce qu'ils portent deja ?
|
||
|
||
POURQUOI (2026-10-04, `docs/conception-contextes.md` §2.4). Le site informait le locataire
|
||
par trois canaux, sans fiche : des copies ecrites a la main (index, adresse publique,
|
||
intrants, racine), une lecture DIRECTE du site par l'instancier (temps, delegation DNS,
|
||
routage SDN), et le genome. La fiche les reunit. Avant qu'un seul consommateur bascule sur
|
||
elle (etape 3), elle doit dire EXACTEMENT ce que ces canaux ont depose : sinon la bascule
|
||
changerait la flotte au lieu de la decrire.
|
||
|
||
Pour chaque couple (site, locataire) dont le site est l'hebergeur ACTIF : ecarts entre
|
||
`site.fiche_pour(locataire)` et les copies du locataire, sa racine, et son inventaire
|
||
genere. `scripts/tests/test_contexte.py` prouve que chaque sorte d'ecart est vue.
|
||
"""
|
||
sys.path.insert(0, str(RACINE / "scripts"))
|
||
import contexte as _ctx
|
||
couples = _ctx.couples_hebergeur()
|
||
if not couples:
|
||
return True, "Aucun site frere : aucune fiche a confronter."
|
||
ecarts = [f"{s.nom} -> {l.nom} : {e}" for s, l in couples for e in _ctx.verifier_fiche(s, l)]
|
||
if ecarts:
|
||
return False, (f"{len(ecarts)} ecart(s) entre la fiche du site et ce que le locataire "
|
||
f"porte : " + " | ".join(ecarts[:4]))
|
||
return True, (f"{len(couples)} fiche(s) de site : chacune dit exactement ce que son locataire "
|
||
f"porte (copies, racine, inventaire genere).")
|
||
|
||
|
||
def preuve_face_reseau() -> tuple[bool, str]:
|
||
"""La face reseau que chaque locataire publie dit-elle ce que les consommateurs du site en
|
||
tirent aujourd'hui, en lisant ses fichiers internes ?
|
||
|
||
POURQUOI (2026-10-04, `docs/conception-contextes.md` §2.4). Le site lisait six fichiers
|
||
internes de ses locataires — nomenclature, inventaire, `group_vars`, acces, domaines. La
|
||
face reseau les publie, calculee chez le locataire. Avant que le site la lise a leur place
|
||
(etape 3), elle doit dire EXACTEMENT ce que la decouverte, le devis Proxmox, la frontiere
|
||
et l'inventaire du site en tirent. Les flux resolus suivront ; ils ne sont pas encore la.
|
||
"""
|
||
sys.path.insert(0, str(RACINE / "scripts"))
|
||
import contexte as _ctx
|
||
couples = _ctx.couples_hebergeur()
|
||
if not couples:
|
||
return True, "Aucun site frere : aucune face reseau a confronter."
|
||
ecarts = [f"{l.nom} -> {s.nom} : {e}" for s, l in couples for e in _ctx.verifier_face(s, l)]
|
||
if ecarts:
|
||
return False, (f"{len(ecarts)} ecart(s) entre la face reseau et ce que le site en tire : "
|
||
+ " | ".join(ecarts[:4]))
|
||
return True, (f"{len(couples)} face(s) reseau : chacune dit exactement ce que le site en tire "
|
||
f"(decouverte, devis Proxmox, frontiere, DNS public, sauvegarde).")
|
||
|
||
|
||
def preuve_flux_concordent() -> tuple[bool, str]:
|
||
"""Le pare-feu de chaque machine (nftables, resolu par le locataire) et celui de Proxmox
|
||
(devise par le site) admettent-ils les memes entrees ?
|
||
|
||
POURQUOI (2026-10-04, `docs/conception-contextes.md` §2.4, constat 2). Les flux etaient
|
||
calcules DEUX FOIS, par le locataire et par le site, chacun avec sa version du moteur. La
|
||
face reseau publie ceux du locataire (`flux-genere/*.connectivite.json`). Machine par
|
||
machine et port par port, les sources admises doivent etre les memes, une fois neutralisees
|
||
les differences d'ecriture (decrites dans `contexte.py`). Premiere mesure : 3 ecarts par
|
||
locataire, tous sur le courriel — le locataire jetait les clients internes d'un port aussi
|
||
public ; il les publie desormais (`sources_declarees`).
|
||
"""
|
||
sys.path.insert(0, str(RACINE / "scripts"))
|
||
import contexte as _ctx
|
||
couples = _ctx.couples_hebergeur()
|
||
if not couples:
|
||
return True, "Aucun site frere : aucun flux a confronter."
|
||
ecarts = [f"{l.nom} : {e}" for s, l in couples for e in _ctx.verifier_flux(s, l)]
|
||
if ecarts:
|
||
return False, (f"{len(ecarts)} ecart(s) entre le pare-feu des machines et celui de Proxmox : "
|
||
+ " | ".join(ecarts[:4]))
|
||
return True, (f"{len(couples)} locataire(s) : machine par machine et port par port, nftables et "
|
||
f"Proxmox admettent les memes sources.")
|
||
|
||
|
||
def preuve_frontiere_identites() -> tuple[bool, str]:
|
||
"""Les alias, les routes et la traduction sortante par lesquels la frontiere designe chaque
|
||
locataire disent-ils ce que sa face reseau et la fiche du site portent ?
|
||
|
||
POURQUOI (2026-10-04, `docs/conception-contextes.md` §2.4). La frontiere est le dernier
|
||
consommateur du site qui lit l'interieur des locataires. Premier temps de sa bascule : les
|
||
identites — supernet (ses zones), administration par interface, tunnel, un alias par groupe
|
||
qui a des flux de bordure, une route par zone, la sortie par l'adresse publique attribuee.
|
||
Les entrees publiques et les sorties suivront.
|
||
"""
|
||
sys.path.insert(0, str(RACINE / "scripts"))
|
||
import contexte as _ctx
|
||
couples = _ctx.couples_hebergeur()
|
||
if not couples:
|
||
return True, "Aucun site frere : aucune frontiere a confronter."
|
||
ecarts = [f"{l.nom} : {e}" for s, l in couples for e in _ctx.verifier_frontiere(s, l)]
|
||
if ecarts:
|
||
return False, (f"{len(ecarts)} ecart(s) entre la frontiere et ce que le locataire publie : "
|
||
+ " | ".join(ecarts[:4]))
|
||
return True, (f"{len(couples)} locataire(s) : la frontiere les designe par les identites qu'ils "
|
||
f"publient (supernet, administration, tunnel, groupes, routes, sortie).")
|
||
|
||
|
||
def preuve_frontiere_entrees_publiques() -> tuple[bool, str]:
|
||
"""Les regles du WAN, les redirections et le tunnel d'administration d'un locataire disent-ils
|
||
ce que sa face reseau ouvre a tous ?
|
||
|
||
POURQUOI (2026-10-04, `docs/conception-contextes.md` §2.4). Second temps de la frontiere.
|
||
Machine par machine, chaque entree ouverte a tous (`0.0.0.0/0`) dans la face doit avoir sa
|
||
regle WAN et, si elle a un port, sa redirection depuis l'adresse que la fiche du site
|
||
attribue — et rien de plus. Le port public d'un locataire est son port local ; s'il en
|
||
differait, la face ne le porterait pas, et la preuve le dirait.
|
||
"""
|
||
sys.path.insert(0, str(RACINE / "scripts"))
|
||
import contexte as _ctx
|
||
couples = _ctx.couples_hebergeur()
|
||
if not couples:
|
||
return True, "Aucun site frere : aucune entree publique a confronter."
|
||
ecarts = [f"{l.nom} : {e}" for s, l in couples for e in _ctx.verifier_entrees_publiques(s, l)]
|
||
if ecarts:
|
||
return False, (f"{len(ecarts)} ecart(s) entre la frontiere et ce que le locataire ouvre a tous : "
|
||
+ " | ".join(ecarts[:4]))
|
||
return True, (f"{len(couples)} locataire(s) : regles WAN, redirections et tunnel disent exactement "
|
||
f"ce que leur face reseau ouvre a tous.")
|
||
|
||
|
||
def preuve_frontiere_administration() -> tuple[bool, str]:
|
||
"""Par la gestion, le VPN et le tunnel, l'administration atteint-elle chez chaque locataire
|
||
exactement ce que sa face reseau lui ouvre ?
|
||
|
||
POURQUOI (2026-10-05, `docs/conception-contextes.md` §2.4). Troisieme temps de la frontiere,
|
||
premiere moitie. Le SSH, les entrees qui nomment l'administration parmi leurs sources, et
|
||
les entrees publiques que leur flux ouvre au poste (`poste`, faux pour le 25 de Postfix).
|
||
Le tunnel atteint toute la flotte, sur les ports que la politique du site lui accorde.
|
||
"""
|
||
sys.path.insert(0, str(RACINE / "scripts"))
|
||
import contexte as _ctx
|
||
couples = _ctx.couples_hebergeur()
|
||
if not couples:
|
||
return True, "Aucun site frere : aucune administration a confronter."
|
||
ecarts = [f"{l.nom} : {e}" for s, l in couples for e in _ctx.verifier_administration(s, l)]
|
||
if ecarts:
|
||
return False, (f"{len(ecarts)} ecart(s) entre la frontiere et ce que la face ouvre a "
|
||
f"l'administration : " + " | ".join(ecarts[:4]))
|
||
return True, (f"{len(couples)} locataire(s) : par la gestion, le VPN et le tunnel, "
|
||
f"l'administration atteint exactement ce que leur face reseau lui ouvre.")
|
||
|
||
|
||
def preuve_frontiere_sorties() -> tuple[bool, str]:
|
||
"""Les regles de sortie d'un locataire vers l'Internet disent-elles ce que sa face reseau
|
||
publie (`sorties_externes`) ?
|
||
|
||
POURQUOI (2026-10-05, `docs/conception-contextes.md` §2.4). Dernier temps de la frontiere.
|
||
Le locataire ne relevait pas ses sorties (ses machines les acceptent toutes) ; il les publie
|
||
desormais, et la frontiere doit les laisser passer, elles et rien d'autre. En chemin, deux
|
||
sorties de `serveur_ops` se sont revelees fausses et ont ete retirees (85, 87).
|
||
"""
|
||
sys.path.insert(0, str(RACINE / "scripts"))
|
||
import contexte as _ctx
|
||
couples = _ctx.couples_hebergeur()
|
||
if not couples:
|
||
return True, "Aucun site frere : aucune sortie a confronter."
|
||
ecarts = [f"{l.nom} : {e}" for s, l in couples for e in _ctx.verifier_sorties(s, l)]
|
||
if ecarts:
|
||
return False, (f"{len(ecarts)} ecart(s) entre la frontiere et ce que le locataire publie comme "
|
||
f"sorties : " + " | ".join(ecarts[:4]))
|
||
return True, (f"{len(couples)} locataire(s) : la frontiere laisse sortir exactement ce que leur "
|
||
f"face reseau publie.")
|
||
|
||
|
||
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},
|
||
{"id": "P76", "titre": "Tout gabarit de role se rend vraiment",
|
||
"refs": [], "func": preuve_gabarits_shell_rendent},
|
||
{"id": "P77", "titre": "Panneaux declares : assemblables, et gradues",
|
||
"refs": [], "func": preuve_panneaux_assembles},
|
||
{"id": "P78", "titre": "Un consommateur de base suit le verrou TLS de son serveur",
|
||
"refs": [], "func": preuve_consommateurs_suivent_le_verrou_tls},
|
||
{"id": "P79", "titre": "Replis silencieux : une derivation vide ne passe pas pour un succes",
|
||
"refs": [], "func": preuve_une_derivation_vide_se_dit},
|
||
{"id": "P80", "titre": "Remise au client : inscrite, nommee, et son second temps a l'heure",
|
||
"refs": [], "func": preuve_remise_tenue},
|
||
{"id": "P81", "titre": "La console dit sa portee, et ne sert pas un inventaire vide en silence",
|
||
"refs": [], "func": preuve_console_dit_sa_portee},
|
||
{"id": "P82", "titre": "DNS public : les zones publiees sont servies, signees avant d'etre exposees",
|
||
"refs": [], "func": preuve_dns_public_tient},
|
||
{"id": "P83", "titre": "Assistants : le registre des runbooks ne prend pas de retard sur le Makefile",
|
||
"refs": [], "func": preuve_runbooks_suivent_le_makefile},
|
||
{"id": "P84", "titre": "La fiche du site dit exactement ce que chaque locataire porte",
|
||
"refs": [], "func": preuve_fiche_du_site},
|
||
{"id": "P85", "titre": "La face reseau du locataire dit exactement ce que le site en tire",
|
||
"refs": [], "func": preuve_face_reseau},
|
||
{"id": "P86", "titre": "Machine par machine, le locataire et Proxmox admettent les memes entrees",
|
||
"refs": [], "func": preuve_flux_concordent},
|
||
{"id": "P87", "titre": "La frontiere designe chaque locataire par les identites qu'il publie",
|
||
"refs": [], "func": preuve_frontiere_identites},
|
||
{"id": "P88", "titre": "Ce qui entre depuis l'Internet chez un locataire, la frontiere le tient de lui",
|
||
"refs": [], "func": preuve_frontiere_entrees_publiques},
|
||
{"id": "P89", "titre": "Ce que l'administration atteint chez un locataire, la frontiere le tient de lui",
|
||
"refs": [], "func": preuve_frontiere_administration},
|
||
{"id": "P90", "titre": "Ce qui sort d'un locataire vers l'Internet, la frontiere le tient de lui",
|
||
"refs": [], "func": preuve_frontiere_sorties},
|
||
]
|
||
|
||
|
||
# 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:
|
||
# NOMMER CE QUI ECHOUE (2026-10-04) : le verdict seul (« 6 echec ») obligeait a
|
||
# reconstituer le contexte et relire le code pour savoir lesquelles.
|
||
for p, s, d in resultats:
|
||
if s == "ECHEC":
|
||
print(f" ECHEC {p['id']} — {p.get('titre') or p.get('nom') or ''} : "
|
||
f"{' '.join(str(d).split())[:300]}")
|
||
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:]))
|