Set-OPS-Public/scripts/prouver.py
Daniel Allaire 777bea8408 portabilite : Technolibre debout, six devis, et P35
L'epreuve de portabilite est passee. Un second ecosysteme souverain complet,
monte depuis zero par le meme moteur : 14 hotes, 2583 taches ok, 331 changed,
0 failed. Plan distinct, voute separee, realm technolibre, sa propre AC — et
une topologie differente : LDAP et SSO sur des machines separees la ou
Chezlepro les co-localise.

Cinq devis CONFORME (identite, certificats, PostgreSQL, courriel, frontiere —
55 lignes 0 ecart). Le sixieme dit exactement la bonne chose : les 6 services
repondent depuis l'edge, aucun depuis le poste, qui ne resout pas encore
technolibre.internal (6 entrees /etc/hosts absentes — le plancher).

SIXIEME DEFAUT MOTEUR. Le devis d'identite interrogeait LDAP en `ldapi:///`
— un socket UNIX LOCAL — depuis l'hote serveur_keycloak. Cela ne marchait que
par CO-LOCATION ACCIDENTELLE. Un tenant qui separe l'annuaire du SSO echouait
sur « Failed to import python-ldap » : l'hote SSO n'a pas de client LDAP. Les
deux lectures sont deleguees a l'hote DERIVE par resoudre_annuaire.

P35 (D-75) : toute application dont le role exige une base en a une au plan.
La garde de resoudre_base existait deja, mais s'est declenchee a la 92e tache
de collab-01, apres quarante minutes, pour un ecart entierement lisible dans
le plan.

Rien n'y est code en dur : les roles concernes sont ceux qui INCLUENT
resoudre_base, et le groupe reclame est lu dans le DEFAUT de la variable
passee — jamais deduit du nom. serveur_icingaweb2 reclame la base de
serveur_icinga ; une preuve supposant « role = groupe » aurait crie sur un cas
sain.

Eprouvee dans les deux sens ET sur les deux tenants, dont les registres n'ont
pas la meme portee : base retiree -> ECHEC la nommant ; restauree -> OK.

Verifie : prouver.py 35 OK sur les deux instances, ansible-lint production.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:36:44 -04:00

