Some checks are pending
verifier / verifier (push) Waiting to run
L'exploitant, apres le refactor : « je n'y comprends rien ». C'est la mesure qui compte — la regle fondatrice du depot est qu'un humain pilote sans IA, et une correction qu'il ne peut pas expliquer ne lui appartient pas. - L'unite « La preuve » gagne une section : le defaut le plus dangereux n'est pas l'erreur, c'est la COPIE. Neuf copies ne vieillissent pas ensemble, et la divergence ne se voit jamais de l'interieur d'une copie. Avec le cas vecu — un devis qui repondait CONFORME sur le mauvais ecosysteme parce que les deux avaient les memes valeurs. - Le glossaire gagne « source unique » et « resolution d'instance » ; P39 les exige. - Le rapprochement qui rend la chose evidente : c'est la meme lecon que proxmox-hebergeur.yml, ou les listes du cluster recopiees chez chaque tenant avaient deja diverge. Une source, pas N copies — pour les donnees comme pour le code. Plan de recette regenere. make verifier 41/41. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1359 lines
70 KiB
Python
1359 lines
70 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"}
|
||
|
||
|
||
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 = instance_courante()
|
||
if instance.resolve() == hebergeur.resolve():
|
||
cles = {} # l'hebergeur est aussi tenant : son propre depot porte les deux
|
||
else:
|
||
fichier_heb = hebergeur / "proxmox-hebergeur.yml"
|
||
if not fichier_heb.is_file():
|
||
return False, f"Fichier du cluster absent chez l'hebergeur : {fichier_heb}"
|
||
cles = _yaml.safe_load(fichier_heb.read_text(encoding="utf-8")) or {}
|
||
fautes = []
|
||
for gv in sorted((instance / "inventories").glob("*/group_vars/*.yml")):
|
||
data = _yaml.safe_load(gv.read_text(encoding="utf-8")) or {}
|
||
if not isinstance(data, dict):
|
||
continue
|
||
redites = sorted(set(data) & set(cles))
|
||
if redites:
|
||
fautes.append(f"{gv.parent.name}/{gv.name}: {', '.join(redites)}")
|
||
if fautes:
|
||
return False, "cle(s) de l'hebergeur recopiee(s) chez le tenant — " + " ; ".join(fautes[:4])
|
||
return True, (f"{len(cles)} cle(s) de cluster chez l'hebergeur, aucune recopiee dans les "
|
||
f"group_vars du tenant.")
|
||
|
||
|
||
def preuve_integrations_universelles() -> tuple[bool, str]:
|
||
"""Aucun hote n'echappe a une integration universelle, et le plan ne la redit pas.
|
||
|
||
C'est la garde de l'inversion du defaut (D-33). Elle attrape les deux rechutes
|
||
possibles : un hote sans supervision/journal/certificat, et une recopie dans le
|
||
plan — qui reintroduirait deux sources et donc, tot ou tard, un ecart.
|
||
"""
|
||
sys.path.insert(0, str(RACINE / "scripts"))
|
||
from inventory_rules import (charger_applications, charger_serveurs,
|
||
integrations_de, integrations_universelles)
|
||
universelles = integrations_universelles(RACINE)
|
||
if not universelles:
|
||
return False, "Aucune integration universelle declaree : la politique a disparu."
|
||
instance = instance_courante()
|
||
serveurs = (charger_serveurs(instance / "plan/serveurs.yml").get("serveurs") or {})
|
||
apps = (charger_applications(instance / "plan/applications.yml").get("applications") or {})
|
||
services: dict[str, set[str]] = {}
|
||
for app in apps.values():
|
||
services.setdefault(app.get("hote"), set()).add(app.get("groupe"))
|
||
fautes, exemptes = [], 0
|
||
for nom, srv in serveurs.items():
|
||
rendus = services.get(nom, set())
|
||
obtenues = integrations_de(srv, rendus, universelles)
|
||
for role, politique in universelles.items():
|
||
exempt = politique["sauf_role"] and politique["sauf_role"] in rendus
|
||
if exempt:
|
||
exemptes += 1
|
||
elif role not in obtenues:
|
||
fautes.append(f"{nom} sans {role}")
|
||
redites = sorted(set(srv.get("integrations") or []) & set(universelles))
|
||
if redites:
|
||
fautes.append(f"{nom} redit {', '.join(redites)} (politique du role)")
|
||
if fautes:
|
||
return False, "; ".join(fautes[:6])
|
||
return True, (f"{len(serveurs)} hote(s) x {len(universelles)} integration(s) universelle(s) : "
|
||
f"aucune lacune, aucune recopie ({exemptes} exemption(s) derivee(s) du service rendu).")
|
||
|
||
|
||
def preuve_nomenclature_derivee() -> tuple[bool, str]:
|
||
"""Aucune nomenclature ne STOCKE d'adressage : tout se derive du seed `index`.
|
||
|
||
Interdit `supernet` au niveau racine et `sous_reseau`/`passerelle`/`vlan` par zone.
|
||
Attrape toute rechute vers l'ecriture manuelle de l'adressage (rupture du 2026-07-23).
|
||
Portee : l'instance liee + tous les modeles decouverts (SETOPS_MODELES inclus).
|
||
"""
|
||
import yaml
|
||
sys.path.insert(0, str(RACINE / "scripts"))
|
||
import modeles as _mod
|
||
|
||
cibles = []
|
||
inst_nom = RACINE / INSTANCE / "plan" / "nomenclature.yml"
|
||
if inst_nom.exists():
|
||
cibles.append(inst_nom)
|
||
cibles += [m / "plan" / "nomenclature.yml" for m in _mod.decouvrir()
|
||
if (m / "plan" / "nomenclature.yml").exists()]
|
||
|
||
interdits_zone = {"sous_reseau", "passerelle", "vlan"}
|
||
fautes = []
|
||
for p in cibles:
|
||
n = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
||
rel = p.relative_to(RACINE) if p.is_relative_to(RACINE) else p
|
||
if "supernet" in n:
|
||
fautes.append(f"{rel}: supernet stocke")
|
||
if "vmid_schema" in n:
|
||
fautes.append(f"{rel}: vmid_schema obsolete (mode compact retire)")
|
||
for z, c in (n.get("categories") or {}).items():
|
||
for k in interdits_zone & set(c or {}):
|
||
fautes.append(f"{rel}: categorie {z}.{k} stocke")
|
||
if fautes:
|
||
return False, f"{len(fautes)} champ(s) d'adressage stockes : " + " | ".join(fautes[:4])
|
||
return True, f"{len(cibles)} nomenclature(s) : adressage 100% derive du seed index."
|
||
|
||
|
||
def preuve_pas_lab_code_en_dur() -> tuple[bool, str]:
|
||
"""Aucun chemin `instance/inventories/lab/group_vars` code en dur (AFF-097).
|
||
|
||
On vise le vrai chemin (prefixe `instance/`) et on ignore les fichiers meta qui
|
||
*parlent* de l'audit (CHANGELOG, docs/audit) sans etre des chemins d'exploitation.
|
||
"""
|
||
r = subprocess.run(
|
||
["git", "grep", "-n", "instance/inventories/lab/group_vars", "--", "*.md", "*.yml"],
|
||
cwd=RACINE, capture_output=True, text=True,
|
||
)
|
||
exclus = ("docs/audit/", "CHANGELOG.md:")
|
||
lignes = [l for l in r.stdout.splitlines() if not l.startswith(exclus)]
|
||
if lignes:
|
||
return False, f"{len(lignes)} occurrence(s) : " + " | ".join(lignes[:3])
|
||
return True, "Aucun chemin instance/inventories/lab/group_vars code en dur."
|
||
|
||
|
||
def preuve_inventaire_ansible() -> tuple[bool, str]:
|
||
"""`ansible-inventory --list` : l'inventaire (voute dechiffree) se parse (AFF-030)."""
|
||
r = subprocess.run(["ansible-inventory", "-i", INV, "--list"],
|
||
cwd=RACINE, capture_output=True, text=True, timeout=600)
|
||
if r.returncode != 0:
|
||
sortie = (r.stdout + r.stderr).strip().splitlines()
|
||
return False, f"rc={r.returncode} : {sortie[-1] if sortie else ''}"
|
||
try:
|
||
data = json.loads(r.stdout)
|
||
except ValueError:
|
||
return True, "inventaire liste (JSON non parse)."
|
||
n_hotes = len((data.get("_meta") or {}).get("hostvars") or {})
|
||
n_groupes = len([k for k in data if k not in ("_meta", "all")])
|
||
return True, f"{n_hotes} hotes, {n_groupes} groupes (inventaire dechiffre et parse)."
|
||
|
||
|
||
def _valeur_effective(cle: str, role: str) -> str | None:
|
||
"""La valeur d'une variable pour l'instance montee : group_vars d'abord, defaut du role.
|
||
|
||
Sert aux preuves qui doivent tenir compte d'un CHOIX de l'instance et non seulement de
|
||
ce que le role sait faire. Lecture textuelle des group_vars — aucune voute n'est
|
||
dechiffree, aucun Ansible n'est lance : ces preuves doivent rester instantanees.
|
||
"""
|
||
inv = INSTANCE / "inventories"
|
||
if inv.is_dir():
|
||
for f in sorted(inv.glob("*/group_vars/**/*.yml")):
|
||
try:
|
||
d = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
|
||
except (OSError, yaml.YAMLError):
|
||
continue
|
||
if isinstance(d, dict) and cle in d:
|
||
return str(d[cle])
|
||
defauts = RACINE / "roles" / role / "defaults" / "main.yml"
|
||
if defauts.is_file():
|
||
d = yaml.safe_load(defauts.read_text(encoding="utf-8")) or {}
|
||
if isinstance(d, dict) and cle in d:
|
||
return str(d[cle])
|
||
return None
|
||
|
||
|
||
def preuve_base_par_consommateur() -> tuple[bool, str]:
|
||
"""Toute application deployee dont le role exige une base en a une au plan.
|
||
|
||
`resoudre_base` porte deja la garde (D-72) : sans entree, il refuse. Mais elle se
|
||
declenche EN COURS DE DEPLOIEMENT — le 2026-08-10, a la 92e tache de `collab-01`,
|
||
apres quarante minutes. L'ecart est pourtant entierement lisible dans le plan : une
|
||
application declaree, un role qui reclame une base, aucune entree correspondante.
|
||
Deux secondes ici valent quarante minutes la-bas. Meme raisonnement que P32 pour les
|
||
intrants.
|
||
|
||
RIEN N'EST CODE EN DUR, et c'est ce qui la rend juste :
|
||
|
||
- les roles qui exigent une base sont ceux qui incluent `resoudre_base` ;
|
||
- le GROUPE consommateur qu'ils reclament est lu dans le DEFAUT de la variable
|
||
qu'ils passent — pas deduit de leur nom. `serveur_icingaweb2` reclame la base de
|
||
`serveur_icinga`, pas la sienne : une preuve qui aurait suppose « role = groupe »
|
||
aurait crie sur un cas parfaitement sain ;
|
||
- les noms acceptables suivent la meme regle que le resolveur : le groupe, ou toute
|
||
application qui declare ce groupe (les deux portees du registre).
|
||
|
||
CE QU'ELLE NE TESTE PAS : que le secret nomme existe — c'est P18, sur le gabarit de
|
||
voute. Ni que la base reponde : c'est `make postgresql-plan`, qui interroge le reel.
|
||
"""
|
||
# INSTANCE, pas le symlink en dur : sans cela cette preuve mesurait toujours
|
||
# l'instance montee, quelle que soit celle qu'on visait (2026-08-22, en verifiant
|
||
# patient 0 — elle a rendu un verdict juste sur le mauvais ecosysteme).
|
||
plan = INSTANCE / "plan"
|
||
if not (plan / "bases-donnees.yml").is_file():
|
||
return True, "Aucun registre de bases : rien a verifier."
|
||
bases = (yaml.safe_load((plan / "bases-donnees.yml").read_text(encoding="utf-8"))
|
||
or {}).get("bases_donnees") or {}
|
||
apps = (yaml.safe_load((plan / "applications.yml").read_text(encoding="utf-8"))
|
||
or {}).get("applications") or {}
|
||
|
||
# Quels roles exigent une base, et QUEL groupe ils reclament.
|
||
exigences: dict[str, str] = {}
|
||
for tache in sorted((RACINE / "roles").glob("*/tasks/main.yml")):
|
||
texte = tache.read_text(encoding="utf-8", errors="ignore")
|
||
if "name: resoudre_base" not in texte:
|
||
continue
|
||
role = tache.parent.parent.name
|
||
m = re.search(r"resoudre_base_groupe:\s*[\"']\{\{\s*([a-z0-9_]+)", texte)
|
||
if not m:
|
||
continue
|
||
defauts = tache.parent.parent / "defaults" / "main.yml"
|
||
val = (yaml.safe_load(defauts.read_text(encoding="utf-8")) or {}).get(m.group(1)) \
|
||
if defauts.is_file() else None
|
||
exigences[role] = str(val or role)
|
||
|
||
consommateurs = {str((b or {}).get("consommateur")) for b in bases.values()}
|
||
# UN ROLE PEUT SAVOIR SE PASSER DE SERVEUR (2026-08-22). `serveur_forgejo` accepte
|
||
# `serveur_forgejo_bd: sqlite` : la base devient un fichier, aucune entree de registre
|
||
# n'est attendue. La convention est `<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 {}
|
||
|
||
detenu: dict[str, set[str]] = {}
|
||
for app in apps.values():
|
||
groupe = str((app or {}).get("groupe") or "")
|
||
if groupe in catalogue:
|
||
detenu.setdefault(str((app or {}).get("hote") or ""), set()).add(groupe)
|
||
|
||
manques = [f"{hote} detient {sorted(groupes)}"
|
||
for hote, groupes in sorted(detenu.items())
|
||
if "client_backup" not in ((serveurs.get(hote) or {}).get("integrations") or [])]
|
||
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"{len(detenu)} hote(s) detiennent de l'etat, tous porteurs de "
|
||
f"`client_backup` ({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_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_glossaire_enseigne() -> tuple[bool, str]:
|
||
"""Tout mot que le depot emploie devant l'exploitant est explique au glossaire.
|
||
|
||
POURQUOI (exigence de l'exploitant, 2026-08-21). La regle fondatrice du depot est
|
||
qu'un humain doit pouvoir piloter cet ecosysteme SANS IA — idealement un seul. Un
|
||
vocabulaire qu'on n'explique pas est donc un defaut de conception, pas un detail de
|
||
redaction : chaque mot obscur retire une personne a la liste de celles qui peuvent
|
||
reprendre le systeme.
|
||
|
||
Mesure du jour, qui a motive la preuve : 40 termes employes par le depot et absents
|
||
du glossaire — `underlay` 106 fois, `EVPN` 66, `VRF` 33, `LDAP` 184. Le glossaire
|
||
definissait le vocabulaire propre a Set-OPS (plan, index, voute) et laissait dehors
|
||
tout ce qui vient du metier.
|
||
|
||
CE QU'ELLE TESTE :
|
||
- chaque terme de `JARGON_A_ENSEIGNER` a une entree au glossaire ;
|
||
- chaque lien du glossaire vers une page du wiki mene a une page qui EXISTE ;
|
||
- chaque page du wiki est atteignable depuis `_Sidebar.md` — une unite que la
|
||
navigation ne cite pas n'est lue par personne.
|
||
|
||
CE QU'ELLE NE PEUT PAS TESTER, et il faut le dire : qu'un mot que PERSONNE n'a
|
||
declare manque a la liste. Elle garde ce qu'on lui confie ; elle ne devine pas. Et
|
||
elle ne juge pas la QUALITE d'une explication — cela se lit, cela ne se mesure pas.
|
||
"""
|
||
wiki = RACINE / "wiki"
|
||
glossaire = wiki / "Glossaire.md"
|
||
if not glossaire.is_file():
|
||
return False, "wiki/Glossaire.md absent"
|
||
texte = glossaire.read_text(encoding="utf-8", errors="ignore")
|
||
|
||
# Comparaison SANS ACCENTS et sur des mots entiers. Le glossaire s'ecrit en francais
|
||
# correct (« Frontière », « Voûte », « Rôle ») ; c'est a la preuve de s'y plier, pas
|
||
# au texte de s'appauvrir. Les bornes de mot evitent le faux positif classique :
|
||
# « controle » contient « role ».
|
||
def _sans_accent(s: str) -> str:
|
||
return "".join(c for c in unicodedata.normalize("NFD", s)
|
||
if unicodedata.category(c) != "Mn")
|
||
|
||
nu = _sans_accent(texte)
|
||
manquants = [t for t in JARGON_A_ENSEIGNER
|
||
if not re.search(rf"\b{re.escape(_sans_accent(t))}\b", nu, re.IGNORECASE)]
|
||
|
||
pages = {p.stem for p in wiki.glob("*.md")}
|
||
liens = set(re.findall(r"\]\(([A-Za-zÀ-ÿ0-9'’\-]+)\)", texte))
|
||
casses = sorted(l for l in liens if l not in pages)
|
||
|
||
sidebar = (wiki / "_Sidebar.md")
|
||
orphelines: list[str] = []
|
||
if sidebar.is_file():
|
||
nav = sidebar.read_text(encoding="utf-8", errors="ignore")
|
||
orphelines = sorted(p for p in pages
|
||
if p not in ("_Sidebar", "README", "Home")
|
||
and f"({p})" not in nav)
|
||
|
||
echecs = []
|
||
if manquants:
|
||
echecs.append(f"{len(manquants)} terme(s) employe(s) sans entree au glossaire : "
|
||
+ ", ".join(manquants))
|
||
if casses:
|
||
echecs.append(f"{len(casses)} lien(s) du glossaire vers une page inexistante : "
|
||
+ ", ".join(casses))
|
||
if orphelines:
|
||
echecs.append(f"{len(orphelines)} page(s) du wiki absente(s) de la navigation : "
|
||
+ ", ".join(orphelines))
|
||
if echecs:
|
||
return False, " | ".join(echecs)
|
||
return True, (f"Glossaire complet : {len(JARGON_A_ENSEIGNER)} terme(s) du jargon "
|
||
f"expliques, {len(liens)} lien(s) valides, {len(pages)} page(s) de wiki "
|
||
f"toutes atteignables.")
|
||
|
||
|
||
def preuve_catalogue_a_jour() -> tuple[bool, str]:
|
||
"""Le catalogue des services nomme TOUT ce que le moteur sait deployer, et rien d'autre.
|
||
|
||
POURQUOI (mesure du 2026-08-18). `catalogue-services.md` est la carte de ce que Set-OPS
|
||
FAIT — le document que lit l'hebergeur d'un second site, un futur client, un mainteneur
|
||
qui arrive. Il avait quatre mois de retard : il annoncait comme « capacites futures
|
||
encore a implementer » la collaboration (Nextcloud/Collabora) et la couche web
|
||
(frontal/dorsal), dont les roles existent et dont les hotes sont ACTIFS ; il disait la
|
||
federation LDAP de Keycloak « pas encore automatisee » alors que le role la pose ; ses
|
||
tables ignoraient NEUF roles vivants (le socle, la pile courriel entiere, les
|
||
sauvegardes, Icinga Web 2, oauth2-proxy, Unbound) dont deux — `serveur_backup` et
|
||
`client_backup` — que le document ne nommait NULLE PART ; et il annoncait une
|
||
integration `client_supervision`
|
||
qui n'a jamais existe, plus deux roles inventes par une colonne « Role » decorative
|
||
(`client_metriques`, `client_journaux`, au pluriel).
|
||
|
||
P31 ne pouvait pas le voir : elle verifie que chaque script, cible `make` et role est
|
||
NOMME et ATTEIGNABLE — pas qu'un document dise vrai. Une carte peut etre complete et
|
||
perimee.
|
||
|
||
CE QU'ELLE TESTE, dans les deux sens — et des DEUX cotes c'est la TABLE qui fait foi :
|
||
- tout role `serveur_*` / `client_*` figure dans une ligne de table. Etre cite dans
|
||
un paragraphe ne suffit pas : la pile courriel (`serveur_postfix`,
|
||
`serveur_dovecot`, `serveur_rspamd`) etait racontee en prose et absente de toutes
|
||
les tables — introuvable pour qui lit le catalogue comme un index, c'est-a-dire
|
||
pour tout le monde ;
|
||
- tout groupe cite dans une LIGNE DE TABLE du catalogue existe reellement — comme
|
||
role, ou comme playbook de groupe (`serveur_durci` compose des roles de
|
||
durcissement sans role homonyme). Une case de table est une affirmation
|
||
d'existence : c'est ainsi que `client_supervision` a survecu des mois.
|
||
|
||
CE QU'ELLE NE TESTE PAS, et c'est deliberate :
|
||
- la PROSE. Le catalogue raconte son histoire, y compris les roles RETIRES
|
||
(`serveur_sendmail`, `client_dns`, `client_ldap`) : les nommer hors table doit
|
||
rester permis, sinon on ne peut plus ecrire d'ou l'on vient ;
|
||
- que la description soit JUSTE. Qu'un service soit dit « eprouve » a bon droit se
|
||
juge en revue, contre le CHANGELOG. Mecaniser ce jugement serait se mentir.
|
||
"""
|
||
catalogue = RACINE / "docs" / "catalogue-services.md"
|
||
if not catalogue.is_file():
|
||
return False, "docs/catalogue-services.md absent"
|
||
texte = catalogue.read_text(encoding="utf-8", errors="ignore")
|
||
roles = {d.name for d in (RACINE / "roles").iterdir()
|
||
if d.is_dir() and d.name.startswith(("serveur_", "client_"))}
|
||
groupes = {p.stem for p in (RACINE / "playbooks" / "groupes").glob("*.yml")}
|
||
|
||
lignes_table = [l for l in texte.splitlines() if l.lstrip().startswith("|")]
|
||
cites = {m for l in lignes_table for m in re.findall(r"(?:serveur|client)_[a-z0-9_]+", l)}
|
||
tus = sorted(r for r in roles if r not in cites)
|
||
fantomes = sorted(c for c in cites if c not in roles and c not in groupes)
|
||
|
||
echecs = []
|
||
if tus:
|
||
echecs.append(f"{len(tus)} role(s) que le catalogue ne nomme pas : " + ", ".join(tus))
|
||
if fantomes:
|
||
echecs.append(f"{len(fantomes)} groupe(s) annonce(s) en table sans role ni playbook : "
|
||
+ ", ".join(fantomes))
|
||
if echecs:
|
||
return False, " | ".join(echecs)
|
||
return True, (f"Catalogue a jour : {len(roles)} role(s) serveur/client tous nommes, "
|
||
f"{len(cites)} groupe(s) cite(s) en table existent tous.")
|
||
|
||
|
||
def preuve_documentation_outillage() -> tuple[bool, str]:
|
||
"""Tout ce que Set-OPS FAIT s'explique et reste atteignable.
|
||
|
||
Exigence de l'exploitant, 2026-08-08 : « la doc dit et explique tout ce que Set-OPS
|
||
fait, et pourquoi c'est ainsi. » Une exigence qu'on n'outille pas pourrit en silence
|
||
— la carte annoncait « 28 decisions » quand il y en avait 66, et disait les acces
|
||
« non construits » alors qu'ils tournaient en production.
|
||
|
||
CE QU'ELLE TESTE, et pourquoi ainsi :
|
||
|
||
- chaque `scripts/*.py` porte une docstring de module dont la premiere ligne
|
||
explique a quoi il sert. Le premier jet verifiait plutot que le nom du script
|
||
« apparaisse dans un document » : deux fois ce critere s'est revele creux —
|
||
d'abord parce que le rapport d'audit GENERE recopiait les noms manquants dans
|
||
son message d'echec, ensuite parce qu'un inventaire genere de l'outillage
|
||
aurait fait passer la preuve au vert sans qu'une ligne soit ecrite. Un critere
|
||
qu'on peut satisfaire en generant du texte ne prouve rien ;
|
||
- chaque script est ATTEIGNABLE : invoque par une cible `make`, ou importe/appele
|
||
par un autre script (bibliotheques partagees, scripts appeles par les preuves).
|
||
Un outil que rien n'atteint est du code mort qui se documente tout seul ;
|
||
- chaque cible `make` porte un texte d'aide `##`, SAUF celles prefixees `_` :
|
||
convention du depot pour les cibles internes (attentes, verifications de
|
||
privileges) qui ne sont pas des commandes d'exploitant. L'exemption est nommee
|
||
ici pour rester un choix et non un trou ;
|
||
- chaque role porte un README.
|
||
|
||
CE QU'ELLE NE TESTE PAS : que l'explication soit BONNE. Le « pourquoi » se juge en
|
||
revue ; il vit dans `CHANGELOG.md` (les faits mesures) et dans
|
||
`decisions-architecture.md`. Pretendre le mesurer mecaniquement serait se mentir.
|
||
"""
|
||
manques: list[str] = []
|
||
scripts = sorted((RACINE / "scripts").glob("*.py"))
|
||
textes = {f.name: f.read_text(encoding="utf-8", errors="ignore") for f in scripts}
|
||
makefile = (RACINE / "Makefile").read_text(encoding="utf-8", errors="ignore")
|
||
|
||
# `ast`, pas une expression reguliere : les scripts commencent par un shebang, et
|
||
# mon premier motif le prenait pour l'absence de docstring — 35 faux positifs d'un
|
||
# coup. Lire du Python avec le parseur de Python.
|
||
muets = []
|
||
for f in scripts:
|
||
try:
|
||
doc = ast.get_docstring(ast.parse(textes[f.name])) or ""
|
||
except SyntaxError:
|
||
muets.append(f"{f.name} (illisible)")
|
||
continue
|
||
if len(doc.strip().splitlines()[0] if doc.strip() else "") < 30:
|
||
muets.append(f.name)
|
||
if muets:
|
||
manques.append(f"{len(muets)} script(s) sans docstring explicative : "
|
||
+ ", ".join(muets[:6]) + ("…" if len(muets) > 6 else ""))
|
||
|
||
# Un script peut aussi etre invoque par un ROLE ou un PLAYBOOK — `serveur_keycloak`
|
||
# appelle `verifier_signature.py` pour controler la signature de son archive. Ne
|
||
# regarder que le Makefile et les autres scripts declarait ce script « injoignable »
|
||
# alors qu'il tourne a chaque deploiement. Trouve le 2026-08-10, en ajoutant
|
||
# justement cette verification : une preuve qui ignore un chemin d'appel reel accuse
|
||
# du code sain, et on apprend a passer outre — le pire sort pour une garde.
|
||
ansible_yaml = "\n".join(
|
||
f.read_text(encoding="utf-8", errors="ignore")
|
||
for dossier in ("roles", "playbooks")
|
||
for f in (RACINE / dossier).rglob("*.yml")
|
||
)
|
||
|
||
injoignables = []
|
||
for f in scripts:
|
||
autres = "\n".join(v for k, v in textes.items() if k != f.name)
|
||
module = f.stem
|
||
if f"scripts/{f.name}" in makefile:
|
||
continue
|
||
if module in autres: # importe ou appele par un autre outil
|
||
continue
|
||
if f.name in ansible_yaml: # invoque par un role ou un playbook
|
||
continue
|
||
injoignables.append(f.name)
|
||
if injoignables:
|
||
manques.append(f"{len(injoignables)} script(s) qu'aucune cible ni aucun outil "
|
||
f"n'atteint : " + ", ".join(injoignables[:6]))
|
||
|
||
muettes = [
|
||
ligne.split(":")[0]
|
||
for ligne in makefile.splitlines()
|
||
# `[A-Za-z]` et non `[a-z]` : le premier motif laissait passer TOUTE cible
|
||
# contenant une majuscule. `myDay` — alias de `reconstruire`, cite dans l'aide
|
||
# et dans la GUI — echappait donc au controle et n'avait aucun texte d'aide.
|
||
# Trouve par une question de l'exploitant, une heure apres l'ecriture de P31 :
|
||
# une preuve ne vaut que ce que vaut son motif.
|
||
if re.match(r"^[A-Za-z][A-Za-z0-9_-]*:", ligne) and "##" not in ligne
|
||
]
|
||
if muettes:
|
||
manques.append(f"{len(muettes)} cible(s) make sans texte d'aide `##` : "
|
||
+ ", ".join(sorted(muettes)[:6]) + ("…" if len(muettes) > 6 else ""))
|
||
|
||
sans_readme = sorted(
|
||
d.name for d in (RACINE / "roles").iterdir()
|
||
if d.is_dir() and not (d / "README.md").exists()
|
||
)
|
||
if sans_readme:
|
||
manques.append(f"{len(sans_readme)} role(s) sans README : " + ", ".join(sans_readme[:6]))
|
||
|
||
if manques:
|
||
return False, " | ".join(manques)
|
||
|
||
n_c = len([l for l in makefile.splitlines() if re.match(r"^[A-Za-z][A-Za-z0-9_-]*:", l)])
|
||
n_r = len([d for d in (RACINE / "roles").iterdir() if d.is_dir()])
|
||
return True, (f"{len(scripts)} scripts expliques et atteignables, "
|
||
f"{n_c} cibles make documentees, {n_r} roles avec README.")
|
||
|
||
|
||
# --- Registre des preuves : (id, titre, refs AFF, executeur) -----------------------
|
||
#
|
||
# executeur = liste de commandes argv (toutes doivent renvoyer 0), ou callable -> (ok, detail).
|
||
|
||
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 — 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": "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).
|
||
|
||
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 = {"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:]))
|