Principe : les valeurs de configuration se derivent des intrants, elles ne se reecrivent pas a la main. La nomenclature dupliquait ce qu'index determine deja (supernet, sous-reseaux, passerelles, VLAN). Corrige en rupture nette. - inventory_rules : source unique de derivation — supernet_de, base3_de, sous_reseau_de, passerelle_de, vlan_de. Modele 6 zones encode une fois (2e octet = 10+index, 3e octet zone = 15+categorie, VLAN = 1000+index*10+zone). deriver_nomenclature ne lit plus aucun adressage stocke ; mode compact supprime. - devis_reseau : importe ces helpers (fin de la duplication) ; decouvre les tenants sur `index` present (filtre vmid_schema retire). - GUI : `index` devient un INTRANT (section Reseau). Il vit dans la nomenclature (plan reseau uniforme, contrairement aux intrants des modeles heterogenes) et le GUI l'ecrit chirurgicalement (une ligne, sans reformater). Le miroir JS derive le VLAN du seed (fin de la lecture de c.vlan stocke). - socle public : nomenclature au format maigre. Preuve P20 (preuve_nomenclature_derivee) : aucune nomenclature ne stocke d'adressage — garde-fou permanent, teste en negatif. Valide : DIFF VIDE sur les 3 instances (la derivation reproduit exactement l'adressage stocke), 7 modeles valident, devis_reseau genere les memes VLAN (1011-1016 derives), make verifier rc=0 CONFORME 20/20, node --check du GUI OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
417 lines
19 KiB
Python
417 lines
19 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 json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
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."
|
|
|
|
|
|
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)."
|
|
|
|
|
|
# --- 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"]]},
|
|
{"id": "P03", "titre": "Diff-vide du plan (inventaire genere)",
|
|
"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"],
|
|
"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", "AFF-083"], "func": preuve_runbooks},
|
|
{"id": "P13", "titre": "Invariants structurels/doctrinaux",
|
|
"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 d'instance valident",
|
|
"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-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-001", "AFF-003"], "func": preuve_nomenclature_derivee},
|
|
]
|
|
|
|
|
|
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 _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)
|
|
|
|
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:]))
|