866 lines
43 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 os
import subprocess
import sys
from pathlib import Path
import yaml
RACINE = Path(__file__).resolve().parents[1]
INSTANCE = Path(os.environ.get("SETOPS_INSTANCE", "instance"))
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"
env = {**os.environ, "SETOPS_INSTANCE": socle}
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"}
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)
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}."
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 = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance"))
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 = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance"))
serveurs = (charger_serveurs(instance / "plan/serveurs.yml").get("serveurs") or {})
apps = (charger_applications(instance / "plan/applications.yml").get("applications") or {})
services: dict[str, set[str]] = {}
for app in apps.values():
services.setdefault(app.get("hote"), set()).add(app.get("groupe"))
fautes, exemptes = [], 0
for nom, srv in serveurs.items():
rendus = services.get(nom, set())
obtenues = integrations_de(srv, rendus, universelles)
for role, politique in universelles.items():
exempt = politique["sauf_role"] and politique["sauf_role"] in rendus
if exempt:
exemptes += 1
elif role not in obtenues:
fautes.append(f"{nom} sans {role}")
redites = sorted(set(srv.get("integrations") or []) & set(universelles))
if redites:
fautes.append(f"{nom} redit {', '.join(redites)} (politique du role)")
if fautes:
return False, "; ".join(fautes[:6])
return True, (f"{len(serveurs)} hote(s) x {len(universelles)} integration(s) universelle(s) : "
f"aucune lacune, aucune recopie ({exemptes} exemption(s) derivee(s) du service rendu).")
def preuve_nomenclature_derivee() -> tuple[bool, str]:
"""Aucune nomenclature ne STOCKE d'adressage : tout se derive du seed `index`.
Interdit `supernet` au niveau racine et `sous_reseau`/`passerelle`/`vlan` par zone.
Attrape toute rechute vers l'ecriture manuelle de l'adressage (rupture du 2026-07-23).
Portee : l'instance liee + tous les modeles decouverts (SETOPS_MODELES inclus).
"""
import yaml
sys.path.insert(0, str(RACINE / "scripts"))
import modeles as _mod
cibles = []
inst_nom = RACINE / INSTANCE / "plan" / "nomenclature.yml"
if inst_nom.exists():
cibles.append(inst_nom)
cibles += [m / "plan" / "nomenclature.yml" for m in _mod.decouvrir()
if (m / "plan" / "nomenclature.yml").exists()]
interdits_zone = {"sous_reseau", "passerelle", "vlan"}
fautes = []
for p in cibles:
n = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
rel = p.relative_to(RACINE) if p.is_relative_to(RACINE) else p
if "supernet" in n:
fautes.append(f"{rel}: supernet stocke")
if "vmid_schema" in n:
fautes.append(f"{rel}: vmid_schema obsolete (mode compact retire)")
for z, c in (n.get("categories") or {}).items():
for k in interdits_zone & set(c or {}):
fautes.append(f"{rel}: categorie {z}.{k} stocke")
if fautes:
return False, f"{len(fautes)} champ(s) d'adressage stockes : " + " | ".join(fautes[:4])
return True, f"{len(cibles)} nomenclature(s) : adressage 100% derive du seed index."
def preuve_pas_lab_code_en_dur() -> tuple[bool, str]:
"""Aucun chemin `instance/inventories/lab/group_vars` code en dur (AFF-097).
On vise le vrai chemin (prefixe `instance/`) et on ignore les fichiers meta qui
*parlent* de l'audit (CHANGELOG, docs/audit) sans etre des chemins d'exploitation.
"""
r = subprocess.run(
["git", "grep", "-n", "instance/inventories/lab/group_vars", "--", "*.md", "*.yml"],
cwd=RACINE, capture_output=True, text=True,
)
exclus = ("docs/audit/", "CHANGELOG.md:")
lignes = [l for l in r.stdout.splitlines() if not l.startswith(exclus)]
if lignes:
return False, f"{len(lignes)} occurrence(s) : " + " | ".join(lignes[:3])
return True, "Aucun chemin instance/inventories/lab/group_vars code en dur."
def preuve_inventaire_ansible() -> tuple[bool, str]:
"""`ansible-inventory --list` : l'inventaire (voute dechiffree) se parse (AFF-030)."""
r = subprocess.run(["ansible-inventory", "-i", INV, "--list"],
cwd=RACINE, capture_output=True, text=True, timeout=600)
if r.returncode != 0:
sortie = (r.stdout + r.stderr).strip().splitlines()
return False, f"rc={r.returncode} : {sortie[-1] if sortie else ''}"
try:
data = json.loads(r.stdout)
except ValueError:
return True, "inventaire liste (JSON non parse)."
n_hotes = len((data.get("_meta") or {}).get("hostvars") or {})
n_groupes = len([k for k in data if k not in ("_meta", "all")])
return True, f"{n_hotes} hotes, {n_groupes} groupes (inventaire dechiffre et parse)."
def 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.
"""
plan = RACINE / "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()}
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_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)).")
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 ""))
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
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).
PREUVES: list[dict] = [
{"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"],
"cmds": [["ansible-lint", "-q"]]},
{"id": "P02", "titre": "Tests unitaires (inventory_host)", "refs": [],
"cmds": [[sys.executable, "scripts/tests/test_inventory_host.py"],
[sys.executable, "scripts/tests/test_raser.py"]]},
{"id": "P03", "titre": "Diff-vide du plan (inventaire genere)", "refs": ["AFF-001", "AFF-004", "AFF-030", "AFF-031", "AFF-032"],
"refs": ["AFF-001", "AFF-004", "AFF-030", "AFF-031", "AFF-032"],
"cmds": [[sys.executable, "scripts/instancier.py", "generer"],
[sys.executable, "scripts/instancier.py", "comparer"]]},
{"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": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],
"cmds": [[sys.executable, "scripts/verifier_ports.py"]]},
]
def _vault_requis_absent() -> bool:
"""Vrai si l'inventaire chiffre un group_vars et qu'aucun mot de passe n'est fourni."""
if os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE"):
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)."""
return not (RACINE / "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 = {"id": "P16", "titre": "Inventaire Ansible complet (--list)",
"refs": ["AFF-030"], "func": preuve_inventaire_ansible}
liste = list(PREUVES)
if _vault_requis_absent():
resultats.append((preuve_inv, "SAUTE",
"Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026)."))
else:
liste.append(preuve_inv)
# Preuve conditionnelle : underlay (fabric physique). Sautee si non definie.
if _underlay_absent():
p23 = next((p for p in liste if p["id"] == "P23"), None)
if p23:
liste.remove(p23)
resultats.append((p23, "SAUTE", "Aucun underlay.yml (fabric non definie) — rien a prouver."))
# Preuve conditionnelle : frontiere nord/sud. Sautee sans instance active.
if _frontiere_absente():
p24 = next((p for p in liste if p["id"] == "P24"), None)
if p24:
liste.remove(p24)
resultats.append((p24, "SAUTE", "Aucune instance active — aucune frontiere a prouver."))
for preuve in liste:
statut, detail = _executer(preuve)
resultats.append((preuve, statut, detail))
# Tri : garder l'ordre d'execution (P01..P19) ; la preuve sautee a ete inseree tot.
resultats.sort(key=lambda r: r[0]["id"])
n_ok = sum(1 for _, s, _ in resultats if s == "OK")
n_echec = sum(1 for _, s, _ in resultats if s == "ECHEC")
n_saute = sum(1 for _, s, _ in resultats if s == "SAUTE")
conforme = n_echec == 0
print(f"{'CONFORME' if conforme else 'NON CONFORME'} : "
f"{n_ok} OK, {n_echec} echec, {n_saute} saute.")
if not ecrire_rapport:
return 0 if conforme else 1
icone = {"OK": "", "ECHEC": "", "SAUTE": ""}
lignes = [
f"# Preuve de conformite — Set-OPS — {date}",
"",
"> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer",
"> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;",
"> aucune validation n'est reimplementee ici. Voir le mode d'emploi :",
"> [`docs/audit/README.md`](README.md), et le registre trace :",
"> [`docs/audit/affirmations.md`](affirmations.md).",
"",
f"- **Instance** : `{INSTANCE}` — inventaire `{INV}`",
f"- **Verdict** : {'✅ CONFORME' if conforme else '❌ NON CONFORME'} "
f"({n_ok} OK · {n_echec} echec · {n_saute} saute)",
"",
"## Preuves",
"",
"| # | Preuve | Affirmations | Statut | Detail |",
"|---|---|---|---|---|",
]
for preuve, statut, detail in resultats:
refs = ", ".join(preuve["refs"]) or ""
det = detail.replace("|", "\\|")[:160]
lignes.append(f"| {preuve['id']} | {preuve['titre']} | {refs} | {icone[statut]} {statut} | {det} |")
lignes += [
"",
"## Couverture des affirmations ✅ du registre",
"",
"Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.",
"Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005",
"`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite",
"d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;",
"elles restent hors du harnais recurrent (rien d'executable a rejouer).",
"",
"## Declarations d'intention (⚪ invérifiables localement — assumees)",
"",
"Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**",
"comme declarations d'intention, non comme preuves :",
"",
"- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement",
" contre une flotte vivante.",
"- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.",
"- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.",
"- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.",
"",
f"_Rapport genere le {date}._",
"",
]
DOSSIER_AUDIT.mkdir(parents=True, exist_ok=True)
rapport = DOSSIER_AUDIT / f"preuve-{date}.md"
rapport.write_text("\n".join(lignes), encoding="utf-8")
print(f"Rapport : {rapport.relative_to(RACINE)}")
return 0 if conforme else 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))