Mise en conformité prouvable : registre d'affirmations + make prouver
Le dépôt fait / explique / prouve ce qu'il affirme, vérifiable en une commande.
- Phase 1 : docs/audit/affirmations.md — 54 affirmations publiques tracées vers
une commande de preuve et un statut (✅/🟡/❌/⚪).
- Phase 2 : CLAUDE.md réduit à un pointeur mince ; contradiction SSH levée (le
code applique déjà PasswordAuthentication no + AuthenticationMethods publickey,
conforme à AGENTS.md) ; section AGENTS « Codex » → « agents IA ».
- Phase 3 : parcours démarrage réparé (QUICKSTART renvoyait à un modèle absent,
chemins de voûte faux, commandes make périmées) ; make verifier vert
(ansible-lint 33 → 0 : site.yml généré nommé, pipefail, name[template]) ;
voûte Proxmox unifiée lue par le clonage (all/vault.yml).
- Phase 4 : make prouver → docs/audit/preuve-<date>.md, harnais rejouable qui
rappelle l'outillage existant (aucune validation réimplémentée).
- Phase 5 : parcours QUICKSTART prouvé hors-ligne sur le socle ; modèle socle
rendu valide (autorite interne → auto-heberge) ; split-brain d'inventaire
corrigé (repli sur le répertoire existant, pas principal/).
make prouver : 15 OK, 0 échec, 1 sautée (voûte). ansible-lint : 0 failure.
Écarts découverts en cours de traitement (AFF-097..100) : tous résolus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:53:18 -04:00
|
|
|
|
#!/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
|
2026-07-20 21:03:20 -04:00
|
|
|
|
python3 scripts/prouver.py --verifier # preuves seules (verdict + code sortie), sans rapport
|
Mise en conformité prouvable : registre d'affirmations + make prouver
Le dépôt fait / explique / prouve ce qu'il affirme, vérifiable en une commande.
- Phase 1 : docs/audit/affirmations.md — 54 affirmations publiques tracées vers
une commande de preuve et un statut (✅/🟡/❌/⚪).
- Phase 2 : CLAUDE.md réduit à un pointeur mince ; contradiction SSH levée (le
code applique déjà PasswordAuthentication no + AuthenticationMethods publickey,
conforme à AGENTS.md) ; section AGENTS « Codex » → « agents IA ».
- Phase 3 : parcours démarrage réparé (QUICKSTART renvoyait à un modèle absent,
chemins de voûte faux, commandes make périmées) ; make verifier vert
(ansible-lint 33 → 0 : site.yml généré nommé, pipefail, name[template]) ;
voûte Proxmox unifiée lue par le clonage (all/vault.yml).
- Phase 4 : make prouver → docs/audit/preuve-<date>.md, harnais rejouable qui
rappelle l'outillage existant (aucune validation réimplémentée).
- Phase 5 : parcours QUICKSTART prouvé hors-ligne sur le socle ; modèle socle
rendu valide (autorite interne → auto-heberge) ; split-brain d'inventaire
corrigé (repli sur le répertoire existant, pas principal/).
make prouver : 15 OK, 0 échec, 1 sautée (voûte). ansible-lint : 0 failure.
Écarts découverts en cours de traitement (AFF-097..100) : tous résolus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:53:18 -04:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import datetime as _dt
|
2026-08-08 13:21:23 -04:00
|
|
|
|
import ast
|
2026-07-20 20:47:36 -04:00
|
|
|
|
import json
|
2026-08-08 13:21:23 -04:00
|
|
|
|
import re
|
glossaire : le metier n'etait explique nulle part
Demande de l'exploitant apres une soiree passee a croiser « strophe FRR », VRF, VNet et
nexthop-vrf : cet ecosysteme doit rester pilotable par un humain, idealement un seul ;
que chaque notion sous-jacente soit ENSEIGNEE.
MESURE AVANT D'ECRIRE : 40 termes employes par le depot et absents du glossaire — LDAP
184 fois, playbook 165, underlay 106, EVPN 66, VRF 33, LMTP 25. Le glossaire expliquait le
vocabulaire propre a Set-OPS (plan, index, voute, zone) et laissait dehors tout ce qui
vient du metier. Or c'est le metier qui perd le lecteur.
CE N'EST PAS UN DEFAUT DE REDACTION. La regle fondatrice du depot est qu'un humain pilote
sans IA. Chaque mot obscur retire une personne a la liste de celles qui peuvent reprendre
le systeme : un vocabulaire non explique est un defaut de CONCEPTION.
- Glossaire reecrit : 67 termes groupes par famille (plan, machines, Ansible, reseau,
noms, confiance, identite, courriel, etat et preuve). Chaque entree dit ce que c'est ET
pourquoi ce depot s'en sert, avec renvoi vers l'unite qui developpe.
- Unite d'apprentissage manquante : « Le reseau des tenants ». Dix-sept des quarante
termes y vivaient sans domicile. Elle suit l'ordre ou les problemes se sont poses : deux
clients sur un cable -> VLAN -> ses deux limites -> encapsulation -> pourquoi 1450 ->
EVPN -> le VRF, qui n'est pas une interdiction mais une ignorance structurelle.
- Navigation : la nouvelle unite est au sidebar ; le plan de recette regenere (P22 l'a
exige des l'ajout de la page — le harnais a mordu).
P39 verifie : chaque terme du jargon a une entree ; chaque lien du glossaire mene a une
page existante ; chaque page du wiki est atteignable depuis la navigation.
LA LISTE EST DECLAREE, ET C'EST UN CHOIX MESURE. La derivation automatique a ete essayee :
153 acronymes dans le wiki et le README, dont la moitie sont des mots francais en
capitales (AUCUNE, AVANT, TOUS). Un controle qui exige une entree pour « AUCUNE » finit
desactive, et une preuve desactivee ne garde rien. La preuve dit elle-meme cet angle mort.
EPROUVEE EN NEGATIF contre le glossaire d'avant : 49 termes manquants, nommes un par un.
make verifier 39 OK, 0 echec, 0 saute ; make ci idem.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:15:55 -04:00
|
|
|
|
import unicodedata
|
Mise en conformité prouvable : registre d'affirmations + make prouver
Le dépôt fait / explique / prouve ce qu'il affirme, vérifiable en une commande.
- Phase 1 : docs/audit/affirmations.md — 54 affirmations publiques tracées vers
une commande de preuve et un statut (✅/🟡/❌/⚪).
- Phase 2 : CLAUDE.md réduit à un pointeur mince ; contradiction SSH levée (le
code applique déjà PasswordAuthentication no + AuthenticationMethods publickey,
conforme à AGENTS.md) ; section AGENTS « Codex » → « agents IA ».
- Phase 3 : parcours démarrage réparé (QUICKSTART renvoyait à un modèle absent,
chemins de voûte faux, commandes make périmées) ; make verifier vert
(ansible-lint 33 → 0 : site.yml généré nommé, pipefail, name[template]) ;
voûte Proxmox unifiée lue par le clonage (all/vault.yml).
- Phase 4 : make prouver → docs/audit/preuve-<date>.md, harnais rejouable qui
rappelle l'outillage existant (aucune validation réimplémentée).
- Phase 5 : parcours QUICKSTART prouvé hors-ligne sur le socle ; modèle socle
rendu valide (autorite interne → auto-heberge) ; split-brain d'inventaire
corrigé (repli sur le répertoire existant, pas principal/).
make prouver : 15 OK, 0 échec, 1 sautée (voûte). ansible-lint : 0 failure.
Écarts découverts en cours de traitement (AFF-097..100) : tous résolus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:53:18 -04:00
|
|
|
|
import os
|
|
|
|
|
|
import subprocess
|
|
|
|
|
|
import sys
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
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
|
|
|
|
import yaml
|
|
|
|
|
|
|
Mise en conformité prouvable : registre d'affirmations + make prouver
Le dépôt fait / explique / prouve ce qu'il affirme, vérifiable en une commande.
- Phase 1 : docs/audit/affirmations.md — 54 affirmations publiques tracées vers
une commande de preuve et un statut (✅/🟡/❌/⚪).
- Phase 2 : CLAUDE.md réduit à un pointeur mince ; contradiction SSH levée (le
code applique déjà PasswordAuthentication no + AuthenticationMethods publickey,
conforme à AGENTS.md) ; section AGENTS « Codex » → « agents IA ».
- Phase 3 : parcours démarrage réparé (QUICKSTART renvoyait à un modèle absent,
chemins de voûte faux, commandes make périmées) ; make verifier vert
(ansible-lint 33 → 0 : site.yml généré nommé, pipefail, name[template]) ;
voûte Proxmox unifiée lue par le clonage (all/vault.yml).
- Phase 4 : make prouver → docs/audit/preuve-<date>.md, harnais rejouable qui
rappelle l'outillage existant (aucune validation réimplémentée).
- Phase 5 : parcours QUICKSTART prouvé hors-ligne sur le socle ; modèle socle
rendu valide (autorite interne → auto-heberge) ; split-brain d'inventaire
corrigé (repli sur le répertoire existant, pas principal/).
make prouver : 15 OK, 0 échec, 1 sautée (voûte). ansible-lint : 0 failure.
Écarts découverts en cours de traitement (AFF-097..100) : tous résolus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:53:18 -04:00
|
|
|
|
RACINE = Path(__file__).resolve().parents[1]
|
resolution d'instance : une seule, partagee — au lieu de neuf copies
Cinq jours, cinq defauts, tous de la meme famille : « quelle instance, quel inventaire ? »
Neuf modules portaient chacun leur reponse.
- 18 aout : P03 comparait chaque instance a l'inventaire d'une AUTRE ;
- 19 aout : verifier_ports codait `principal/` en dur ; verifier_intrants et
_frontiere_absente lisaient le symlink au lieu de la variable ;
- 20 aout : devis_placement rendait un verdict juste sur le mauvais tenant ;
- 22 aout : P35, puis P36 — la dixieme, trouvee par la preuve elle-meme.
Aucune n'etait une faute d'inattention : chacune avait ete ecrite de bonne foi, a un
moment ou le besoin semblait local. C'est le mode de panne de la duplication — pas
l'erreur, mais la DERIVE, invisible depuis l'interieur d'un fichier.
LA RESOLUTION UNIQUE. `inventory_rules` porte instance_courante(), inventaire_de(),
dossier_inventaire() et plan_de(). Trois niveaux de repli, dont le TROISIEME manquait a la
moitie des copies : un hosts.yml existant, puis un REPERTOIRE existant (instance neuve —
c'est ce qui faisait echouer `make instancier` sur le modele public), puis le defaut.
Vingt-huit modules y sont branches.
CE QUI REND CE REFACTOR SUR : avant de toucher quoi que ce soit, chaque module a ete
interroge sur ce qu'il resolvait, pour les DEUX ecosystemes. Apres refactor, meme mesure :
17 modules x 2 instances, diff VIDE. Aucune resolution n'a change — prouve, pas suppose.
P41 echoue des qu'un module reintroduit une copie. Eprouvee en negatif : une copie
replacee dans genome.py est signalee avec son numero de ligne. Trois exemptions nommees :
instances.py et inventory_gui.py manipulent le SYMLINK lui-meme (bascule d'instance), et
devis_opnsense lit deliberement quelle instance est ACTIVE. Elles parlent du lien, pas de
la resolution.
make verifier 41 OK, 0 echec, 0 saute ; make ci idem ; lint vert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:19:55 -04:00
|
|
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
|
|
|
|
|
|
|
|
|
|
from inventory_rules import instance_courante, plan_de # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
INSTANCE = instance_courante()
|
Mise en conformité prouvable : registre d'affirmations + make prouver
Le dépôt fait / explique / prouve ce qu'il affirme, vérifiable en une commande.
- Phase 1 : docs/audit/affirmations.md — 54 affirmations publiques tracées vers
une commande de preuve et un statut (✅/🟡/❌/⚪).
- Phase 2 : CLAUDE.md réduit à un pointeur mince ; contradiction SSH levée (le
code applique déjà PasswordAuthentication no + AuthenticationMethods publickey,
conforme à AGENTS.md) ; section AGENTS « Codex » → « agents IA ».
- Phase 3 : parcours démarrage réparé (QUICKSTART renvoyait à un modèle absent,
chemins de voûte faux, commandes make périmées) ; make verifier vert
(ansible-lint 33 → 0 : site.yml généré nommé, pipefail, name[template]) ;
voûte Proxmox unifiée lue par le clonage (all/vault.yml).
- Phase 4 : make prouver → docs/audit/preuve-<date>.md, harnais rejouable qui
rappelle l'outillage existant (aucune validation réimplémentée).
- Phase 5 : parcours QUICKSTART prouvé hors-ligne sur le socle ; modèle socle
rendu valide (autorite interne → auto-heberge) ; split-brain d'inventaire
corrigé (repli sur le répertoire existant, pas principal/).
make prouver : 15 OK, 0 échec, 1 sautée (voûte). ansible-lint : 0 failure.
Écarts découverts en cours de traitement (AFF-097..100) : tous résolus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:53:18 -04:00
|
|
|
|
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"
|
P03 : la preuve comparait chaque instance a l'inventaire d'UNE SEULE
Trouve en validant une mise a jour du CHANGELOG. Deux invocations de la meme preuve,
deux verdicts : `make prouver` -> NON CONFORME (« lab : 17 hotes avec ecart »),
`python3 scripts/prouver.py` -> CONFORME 37/37. Le lab n'avait aucun ecart.
DEUX VARIABLES DESIGNENT LA CIBLE, ET LA SECONDE GAGNE. Le Makefile exporte
SETOPS_INVENTAIRE (ligne 13), derive de l'instance ACTIVE ; instancier.py:68 lui fait
FORCER la cible par-dessus SETOPS_INSTANCE. P03 (prouver.py:505) ne redirigeait que
SETOPS_INSTANCE : elle generait le plan de CHAQUE instance federee et le comparait a
l'inventaire applique de la SEULE instance active.
LE ROUGE N'ETAIT PAS LE PROBLEME, LE VERT L'ETAIT. Sous `make`, l'inventaire applique
de lab et de Technolibre n'etait JAMAIS lu — l'angle meme pour lequel P03 a ete ecrite
le 2026-08-12 (un tenant qu'on ne regarde pas imposant ses vieilles adresses au pare-feu
partage). La preuve etait aveugle a son propre cas, par l'invocation documentee. Les
rapports du 13 et du 14 sortent de cette invocation-la. Signature visible sans lire le
code : les hosts.genere.yml de lab et de Technolibre ne bougeaient pas.
CORRECTIF, cinq sites : env.pop("SETOPS_INVENTAIRE") partout ou l'on redirige
SETOPS_INSTANCE — P03 et P15, plus les trois applicateurs (opnsense, proxmox_fw, sdn) qui
pointent vers l'HEBERGEUR. Ces trois sont sans effet tant qu'hebergeur et tenant actif
coincident, c'est-a-dire jusqu'au second site. Le geste existait deja (modeles.py:96).
GARDE, pour que la classe cesse d'etre silencieuse : inventory_rules.inventaire_force()
REFUSE une cible hors de l'instance visee, en nommant les deux valeurs. Eprouvee dans les
deux sens (contradiction -> code 1 ; cible legitime dans l'instance -> passe). Branchee
sur les quatre resolutions de _inventaire (instancier, serveurs, applications,
config_proxmox). Le GUI garde la sienne : il ne redirige jamais SETOPS_INSTANCE pour un
fils et resout par symlink a chaque requete.
make prouver : 37 OK, 0 echec, 0 saute — et les hosts.genere.yml des TROIS instances
portent l'horodatage du passage. make test 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:35:01 -04:00
|
|
|
|
# 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.
|
Mise en conformité prouvable : registre d'affirmations + make prouver
Le dépôt fait / explique / prouve ce qu'il affirme, vérifiable en une commande.
- Phase 1 : docs/audit/affirmations.md — 54 affirmations publiques tracées vers
une commande de preuve et un statut (✅/🟡/❌/⚪).
- Phase 2 : CLAUDE.md réduit à un pointeur mince ; contradiction SSH levée (le
code applique déjà PasswordAuthentication no + AuthenticationMethods publickey,
conforme à AGENTS.md) ; section AGENTS « Codex » → « agents IA ».
- Phase 3 : parcours démarrage réparé (QUICKSTART renvoyait à un modèle absent,
chemins de voûte faux, commandes make périmées) ; make verifier vert
(ansible-lint 33 → 0 : site.yml généré nommé, pipefail, name[template]) ;
voûte Proxmox unifiée lue par le clonage (all/vault.yml).
- Phase 4 : make prouver → docs/audit/preuve-<date>.md, harnais rejouable qui
rappelle l'outillage existant (aucune validation réimplémentée).
- Phase 5 : parcours QUICKSTART prouvé hors-ligne sur le socle ; modèle socle
rendu valide (autorite interne → auto-heberge) ; split-brain d'inventaire
corrigé (repli sur le répertoire existant, pas principal/).
make prouver : 15 OK, 0 échec, 1 sautée (voûte). ansible-lint : 0 failure.
Écarts découverts en cours de traitement (AFF-097..100) : tous résolus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:53:18 -04:00
|
|
|
|
env = {**os.environ, "SETOPS_INSTANCE": socle}
|
P03 : la preuve comparait chaque instance a l'inventaire d'UNE SEULE
Trouve en validant une mise a jour du CHANGELOG. Deux invocations de la meme preuve,
deux verdicts : `make prouver` -> NON CONFORME (« lab : 17 hotes avec ecart »),
`python3 scripts/prouver.py` -> CONFORME 37/37. Le lab n'avait aucun ecart.
DEUX VARIABLES DESIGNENT LA CIBLE, ET LA SECONDE GAGNE. Le Makefile exporte
SETOPS_INVENTAIRE (ligne 13), derive de l'instance ACTIVE ; instancier.py:68 lui fait
FORCER la cible par-dessus SETOPS_INSTANCE. P03 (prouver.py:505) ne redirigeait que
SETOPS_INSTANCE : elle generait le plan de CHAQUE instance federee et le comparait a
l'inventaire applique de la SEULE instance active.
LE ROUGE N'ETAIT PAS LE PROBLEME, LE VERT L'ETAIT. Sous `make`, l'inventaire applique
de lab et de Technolibre n'etait JAMAIS lu — l'angle meme pour lequel P03 a ete ecrite
le 2026-08-12 (un tenant qu'on ne regarde pas imposant ses vieilles adresses au pare-feu
partage). La preuve etait aveugle a son propre cas, par l'invocation documentee. Les
rapports du 13 et du 14 sortent de cette invocation-la. Signature visible sans lire le
code : les hosts.genere.yml de lab et de Technolibre ne bougeaient pas.
CORRECTIF, cinq sites : env.pop("SETOPS_INVENTAIRE") partout ou l'on redirige
SETOPS_INSTANCE — P03 et P15, plus les trois applicateurs (opnsense, proxmox_fw, sdn) qui
pointent vers l'HEBERGEUR. Ces trois sont sans effet tant qu'hebergeur et tenant actif
coincident, c'est-a-dire jusqu'au second site. Le geste existait deja (modeles.py:96).
GARDE, pour que la classe cesse d'etre silencieuse : inventory_rules.inventaire_force()
REFUSE une cible hors de l'instance visee, en nommant les deux valeurs. Eprouvee dans les
deux sens (contradiction -> code 1 ; cible legitime dans l'instance -> passe). Branchee
sur les quatre resolutions de _inventaire (instancier, serveurs, applications,
config_proxmox). Le GUI garde la sienne : il ne redirige jamais SETOPS_INSTANCE pour un
fils et resout par symlink a chaque requete.
make prouver : 37 OK, 0 echec, 0 saute — et les hosts.genere.yml des TROIS instances
portent l'horodatage du passage. make test 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:35:01 -04:00
|
|
|
|
env.pop("SETOPS_INVENTAIRE", None)
|
Mise en conformité prouvable : registre d'affirmations + make prouver
Le dépôt fait / explique / prouve ce qu'il affirme, vérifiable en une commande.
- Phase 1 : docs/audit/affirmations.md — 54 affirmations publiques tracées vers
une commande de preuve et un statut (✅/🟡/❌/⚪).
- Phase 2 : CLAUDE.md réduit à un pointeur mince ; contradiction SSH levée (le
code applique déjà PasswordAuthentication no + AuthenticationMethods publickey,
conforme à AGENTS.md) ; section AGENTS « Codex » → « agents IA ».
- Phase 3 : parcours démarrage réparé (QUICKSTART renvoyait à un modèle absent,
chemins de voûte faux, commandes make périmées) ; make verifier vert
(ansible-lint 33 → 0 : site.yml généré nommé, pipefail, name[template]) ;
voûte Proxmox unifiée lue par le clonage (all/vault.yml).
- Phase 4 : make prouver → docs/audit/preuve-<date>.md, harnais rejouable qui
rappelle l'outillage existant (aucune validation réimplémentée).
- Phase 5 : parcours QUICKSTART prouvé hors-ligne sur le socle ; modèle socle
rendu valide (autorite interne → auto-heberge) ; split-brain d'inventaire
corrigé (repli sur le répertoire existant, pas principal/).
make prouver : 15 OK, 0 échec, 1 sautée (voûte). ansible-lint : 0 failure.
Écarts découverts en cours de traitement (AFF-097..100) : tous résolus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:53:18 -04:00
|
|
|
|
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."
|
|
|
|
|
|
|
|
|
|
|
|
|
authentification : chaque rôle déclare sa position, gardé par P29
Une règle qu'aucune garde ne vérifie finit par ne plus être vraie — c'est ce qui
était arrivé aux 28 lignes d'intégration recopiées. Chaque rôle serveur_* porte
un meta/authentification.yml, confronté à son code par P29.
web-sso 5, socle-identite 2 (keycloak/openldap : ils SONT la chaîne d'identité),
ldap-direct 2, interne-sans-auth 2, sans-auth-humaine 12.
La preuve refuse l'oubli ET le mensonge. Éprouvée par sabotage sur sept cas :
déclaration supprimée, portée inventée, secours retiré, posture de formulaire
retirée, raison retirée, ldap-direct mensonger, réglage retiré des defaults.
Les deux derniers passaient dans la première version :
- le mensonge passait à cause d'un commentaire. Je cherchais le mot « ldap » dans
le rôle, et serveur_grafana/defaults/main.yml contient « désactiver quelqu'un
dans LDAP » : de la prose validait une déclaration fausse. La preuve exige
maintenant un indice nommé — variable <rôle>_oidc / <rôle>_ldap, ou URI ldap://
- le réglage retiré passait parce que le gabarit citait encore la variable alors
que plus rien ne lui donnait de valeur. La preuve lit defaults/main.yml en YAML
et exige que la clé y soit définie, pas mentionnée.
Elle a aussi forcé une valeur : oauth2-proxy était déclaré « formulaire local
fermé » alors qu'il n'a aucun compte local. D'où formulaire_local: aucun, qui
distingue « il n'y en a jamais eu » de « il y en a un, il est fermé ».
Correction d'une note de la veille : Prometheus et Loki ne sont PAS exposés
publiquement (aucun expose au plan). Seuls six groupes le sont. Le risque est
intra-tenant, pas frontalier. Les deux lacunes sont comptées à chaque exécution,
pas masquées.
AFF-111, D-42. 29 preuves OK, ansible-lint (production) sur 375 fichiers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:05:43 -04:00
|
|
|
|
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}."
|
|
|
|
|
|
|
|
|
|
|
|
|
intégrations : le rôle déclare sa politique ; le cluster passe à l'hébergeur
Deux corrections de propriété, l'une dans le plan, l'autre dans les intrants.
1. Intégrations universelles (D-33/D-34, P26)
Le plan portait 57 lignes d'intégration écrites à la main, dont 28 disaient oui à
quelque chose de vrai pour tous les hôtes. Elles n'existaient que pour être
oubliées — et elles l'avaient été : dans Chezlepro, backup-01 et infra-pki-01
n'étaient ni supervisés, ni journalisés, ni certifiés.
Le rôle déclare désormais sa politique une fois, dans meta/integration.yml ; le
plan ne garde que les vrais choix et refuse la recopie. Les exemptions se
dérivent du service rendu (sauf_role), jamais d'un nom d'hôte : l'AC ne s'enrôle
pas auprès d'elle-même, et l'exemption suit step-ca si on le déplace.
Une seule fonction de résolution — integrations_de() — lue par l'inventaire, la
voûte et le panneau. Sans le passage par la voûte, les secrets des intégrations
universelles auraient cessé d'être exigés et P18 serait passé au vert sur une
voûte incomplète.
Vérifié : diff vide sur Technolibre (la politique reproduit exactement les 41
lignes retirées) ; sur Chezlepro, exactement les groupes manquants, et pas
client_pki sur infra-pki-01.
2. Vue Intégrations : la matrice
La fiche montrait les intégrations d'UN serveur ; le trou de Chezlepro n'a pas
été trouvé par le panneau mais par le devis de pare-feu. Matrice serveurs x
intégrations : colonnes de politique en lecture seule, facultatives cochables sur
place, ligne de couverture n/N qui rend le motif visible sans le juger.
3. Propriété des intrants (D-35/D-36, P27)
Le cluster Proxmox appartient à l'hébergeur, comme sa fabric et sa frontière.
Recopié chez chaque tenant, son inventaire avait déjà divergé : deux listes de
stockages contradictoires pour le même matériel. API/nœuds/stockages/ponts vont
dans proxmox-hebergeur.yml, à côté d'underlay.yml, dont le chemin se dérive —
l'hébergeur reste non déclaré (D-17). Restent au tenant son golden template et
ses défauts de placement.
Le panneau nomme désormais le propriétaire de chaque section : éditer une section
« hébergeur » vaut pour tous ses tenants, et l'écran ne le disait pas.
26 preuves OK, 0 échec. --syntax-check des deux playbooks Proxmox.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:09:22 -04:00
|
|
|
|
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
|
resolution d'instance : une seule, partagee — au lieu de neuf copies
Cinq jours, cinq defauts, tous de la meme famille : « quelle instance, quel inventaire ? »
Neuf modules portaient chacun leur reponse.
- 18 aout : P03 comparait chaque instance a l'inventaire d'une AUTRE ;
- 19 aout : verifier_ports codait `principal/` en dur ; verifier_intrants et
_frontiere_absente lisaient le symlink au lieu de la variable ;
- 20 aout : devis_placement rendait un verdict juste sur le mauvais tenant ;
- 22 aout : P35, puis P36 — la dixieme, trouvee par la preuve elle-meme.
Aucune n'etait une faute d'inattention : chacune avait ete ecrite de bonne foi, a un
moment ou le besoin semblait local. C'est le mode de panne de la duplication — pas
l'erreur, mais la DERIVE, invisible depuis l'interieur d'un fichier.
LA RESOLUTION UNIQUE. `inventory_rules` porte instance_courante(), inventaire_de(),
dossier_inventaire() et plan_de(). Trois niveaux de repli, dont le TROISIEME manquait a la
moitie des copies : un hosts.yml existant, puis un REPERTOIRE existant (instance neuve —
c'est ce qui faisait echouer `make instancier` sur le modele public), puis le defaut.
Vingt-huit modules y sont branches.
CE QUI REND CE REFACTOR SUR : avant de toucher quoi que ce soit, chaque module a ete
interroge sur ce qu'il resolvait, pour les DEUX ecosystemes. Apres refactor, meme mesure :
17 modules x 2 instances, diff VIDE. Aucune resolution n'a change — prouve, pas suppose.
P41 echoue des qu'un module reintroduit une copie. Eprouvee en negatif : une copie
replacee dans genome.py est signalee avec son numero de ligne. Trois exemptions nommees :
instances.py et inventory_gui.py manipulent le SYMLINK lui-meme (bascule d'instance), et
devis_opnsense lit deliberement quelle instance est ACTIVE. Elles parlent du lien, pas de
la resolution.
make verifier 41 OK, 0 echec, 0 saute ; make ci idem ; lint vert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:19:55 -04:00
|
|
|
|
instance = instance_courante()
|
intégrations : le rôle déclare sa politique ; le cluster passe à l'hébergeur
Deux corrections de propriété, l'une dans le plan, l'autre dans les intrants.
1. Intégrations universelles (D-33/D-34, P26)
Le plan portait 57 lignes d'intégration écrites à la main, dont 28 disaient oui à
quelque chose de vrai pour tous les hôtes. Elles n'existaient que pour être
oubliées — et elles l'avaient été : dans Chezlepro, backup-01 et infra-pki-01
n'étaient ni supervisés, ni journalisés, ni certifiés.
Le rôle déclare désormais sa politique une fois, dans meta/integration.yml ; le
plan ne garde que les vrais choix et refuse la recopie. Les exemptions se
dérivent du service rendu (sauf_role), jamais d'un nom d'hôte : l'AC ne s'enrôle
pas auprès d'elle-même, et l'exemption suit step-ca si on le déplace.
Une seule fonction de résolution — integrations_de() — lue par l'inventaire, la
voûte et le panneau. Sans le passage par la voûte, les secrets des intégrations
universelles auraient cessé d'être exigés et P18 serait passé au vert sur une
voûte incomplète.
Vérifié : diff vide sur Technolibre (la politique reproduit exactement les 41
lignes retirées) ; sur Chezlepro, exactement les groupes manquants, et pas
client_pki sur infra-pki-01.
2. Vue Intégrations : la matrice
La fiche montrait les intégrations d'UN serveur ; le trou de Chezlepro n'a pas
été trouvé par le panneau mais par le devis de pare-feu. Matrice serveurs x
intégrations : colonnes de politique en lecture seule, facultatives cochables sur
place, ligne de couverture n/N qui rend le motif visible sans le juger.
3. Propriété des intrants (D-35/D-36, P27)
Le cluster Proxmox appartient à l'hébergeur, comme sa fabric et sa frontière.
Recopié chez chaque tenant, son inventaire avait déjà divergé : deux listes de
stockages contradictoires pour le même matériel. API/nœuds/stockages/ponts vont
dans proxmox-hebergeur.yml, à côté d'underlay.yml, dont le chemin se dérive —
l'hébergeur reste non déclaré (D-17). Restent au tenant son golden template et
ses défauts de placement.
Le panneau nomme désormais le propriétaire de chaque section : éditer une section
« hébergeur » vaut pour tous ses tenants, et l'écran ne le disait pas.
26 preuves OK, 0 échec. --syntax-check des deux playbooks Proxmox.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:09:22 -04:00
|
|
|
|
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."
|
resolution d'instance : une seule, partagee — au lieu de neuf copies
Cinq jours, cinq defauts, tous de la meme famille : « quelle instance, quel inventaire ? »
Neuf modules portaient chacun leur reponse.
- 18 aout : P03 comparait chaque instance a l'inventaire d'une AUTRE ;
- 19 aout : verifier_ports codait `principal/` en dur ; verifier_intrants et
_frontiere_absente lisaient le symlink au lieu de la variable ;
- 20 aout : devis_placement rendait un verdict juste sur le mauvais tenant ;
- 22 aout : P35, puis P36 — la dixieme, trouvee par la preuve elle-meme.
Aucune n'etait une faute d'inattention : chacune avait ete ecrite de bonne foi, a un
moment ou le besoin semblait local. C'est le mode de panne de la duplication — pas
l'erreur, mais la DERIVE, invisible depuis l'interieur d'un fichier.
LA RESOLUTION UNIQUE. `inventory_rules` porte instance_courante(), inventaire_de(),
dossier_inventaire() et plan_de(). Trois niveaux de repli, dont le TROISIEME manquait a la
moitie des copies : un hosts.yml existant, puis un REPERTOIRE existant (instance neuve —
c'est ce qui faisait echouer `make instancier` sur le modele public), puis le defaut.
Vingt-huit modules y sont branches.
CE QUI REND CE REFACTOR SUR : avant de toucher quoi que ce soit, chaque module a ete
interroge sur ce qu'il resolvait, pour les DEUX ecosystemes. Apres refactor, meme mesure :
17 modules x 2 instances, diff VIDE. Aucune resolution n'a change — prouve, pas suppose.
P41 echoue des qu'un module reintroduit une copie. Eprouvee en negatif : une copie
replacee dans genome.py est signalee avec son numero de ligne. Trois exemptions nommees :
instances.py et inventory_gui.py manipulent le SYMLINK lui-meme (bascule d'instance), et
devis_opnsense lit deliberement quelle instance est ACTIVE. Elles parlent du lien, pas de
la resolution.
make verifier 41 OK, 0 echec, 0 saute ; make ci idem ; lint vert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:19:55 -04:00
|
|
|
|
instance = instance_courante()
|
intégrations : le rôle déclare sa politique ; le cluster passe à l'hébergeur
Deux corrections de propriété, l'une dans le plan, l'autre dans les intrants.
1. Intégrations universelles (D-33/D-34, P26)
Le plan portait 57 lignes d'intégration écrites à la main, dont 28 disaient oui à
quelque chose de vrai pour tous les hôtes. Elles n'existaient que pour être
oubliées — et elles l'avaient été : dans Chezlepro, backup-01 et infra-pki-01
n'étaient ni supervisés, ni journalisés, ni certifiés.
Le rôle déclare désormais sa politique une fois, dans meta/integration.yml ; le
plan ne garde que les vrais choix et refuse la recopie. Les exemptions se
dérivent du service rendu (sauf_role), jamais d'un nom d'hôte : l'AC ne s'enrôle
pas auprès d'elle-même, et l'exemption suit step-ca si on le déplace.
Une seule fonction de résolution — integrations_de() — lue par l'inventaire, la
voûte et le panneau. Sans le passage par la voûte, les secrets des intégrations
universelles auraient cessé d'être exigés et P18 serait passé au vert sur une
voûte incomplète.
Vérifié : diff vide sur Technolibre (la politique reproduit exactement les 41
lignes retirées) ; sur Chezlepro, exactement les groupes manquants, et pas
client_pki sur infra-pki-01.
2. Vue Intégrations : la matrice
La fiche montrait les intégrations d'UN serveur ; le trou de Chezlepro n'a pas
été trouvé par le panneau mais par le devis de pare-feu. Matrice serveurs x
intégrations : colonnes de politique en lecture seule, facultatives cochables sur
place, ligne de couverture n/N qui rend le motif visible sans le juger.
3. Propriété des intrants (D-35/D-36, P27)
Le cluster Proxmox appartient à l'hébergeur, comme sa fabric et sa frontière.
Recopié chez chaque tenant, son inventaire avait déjà divergé : deux listes de
stockages contradictoires pour le même matériel. API/nœuds/stockages/ponts vont
dans proxmox-hebergeur.yml, à côté d'underlay.yml, dont le chemin se dérive —
l'hébergeur reste non déclaré (D-17). Restent au tenant son golden template et
ses défauts de placement.
Le panneau nomme désormais le propriétaire de chaque section : éditer une section
« hébergeur » vaut pour tous ses tenants, et l'écran ne le disait pas.
26 preuves OK, 0 échec. --syntax-check des deux playbooks Proxmox.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:09:22 -04:00
|
|
|
|
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).")
|
|
|
|
|
|
|
|
|
|
|
|
|
Adressage derive du seul seed index (rupture, mode compact retire)
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>
2026-07-23 02:58:15 -04:00
|
|
|
|
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."
|
|
|
|
|
|
|
|
|
|
|
|
|
Mise en conformité prouvable : registre d'affirmations + make prouver
Le dépôt fait / explique / prouve ce qu'il affirme, vérifiable en une commande.
- Phase 1 : docs/audit/affirmations.md — 54 affirmations publiques tracées vers
une commande de preuve et un statut (✅/🟡/❌/⚪).
- Phase 2 : CLAUDE.md réduit à un pointeur mince ; contradiction SSH levée (le
code applique déjà PasswordAuthentication no + AuthenticationMethods publickey,
conforme à AGENTS.md) ; section AGENTS « Codex » → « agents IA ».
- Phase 3 : parcours démarrage réparé (QUICKSTART renvoyait à un modèle absent,
chemins de voûte faux, commandes make périmées) ; make verifier vert
(ansible-lint 33 → 0 : site.yml généré nommé, pipefail, name[template]) ;
voûte Proxmox unifiée lue par le clonage (all/vault.yml).
- Phase 4 : make prouver → docs/audit/preuve-<date>.md, harnais rejouable qui
rappelle l'outillage existant (aucune validation réimplémentée).
- Phase 5 : parcours QUICKSTART prouvé hors-ligne sur le socle ; modèle socle
rendu valide (autorite interne → auto-heberge) ; split-brain d'inventaire
corrigé (repli sur le répertoire existant, pas principal/).
make prouver : 15 OK, 0 échec, 1 sautée (voûte). ansible-lint : 0 failure.
Écarts découverts en cours de traitement (AFF-097..100) : tous résolus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:53:18 -04:00
|
|
|
|
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."
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-20 20:47:36 -04:00
|
|
|
|
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)."
|
|
|
|
|
|
|
|
|
|
|
|
|
forgejo : apprendre SQLite, et retirer ce qui ne servait pas
Doute de l'exploitant sur patient 0 : « je doute de la pertinence de pgsql ». Mesure
plutot que discussion.
REDIS NE SERVAIT A RIEN : le role serveur_forgejo ne le mentionne ni dans son app.ini, ni
dans ses defauts, et ne declare aucun lien. Heritage du modele `forge`. Retire du plan.
POSTGRESQL ETAIT EXIGE PAR LE ROLE : DB_TYPE = postgres en dur, resoudre_base sans
condition. Le doute etait fonde, le moteur ne savait pas faire autrement.
INTERRUPTEUR `serveur_forgejo_bd: postgres|sqlite`. En sqlite la base devient un FICHIER
sous serveur_forgejo_data. Ce que ca change ailleurs : rien. Le job de sauvegarde
`serveur_forgejo` emporte deja ce dossier ; PGSSLROOTCERT etait deja conditionne au mode
TLS ; et P35 lit desormais l'interrupteur (convention `<role>_bd`, group_vars de
l'instance puis defaut du role), donc n'attend aucune entree de registre. Une valeur
inconnue est REFUSEE au debut du role plutot que de retomber en silence sur PostgreSQL.
PATIENT 0 PASSE DE SIX A QUATRE MACHINES (Dovecot, Redis, PostgreSQL et sa VM). Sur la
machine dont tout descend, chaque service en moins est une chose de moins a defendre, a
sauvegarder et a rebatir. Et l'effet depasse patient 0 : une offre `forge` pour un petit
organisme cesse d'exiger une VM PostgreSQL.
LA NEUVIEME. En verifiant P35 sur patient 0, elle a rendu un verdict JUSTE SUR LE MAUVAIS
ECOSYSTEME : `plan = RACINE / "instance" / "plan"`, le symlink en dur. Neuvieme resolution
d'instance codee en dur en cinq jours. Ce n'est plus une serie de bogues, c'est une piece
manquante : une resolution unique et partagee, a faire en une fois et de tete reposee.
Enseigne : SQLite au glossaire (P39 l'exige desormais), et le README du role documente
l'interrupteur et ce qu'il ne change pas.
make verifier 40/40 ; make ci 40/40 ; lint et syntaxe du role verts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:10:05 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
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.
|
|
|
|
|
|
"""
|
forgejo : apprendre SQLite, et retirer ce qui ne servait pas
Doute de l'exploitant sur patient 0 : « je doute de la pertinence de pgsql ». Mesure
plutot que discussion.
REDIS NE SERVAIT A RIEN : le role serveur_forgejo ne le mentionne ni dans son app.ini, ni
dans ses defauts, et ne declare aucun lien. Heritage du modele `forge`. Retire du plan.
POSTGRESQL ETAIT EXIGE PAR LE ROLE : DB_TYPE = postgres en dur, resoudre_base sans
condition. Le doute etait fonde, le moteur ne savait pas faire autrement.
INTERRUPTEUR `serveur_forgejo_bd: postgres|sqlite`. En sqlite la base devient un FICHIER
sous serveur_forgejo_data. Ce que ca change ailleurs : rien. Le job de sauvegarde
`serveur_forgejo` emporte deja ce dossier ; PGSSLROOTCERT etait deja conditionne au mode
TLS ; et P35 lit desormais l'interrupteur (convention `<role>_bd`, group_vars de
l'instance puis defaut du role), donc n'attend aucune entree de registre. Une valeur
inconnue est REFUSEE au debut du role plutot que de retomber en silence sur PostgreSQL.
PATIENT 0 PASSE DE SIX A QUATRE MACHINES (Dovecot, Redis, PostgreSQL et sa VM). Sur la
machine dont tout descend, chaque service en moins est une chose de moins a defendre, a
sauvegarder et a rebatir. Et l'effet depasse patient 0 : une offre `forge` pour un petit
organisme cesse d'exiger une VM PostgreSQL.
LA NEUVIEME. En verifiant P35 sur patient 0, elle a rendu un verdict JUSTE SUR LE MAUVAIS
ECOSYSTEME : `plan = RACINE / "instance" / "plan"`, le symlink en dur. Neuvieme resolution
d'instance codee en dur en cinq jours. Ce n'est plus une serie de bogues, c'est une piece
manquante : une resolution unique et partagee, a faire en une fois et de tete reposee.
Enseigne : SQLite au glossaire (P39 l'exige desormais), et le README du role documente
l'interrupteur et ce qu'il ne change pas.
make verifier 40/40 ; make ci 40/40 ; lint et syntaxe du role verts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:10:05 -04:00
|
|
|
|
# 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"
|
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
|
|
|
|
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()}
|
forgejo : apprendre SQLite, et retirer ce qui ne servait pas
Doute de l'exploitant sur patient 0 : « je doute de la pertinence de pgsql ». Mesure
plutot que discussion.
REDIS NE SERVAIT A RIEN : le role serveur_forgejo ne le mentionne ni dans son app.ini, ni
dans ses defauts, et ne declare aucun lien. Heritage du modele `forge`. Retire du plan.
POSTGRESQL ETAIT EXIGE PAR LE ROLE : DB_TYPE = postgres en dur, resoudre_base sans
condition. Le doute etait fonde, le moteur ne savait pas faire autrement.
INTERRUPTEUR `serveur_forgejo_bd: postgres|sqlite`. En sqlite la base devient un FICHIER
sous serveur_forgejo_data. Ce que ca change ailleurs : rien. Le job de sauvegarde
`serveur_forgejo` emporte deja ce dossier ; PGSSLROOTCERT etait deja conditionne au mode
TLS ; et P35 lit desormais l'interrupteur (convention `<role>_bd`, group_vars de
l'instance puis defaut du role), donc n'attend aucune entree de registre. Une valeur
inconnue est REFUSEE au debut du role plutot que de retomber en silence sur PostgreSQL.
PATIENT 0 PASSE DE SIX A QUATRE MACHINES (Dovecot, Redis, PostgreSQL et sa VM). Sur la
machine dont tout descend, chaque service en moins est une chose de moins a defendre, a
sauvegarder et a rebatir. Et l'effet depasse patient 0 : une offre `forge` pour un petit
organisme cesse d'exiger une VM PostgreSQL.
LA NEUVIEME. En verifiant P35 sur patient 0, elle a rendu un verdict JUSTE SUR LE MAUVAIS
ECOSYSTEME : `plan = RACINE / "instance" / "plan"`, le symlink en dur. Neuvieme resolution
d'instance codee en dur en cinq jours. Ce n'est plus une serie de bogues, c'est une piece
manquante : une resolution unique et partagee, a faire en une fois et de tete reposee.
Enseigne : SQLite au glossaire (P39 l'exige desormais), et le README du role documente
l'interrupteur et ce qu'il ne change pas.
make verifier 40/40 ; make ci 40/40 ; lint et syntaxe du role verts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:10:05 -04:00
|
|
|
|
# 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]
|
|
|
|
|
|
|
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
|
|
|
|
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).")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-12 17:21:09 -04:00
|
|
|
|
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
|
P03 : la preuve comparait chaque instance a l'inventaire d'UNE SEULE
Trouve en validant une mise a jour du CHANGELOG. Deux invocations de la meme preuve,
deux verdicts : `make prouver` -> NON CONFORME (« lab : 17 hotes avec ecart »),
`python3 scripts/prouver.py` -> CONFORME 37/37. Le lab n'avait aucun ecart.
DEUX VARIABLES DESIGNENT LA CIBLE, ET LA SECONDE GAGNE. Le Makefile exporte
SETOPS_INVENTAIRE (ligne 13), derive de l'instance ACTIVE ; instancier.py:68 lui fait
FORCER la cible par-dessus SETOPS_INSTANCE. P03 (prouver.py:505) ne redirigeait que
SETOPS_INSTANCE : elle generait le plan de CHAQUE instance federee et le comparait a
l'inventaire applique de la SEULE instance active.
LE ROUGE N'ETAIT PAS LE PROBLEME, LE VERT L'ETAIT. Sous `make`, l'inventaire applique
de lab et de Technolibre n'etait JAMAIS lu — l'angle meme pour lequel P03 a ete ecrite
le 2026-08-12 (un tenant qu'on ne regarde pas imposant ses vieilles adresses au pare-feu
partage). La preuve etait aveugle a son propre cas, par l'invocation documentee. Les
rapports du 13 et du 14 sortent de cette invocation-la. Signature visible sans lire le
code : les hosts.genere.yml de lab et de Technolibre ne bougeaient pas.
CORRECTIF, cinq sites : env.pop("SETOPS_INVENTAIRE") partout ou l'on redirige
SETOPS_INSTANCE — P03 et P15, plus les trois applicateurs (opnsense, proxmox_fw, sdn) qui
pointent vers l'HEBERGEUR. Ces trois sont sans effet tant qu'hebergeur et tenant actif
coincident, c'est-a-dire jusqu'au second site. Le geste existait deja (modeles.py:96).
GARDE, pour que la classe cesse d'etre silencieuse : inventory_rules.inventaire_force()
REFUSE une cible hors de l'instance visee, en nommant les deux valeurs. Eprouvee dans les
deux sens (contradiction -> code 1 ; cible legitime dans l'instance -> passe). Branchee
sur les quatre resolutions de _inventaire (instancier, serveurs, applications,
config_proxmox). Le GUI garde la sienne : il ne redirige jamais SETOPS_INSTANCE pour un
fils et resout par symlink a chaque requete.
make prouver : 37 OK, 0 echec, 0 saute — et les hosts.genere.yml des TROIS instances
portent l'horodatage du passage. make test 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:35:01 -04:00
|
|
|
|
# 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.
|
2026-08-12 17:21:09 -04:00
|
|
|
|
env = {**os.environ, "SETOPS_INSTANCE": str(chemin)}
|
P03 : la preuve comparait chaque instance a l'inventaire d'UNE SEULE
Trouve en validant une mise a jour du CHANGELOG. Deux invocations de la meme preuve,
deux verdicts : `make prouver` -> NON CONFORME (« lab : 17 hotes avec ecart »),
`python3 scripts/prouver.py` -> CONFORME 37/37. Le lab n'avait aucun ecart.
DEUX VARIABLES DESIGNENT LA CIBLE, ET LA SECONDE GAGNE. Le Makefile exporte
SETOPS_INVENTAIRE (ligne 13), derive de l'instance ACTIVE ; instancier.py:68 lui fait
FORCER la cible par-dessus SETOPS_INSTANCE. P03 (prouver.py:505) ne redirigeait que
SETOPS_INSTANCE : elle generait le plan de CHAQUE instance federee et le comparait a
l'inventaire applique de la SEULE instance active.
LE ROUGE N'ETAIT PAS LE PROBLEME, LE VERT L'ETAIT. Sous `make`, l'inventaire applique
de lab et de Technolibre n'etait JAMAIS lu — l'angle meme pour lequel P03 a ete ecrite
le 2026-08-12 (un tenant qu'on ne regarde pas imposant ses vieilles adresses au pare-feu
partage). La preuve etait aveugle a son propre cas, par l'invocation documentee. Les
rapports du 13 et du 14 sortent de cette invocation-la. Signature visible sans lire le
code : les hosts.genere.yml de lab et de Technolibre ne bougeaient pas.
CORRECTIF, cinq sites : env.pop("SETOPS_INVENTAIRE") partout ou l'on redirige
SETOPS_INSTANCE — P03 et P15, plus les trois applicateurs (opnsense, proxmox_fw, sdn) qui
pointent vers l'HEBERGEUR. Ces trois sont sans effet tant qu'hebergeur et tenant actif
coincident, c'est-a-dire jusqu'au second site. Le geste existait deja (modeles.py:96).
GARDE, pour que la classe cesse d'etre silencieuse : inventory_rules.inventaire_force()
REFUSE une cible hors de l'instance visee, en nommant les deux valeurs. Eprouvee dans les
deux sens (contradiction -> code 1 ; cible legitime dans l'instance -> passe). Branchee
sur les quatre resolutions de _inventaire (instancier, serveurs, applications,
config_proxmox). Le GUI garde la sienne : il ne redirige jamais SETOPS_INSTANCE pour un
fils et resout par symlink a chaque requete.
make prouver : 37 OK, 0 echec, 0 saute — et les hosts.genere.yml des TROIS instances
portent l'horodatage du passage. make test 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:35:01 -04:00
|
|
|
|
env.pop("SETOPS_INVENTAIRE", None)
|
CI : le harnais ne se declenchait que par memoire
Trente-huit preuves, des tests, un lint — et RIEN ne les executait sans qu'un humain tape
`make`. Le meilleur atout du depot dependait de ne pas oublier. Il a desormais une CI
(.forgejo/workflows/verifier.yml) et une cible qui la rejoue a l'identique : `make ci`.
CE QUE LA CI A TROUVE AVANT D'EXISTER. Ecrire le workflow supposait de repondre a une
question jamais posee : est-ce qu'un depot PUBLIC, seul, se tient ? Mesure sur un clone
nu : non, a cinq endroits.
- `make instancier` echouait sur le modele public — le tout premier geste du QUICKSTART.
Le Makefile forcait `principal/hosts.yml` alors que le modele vit en `production/` ; sa
precedence suit desormais celle du code (fichier, puis REPERTOIRE existant, puis defaut).
- P32 parcourait les 54 roles sans regarder ce que l'instance deploie. Elle passait sur
l'ecosysteme de reference PARCE QU'IL PORTE TOUT. Or les modeles sont des OFFRES : toute
offre plus petite que l'ecosysteme complet echouait son propre harnais, pour des services
qu'elle ne vend pas. Le perimetre se lit maintenant du plan (groupes de l'inventaire,
puis roles composes par leur playbook).
- P24 : le modele public ne declarait aucun reseau d'administration — une flotte qu'on
construit et ou l'on n'entre plus. `nftables_admin_ssh` est pose, avec le pourquoi.
- P33 : verifier_ports.py codait `instance/inventories/principal/hosts.yml` en dur.
- P32 et P24 lisaient le symlink `instance/` au lieu de SETOPS_INSTANCE.
Toutes de la MEME FAMILLE que P03 avant-hier : une resolution d'inventaire recopiee, une
variable d'environnement qui deborde de sa portee. Le depot en compte SEPT ; deux de plus
sont corrigees ici, et la septieme le dit en commentaire plutot que de le taire.
`make ci` NE TOUCHE AUCUN SYMLINK : le modele public est monte comme instance jetable,
vise par SETOPS_INSTANCE/SETOPS_UNDERLAY, detruit en sortant. Deux details mesures parce
que devines faux d'abord : l'instance jetable est un DOSSIER FRERE (la federation se
decouvre ainsi ; ailleurs, quatre preuves tombent) ; et SETOPS_UNDERLAY n'est pose QUE
pour la verification, sinon l'inventaire est ecrit avec une fabric et regenere avec une
autre — la commande fabriquait l'ecart qu'elle denonce.
RESULTAT : clone nu sans instance ni frere -> 38 OK, 0 echec, 0 saute. Depot de
l'exploitant avec ses 3 instances -> 38 OK, 0 echec, 0 saute. Aucun residu.
Et le lint du depot a refuse mon propre fichier de CI avant qu'il ne tourne une seule fois
(`on:` lu par YAML comme le booleen vrai). Le harnais mordait deja.
A AJUSTER AU PREMIER PASSAGE, ecrit en tete du workflow : l'etiquette `runs-on` doit
correspondre a un runner Forgejo enregistre, et le runner a besoin du reseau pour pip et
ansible-galaxy. Le vert de cette CI dira que le moteur et son modele public se tiennent —
pas que la flotte va bien : aucune VM jointe, aucune voute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 19:29:46 -04:00
|
|
|
|
# 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)
|
2026-08-12 17:21:09 -04:00
|
|
|
|
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.")
|
|
|
|
|
|
|
|
|
|
|
|
|
D-80 : un tenant est agnostique de son underlay, a trois cles pres
Formulation de l'exploitant, meilleure que celle du depot. Le commentaire disait
« la fabric reste celle de l'hebergeur, quel que soit le tenant actif » — vrai,
mais centre sur l'HEBERGEUR. Le cadrage juste est centre sur le TENANT : les deux
symlinks composent deux axes INDEPENDANTS (instance = quel tenant, underlay.yml =
sur quelle fabric).
Tout l'adressage derive du seed index : le plan se deplace d'une fabric a l'autre
sans y toucher. Ce qui ne se deplace pas tient en TROIS CLES —
proxmox_clone_noeud, _stockage, _pont. Elles vivent cote tenant (c'est lui qui
choisit ou se poser) mais nomment des objets de l'hebergeur. Trois, pas trente :
c'est ce qui separe « portable » de « theoriquement portable ».
P37 confronte ces trois noms aux listes de proxmox-hebergeur.yml, trouve par
derivation du symlink underlay.yml. Un nom absent est un ecart STATIQUE (D-75) —
sans quoi une faute de frappe ne se decouvre qu'au premier clone, apres quarante
minutes. Eprouvee en negatif sur les trois cles.
Non verifie et dit comme tel : le gabarit (_vmid_modele) est un objet du cluster,
pas une liste declaree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 09:47:38 -04:00
|
|
|
|
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.")
|
|
|
|
|
|
|
|
|
|
|
|
|
sauvegarde : le catalogue derive du groupe, et P36 le prouve
Correction : l'entree precedente attribuait le defaut a la reconstruction
from-zero. C'est faux — le commit fondateur 7476a54 (2026-07-03) disait lui-meme
« Reste : jobs Tier 1 (pg_dump/slapcat/vmail/forgejo) ». Ils n'ont jamais ete
ecrits, et infra-pki-01 a ensuite perdu son integration client_backup.
Le role qui POSSEDE la donnee dit comment la sortir : client_backup_jobs est
l'intersection du catalogue et des group_names du noeud. Un tenant qui deplace un
service emporte sa sauvegarde avec lui. On sauvegarde l'etat NON REGENERABLE :
ni zones PowerDNS ni tableaux Grafana, ils se redeploient.
L'unite qui ment est RETIREE, pas rendue bloquante : refuser le deploiement aurait
casse infra-edge-01, infra-dns-01 et mon-01, qui ne detiennent legitimement rien.
Le defaut etait le timer qui echouait chaque nuit en donnant l'apparence d'une
sauvegarde.
P36 (D-75) lit les groupes detenteurs dans le catalogue : ajouter un role au
catalogue etend la preuve du meme geste. Elle a attrape infra-pki-01 — les cles
de l'AC — corrige au plan.
Mesure hors-noeud : collab-01 64,0 MiB/272, edge-mta-01 4,4 MiB/139,
data-sql-01 1,0 MiB (pg_dumpall complet), forge-01 26,4 KiB/68,
infra-pki-01 20,1 KiB/21, idm-01 2,3 KiB/5 (slapcat). 9 hotes, 9 success.
Reste : rien ne surveille l'unite — c'est ce silence qui a laisse le defaut
vivre un mois.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:50:46 -04:00
|
|
|
|
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.
|
|
|
|
|
|
"""
|
resolution d'instance : une seule, partagee — au lieu de neuf copies
Cinq jours, cinq defauts, tous de la meme famille : « quelle instance, quel inventaire ? »
Neuf modules portaient chacun leur reponse.
- 18 aout : P03 comparait chaque instance a l'inventaire d'une AUTRE ;
- 19 aout : verifier_ports codait `principal/` en dur ; verifier_intrants et
_frontiere_absente lisaient le symlink au lieu de la variable ;
- 20 aout : devis_placement rendait un verdict juste sur le mauvais tenant ;
- 22 aout : P35, puis P36 — la dixieme, trouvee par la preuve elle-meme.
Aucune n'etait une faute d'inattention : chacune avait ete ecrite de bonne foi, a un
moment ou le besoin semblait local. C'est le mode de panne de la duplication — pas
l'erreur, mais la DERIVE, invisible depuis l'interieur d'un fichier.
LA RESOLUTION UNIQUE. `inventory_rules` porte instance_courante(), inventaire_de(),
dossier_inventaire() et plan_de(). Trois niveaux de repli, dont le TROISIEME manquait a la
moitie des copies : un hosts.yml existant, puis un REPERTOIRE existant (instance neuve —
c'est ce qui faisait echouer `make instancier` sur le modele public), puis le defaut.
Vingt-huit modules y sont branches.
CE QUI REND CE REFACTOR SUR : avant de toucher quoi que ce soit, chaque module a ete
interroge sur ce qu'il resolvait, pour les DEUX ecosystemes. Apres refactor, meme mesure :
17 modules x 2 instances, diff VIDE. Aucune resolution n'a change — prouve, pas suppose.
P41 echoue des qu'un module reintroduit une copie. Eprouvee en negatif : une copie
replacee dans genome.py est signalee avec son numero de ligne. Trois exemptions nommees :
instances.py et inventory_gui.py manipulent le SYMLINK lui-meme (bascule d'instance), et
devis_opnsense lit deliberement quelle instance est ACTIVE. Elles parlent du lien, pas de
la resolution.
make verifier 41 OK, 0 echec, 0 saute ; make ci idem ; lint vert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:19:55 -04:00
|
|
|
|
plan = plan_de()
|
sauvegarde : le catalogue derive du groupe, et P36 le prouve
Correction : l'entree precedente attribuait le defaut a la reconstruction
from-zero. C'est faux — le commit fondateur 7476a54 (2026-07-03) disait lui-meme
« Reste : jobs Tier 1 (pg_dump/slapcat/vmail/forgejo) ». Ils n'ont jamais ete
ecrits, et infra-pki-01 a ensuite perdu son integration client_backup.
Le role qui POSSEDE la donnee dit comment la sortir : client_backup_jobs est
l'intersection du catalogue et des group_names du noeud. Un tenant qui deplace un
service emporte sa sauvegarde avec lui. On sauvegarde l'etat NON REGENERABLE :
ni zones PowerDNS ni tableaux Grafana, ils se redeploient.
L'unite qui ment est RETIREE, pas rendue bloquante : refuser le deploiement aurait
casse infra-edge-01, infra-dns-01 et mon-01, qui ne detiennent legitimement rien.
Le defaut etait le timer qui echouait chaque nuit en donnant l'apparence d'une
sauvegarde.
P36 (D-75) lit les groupes detenteurs dans le catalogue : ajouter un role au
catalogue etend la preuve du meme geste. Elle a attrape infra-pki-01 — les cles
de l'AC — corrige au plan.
Mesure hors-noeud : collab-01 64,0 MiB/272, edge-mta-01 4,4 MiB/139,
data-sql-01 1,0 MiB (pg_dumpall complet), forge-01 26,4 KiB/68,
infra-pki-01 20,1 KiB/21, idm-01 2,3 KiB/5 (slapcat). 9 hotes, 9 success.
Reste : rien ne surveille l'unite — c'est ce silence qui a laisse le defaut
vivre un mois.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:50:46 -04:00
|
|
|
|
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).")
|
|
|
|
|
|
|
|
|
|
|
|
|
preuve : P34 — chaque document declare son lecteur (D-74)
La refonte de ce matin posait une convention. Une convention qu'on n'outille
pas tient tant que quelqu'un y pense : c'est le raisonnement de D-70, applique
au corpus documentaire.
Etat de depart mesure : 2 documents sur 34 declaraient leur lecteur. Les 32
autres disaient leur SUJET — ce qui avait enfoui le runbook de reprise le plus
utile du depot au §6 de autorisation.md.
Les 38 le declarent desormais, lecteur determine document par document et non
colle au gabarit : l'exploitant (devis, migration de tenant, cycle de vie,
gabarit d'or), le mainteneur (conceptions, registres, carte), le lecteur
externe (ecosysteme-chezlepro), l'agent IA (MISE-A-JOUR-CODEX-CLAUDE).
Deux exemptions DERIVEES, pas listees — un chemin en dur aurait vieilli a la
premiere page ajoutee : un document qui s'annonce genere, et un fragment sans
titre. Les 13 exemptes verifies un par un ; aucun document ecrit a la main
n'est exempte par accident. La preuve ne lit que l'EN-TETE, ce qui empeche
frontiere-opnsense.md et plan-et-generation.md — qui parlent de generation
dans leur corps — d'etre exemptes a tort.
Eprouvee dans les deux sens. Elle a echoue seule des sa premiere execution en
nommant deux documents que mon inventaire avait manques (docs/audit/). Puis
test negatif delibere : declaration retiree de meta-classe.md -> ECHEC la
nommant ; restauree -> OK.
Ce qu'elle ne teste pas : que le lecteur declare soit le BON. Ca se juge en
revue ; elle garantit qu'on a du y penser.
P01–P34. Comptes perimes corriges au passage (AGENTS.md et devis-services.md
annoncaient encore 30 preuves).
Verifie : prouver.py 0 (34 OK), plan-recette inchange.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 07:53:04 -04:00
|
|
|
|
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)).")
|
|
|
|
|
|
|
|
|
|
|
|
|
glossaire : le metier n'etait explique nulle part
Demande de l'exploitant apres une soiree passee a croiser « strophe FRR », VRF, VNet et
nexthop-vrf : cet ecosysteme doit rester pilotable par un humain, idealement un seul ;
que chaque notion sous-jacente soit ENSEIGNEE.
MESURE AVANT D'ECRIRE : 40 termes employes par le depot et absents du glossaire — LDAP
184 fois, playbook 165, underlay 106, EVPN 66, VRF 33, LMTP 25. Le glossaire expliquait le
vocabulaire propre a Set-OPS (plan, index, voute, zone) et laissait dehors tout ce qui
vient du metier. Or c'est le metier qui perd le lecteur.
CE N'EST PAS UN DEFAUT DE REDACTION. La regle fondatrice du depot est qu'un humain pilote
sans IA. Chaque mot obscur retire une personne a la liste de celles qui peuvent reprendre
le systeme : un vocabulaire non explique est un defaut de CONCEPTION.
- Glossaire reecrit : 67 termes groupes par famille (plan, machines, Ansible, reseau,
noms, confiance, identite, courriel, etat et preuve). Chaque entree dit ce que c'est ET
pourquoi ce depot s'en sert, avec renvoi vers l'unite qui developpe.
- Unite d'apprentissage manquante : « Le reseau des tenants ». Dix-sept des quarante
termes y vivaient sans domicile. Elle suit l'ordre ou les problemes se sont poses : deux
clients sur un cable -> VLAN -> ses deux limites -> encapsulation -> pourquoi 1450 ->
EVPN -> le VRF, qui n'est pas une interdiction mais une ignorance structurelle.
- Navigation : la nouvelle unite est au sidebar ; le plan de recette regenere (P22 l'a
exige des l'ajout de la page — le harnais a mordu).
P39 verifie : chaque terme du jargon a une entree ; chaque lien du glossaire mene a une
page existante ; chaque page du wiki est atteignable depuis la navigation.
LA LISTE EST DECLAREE, ET C'EST UN CHOIX MESURE. La derivation automatique a ete essayee :
153 acronymes dans le wiki et le README, dont la moitie sont des mots francais en
capitales (AUCUNE, AVANT, TOUS). Un controle qui exige une entree pour « AUCUNE » finit
desactive, et une preuve desactivee ne garde rien. La preuve dit elle-meme cet angle mort.
EPROUVEE EN NEGATIF contre le glossaire d'avant : 49 termes manquants, nommes un par un.
make verifier 39 OK, 0 echec, 0 saute ; make ci idem.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:15:55 -04:00
|
|
|
|
# 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
|
forgejo : apprendre SQLite, et retirer ce qui ne servait pas
Doute de l'exploitant sur patient 0 : « je doute de la pertinence de pgsql ». Mesure
plutot que discussion.
REDIS NE SERVAIT A RIEN : le role serveur_forgejo ne le mentionne ni dans son app.ini, ni
dans ses defauts, et ne declare aucun lien. Heritage du modele `forge`. Retire du plan.
POSTGRESQL ETAIT EXIGE PAR LE ROLE : DB_TYPE = postgres en dur, resoudre_base sans
condition. Le doute etait fonde, le moteur ne savait pas faire autrement.
INTERRUPTEUR `serveur_forgejo_bd: postgres|sqlite`. En sqlite la base devient un FICHIER
sous serveur_forgejo_data. Ce que ca change ailleurs : rien. Le job de sauvegarde
`serveur_forgejo` emporte deja ce dossier ; PGSSLROOTCERT etait deja conditionne au mode
TLS ; et P35 lit desormais l'interrupteur (convention `<role>_bd`, group_vars de
l'instance puis defaut du role), donc n'attend aucune entree de registre. Une valeur
inconnue est REFUSEE au debut du role plutot que de retomber en silence sur PostgreSQL.
PATIENT 0 PASSE DE SIX A QUATRE MACHINES (Dovecot, Redis, PostgreSQL et sa VM). Sur la
machine dont tout descend, chaque service en moins est une chose de moins a defendre, a
sauvegarder et a rebatir. Et l'effet depasse patient 0 : une offre `forge` pour un petit
organisme cesse d'exiger une VM PostgreSQL.
LA NEUVIEME. En verifiant P35 sur patient 0, elle a rendu un verdict JUSTE SUR LE MAUVAIS
ECOSYSTEME : `plan = RACINE / "instance" / "plan"`, le symlink en dur. Neuvieme resolution
d'instance codee en dur en cinq jours. Ce n'est plus une serie de bogues, c'est une piece
manquante : une resolution unique et partagee, a faire en une fois et de tete reposee.
Enseigne : SQLite au glossaire (P39 l'exige desormais), et le README du role documente
l'interrupteur et ce qu'il ne change pas.
make verifier 40/40 ; make ci 40/40 ; lint et syntaxe du role verts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:10:05 -04:00
|
|
|
|
"restic", "devis", "preuve", "harnais", "GUI", "index", "plan", "tenant", "SQLite",
|
2026-08-22 14:31:52 -04:00
|
|
|
|
"source unique", "resolution d'instance",
|
filiation : signer, inscrire la parente, et compter les temoins
L'exploitant : « j'ai une intuition : blockchain ». L'intuition visait le bon probleme —
une memoire partagee, verifiable, sans centre — mais la reponse etait deja dans git.
GIT EST DEJA UNE CHAINE DE HACHAGE : chaque commit porte l'empreinte de son parent, un
arbre de Merkle. Ce qui manquait n'etait pas la chaine mais l'AUTEUR : `user.name` est
declaratif, et toute la soiree du 20 des commits ont porte « Daniel Allaire » sans qu'aucune
preuve ne les lie a une cle (verifie : 8 commits, 0 signature, 0 etiquette).
POSE AUJOURD'HUI :
- signature par cle SSH (celle que la forge connait deja), etiquettes signees par defaut ;
premiere etiquette v2026.08.21, verifiee par `git verify-tag` ;
- `.git-allowed-signers` VERSIONNE : qui clone verifie sans rien demander a la forge, et
sans lui faire confiance. Retirer une ligne revoque pour la suite ; le passe signe reste
verifiable ;
- `scripts/genome.py` + trois cibles make : les QUATRE depots sans lesquels un ecosysteme
ne renait pas (moteur, instance, hebergeur, modeles), DERIVES et non declares ;
- `parente.yml` par ecosysteme : de quel moteur il descend, a quel commit, sous quelle
etiquette. Patient 0 descend de 742bcbf, etiquette v2026.08.21 ;
- P40 : la parente est inscrite, chaque depot se retrouve, chaque commit inscrit EXISTE
encore (une histoire reecrite se voit la), chacun porte un remote. Sautee proprement
quand l'instance n'est pas un depot git — le modele jetable de la CI.
POURQUOI PAS DE BLOCKCHAIN. Elle resout : qui ecrit ensuite, quand personne ne fait
confiance a personne et qu'il y a de l'argent en jeu. Aucun des trois ici. Et la
multiplicite qu'elle achete cher, la lignee la produit comme effet secondaire : chaque
enfant porte une copie du code dont il descend, donc reecrire l'histoire suppose de
convaincre TOUS les descendants. Le jour ou l'Alliance certifiera, ce sera un JOURNAL DE
TRANSPARENCE (Certificate Transparency, Sigstore), pas une chaine.
ENSEIGNE, pas seulement pose : nouvelle unite « Filiation, signatures et temoins » (moule
en quatre temps), onze termes au glossaire, et P39 les exige desormais.
make verifier 40 OK, 0 echec, 0 saute ; make ci idem.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:56:29 -04:00
|
|
|
|
# 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",
|
glossaire : le metier n'etait explique nulle part
Demande de l'exploitant apres une soiree passee a croiser « strophe FRR », VRF, VNet et
nexthop-vrf : cet ecosysteme doit rester pilotable par un humain, idealement un seul ;
que chaque notion sous-jacente soit ENSEIGNEE.
MESURE AVANT D'ECRIRE : 40 termes employes par le depot et absents du glossaire — LDAP
184 fois, playbook 165, underlay 106, EVPN 66, VRF 33, LMTP 25. Le glossaire expliquait le
vocabulaire propre a Set-OPS (plan, index, voute, zone) et laissait dehors tout ce qui
vient du metier. Or c'est le metier qui perd le lecteur.
CE N'EST PAS UN DEFAUT DE REDACTION. La regle fondatrice du depot est qu'un humain pilote
sans IA. Chaque mot obscur retire une personne a la liste de celles qui peuvent reprendre
le systeme : un vocabulaire non explique est un defaut de CONCEPTION.
- Glossaire reecrit : 67 termes groupes par famille (plan, machines, Ansible, reseau,
noms, confiance, identite, courriel, etat et preuve). Chaque entree dit ce que c'est ET
pourquoi ce depot s'en sert, avec renvoi vers l'unite qui developpe.
- Unite d'apprentissage manquante : « Le reseau des tenants ». Dix-sept des quarante
termes y vivaient sans domicile. Elle suit l'ordre ou les problemes se sont poses : deux
clients sur un cable -> VLAN -> ses deux limites -> encapsulation -> pourquoi 1450 ->
EVPN -> le VRF, qui n'est pas une interdiction mais une ignorance structurelle.
- Navigation : la nouvelle unite est au sidebar ; le plan de recette regenere (P22 l'a
exige des l'ajout de la page — le harnais a mordu).
P39 verifie : chaque terme du jargon a une entree ; chaque lien du glossaire mene a une
page existante ; chaque page du wiki est atteignable depuis la navigation.
LA LISTE EST DECLAREE, ET C'EST UN CHOIX MESURE. La derivation automatique a ete essayee :
153 acronymes dans le wiki et le README, dont la moitie sont des mots francais en
capitales (AUCUNE, AVANT, TOUS). Un controle qui exige une entree pour « AUCUNE » finit
desactive, et une preuve desactivee ne garde rien. La preuve dit elle-meme cet angle mort.
EPROUVEE EN NEGATIF contre le glossaire d'avant : 49 termes manquants, nommes un par un.
make verifier 39 OK, 0 echec, 0 saute ; make ci idem.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:15:55 -04:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
resolution d'instance : une seule, partagee — au lieu de neuf copies
Cinq jours, cinq defauts, tous de la meme famille : « quelle instance, quel inventaire ? »
Neuf modules portaient chacun leur reponse.
- 18 aout : P03 comparait chaque instance a l'inventaire d'une AUTRE ;
- 19 aout : verifier_ports codait `principal/` en dur ; verifier_intrants et
_frontiere_absente lisaient le symlink au lieu de la variable ;
- 20 aout : devis_placement rendait un verdict juste sur le mauvais tenant ;
- 22 aout : P35, puis P36 — la dixieme, trouvee par la preuve elle-meme.
Aucune n'etait une faute d'inattention : chacune avait ete ecrite de bonne foi, a un
moment ou le besoin semblait local. C'est le mode de panne de la duplication — pas
l'erreur, mais la DERIVE, invisible depuis l'interieur d'un fichier.
LA RESOLUTION UNIQUE. `inventory_rules` porte instance_courante(), inventaire_de(),
dossier_inventaire() et plan_de(). Trois niveaux de repli, dont le TROISIEME manquait a la
moitie des copies : un hosts.yml existant, puis un REPERTOIRE existant (instance neuve —
c'est ce qui faisait echouer `make instancier` sur le modele public), puis le defaut.
Vingt-huit modules y sont branches.
CE QUI REND CE REFACTOR SUR : avant de toucher quoi que ce soit, chaque module a ete
interroge sur ce qu'il resolvait, pour les DEUX ecosystemes. Apres refactor, meme mesure :
17 modules x 2 instances, diff VIDE. Aucune resolution n'a change — prouve, pas suppose.
P41 echoue des qu'un module reintroduit une copie. Eprouvee en negatif : une copie
replacee dans genome.py est signalee avec son numero de ligne. Trois exemptions nommees :
instances.py et inventory_gui.py manipulent le SYMLINK lui-meme (bascule d'instance), et
devis_opnsense lit deliberement quelle instance est ACTIVE. Elles parlent du lien, pas de
la resolution.
make verifier 41 OK, 0 echec, 0 saute ; make ci idem ; lint vert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:19:55 -04:00
|
|
|
|
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).")
|
|
|
|
|
|
|
|
|
|
|
|
|
serveur_ops : la difference entre une archive et une matrice
Un ecosysteme pouvait DETENIR son genome sans savoir l'executer. Le poste
d'exploitation porte Ansible epingle, le genome clone depuis SA PROPRE forge, et
une cle SSH qui n'appartient qu'a lui. Il n'emporte ni la voute ni son mot de
passe : la structure se reconstruit depuis la forge, les secrets depuis la
sauvegarde. Deux sources qu'un meme incident n'atteint pas ensemble.
Une machine neuve traverse tout le moteur sans rien heriter, et revele ce qu'un
etat anterieur masquait. Deux defauts silencieux en sont sortis.
setops_plan_dir pointait le lien `instance` en dur : neuf roles lisaient donc le
plan d'une AUTRE instance. Le plancher de patient 0 portait les FQDN de
Chezlepro, et son edge publiait server_name forge.chezlepro.internal. La
variable suit desormais l'inventaire reellement charge.
Trois instances sur quatre declaraient les SAN d'exposition de leur edge ; la
quatrieme et le modele public ne les avaient pas. La forge de patient 0 etait
donc publiee derriere le certificat auto-signe de Debian, sans que rien ne le
signale -- `git clone` fut le premier a refuser, a juste titre. P42 le reclame
maintenant pour tout ecosysteme qui declare un edge.
Le harnais a ecrit la moitie de ce role : 5 echecs sur la piece neuve, aucun
n'empechait le code de tourner, tous la rendaient invisible a la carte, au
graphe et au lecteur. 42 preuves, 0 echec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:58:10 -04:00
|
|
|
|
def preuve_edge_porte_ses_noms() -> tuple[bool, str]:
|
|
|
|
|
|
"""Tout ecosysteme qui PUBLIE des noms sert un certificat qui les porte.
|
|
|
|
|
|
|
|
|
|
|
|
POURQUOI CETTE PREUVE EXISTE (2026-08-23). L'edge de patient 0 publiait sa forge
|
|
|
|
|
|
derriere `ssl-cert-snakeoil.pem` — le certificat auto-signe de Debian. Le service
|
|
|
|
|
|
repondait, la page s'affichait apres un avertissement, `make prouver` etait vert :
|
|
|
|
|
|
rien, nulle part, ne signalait que la publication n'etait pas de confiance. Le premier
|
|
|
|
|
|
a refuser fut `git clone`, en voulant cloner le genome — et il avait raison.
|
|
|
|
|
|
|
|
|
|
|
|
LA CAUSE N'ETAIT PAS UN BUG, C'ETAIT UN OUBLI DE RECOPIE. Trois instances sur quatre
|
|
|
|
|
|
portaient `group_vars/serveur_nginx.yml` (les SAN d'exposition, le chemin du cert
|
|
|
|
|
|
step-ca, le rechargement de nginx). La quatrieme, plus recente, ne l'avait pas — et le
|
|
|
|
|
|
modele public dont descendent les prochaines ne l'avait pas non plus. Un cablage
|
|
|
|
|
|
recopie a la main finit toujours par manquer quelque part ; la seule parade est qu'une
|
|
|
|
|
|
preuve le reclame.
|
|
|
|
|
|
|
|
|
|
|
|
CE QU'ELLE TESTE, hors ligne, pour CHAQUE instance qui declare un groupe
|
|
|
|
|
|
`serveur_nginx` :
|
|
|
|
|
|
- le fichier `group_vars/serveur_nginx.yml` existe ;
|
|
|
|
|
|
- `client_pki_sans` y etend les SAN avec `sans_exposition` (les `expose:` du plan) ;
|
|
|
|
|
|
- `serveur_nginx_certificat` pointe vers les certificats de l'AC, pas ailleurs.
|
|
|
|
|
|
|
|
|
|
|
|
CE QU'ELLE NE TESTE PAS : ce que l'edge sert REELLEMENT. Cela demande de joindre la
|
|
|
|
|
|
machine — c'est le travail de `make certificats-plan`. Ici on verifie que l'ecosysteme
|
|
|
|
|
|
a DEMANDE la bonne chose.
|
|
|
|
|
|
"""
|
|
|
|
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
|
|
|
|
import instances as mod_instances
|
|
|
|
|
|
|
|
|
|
|
|
trouvees = mod_instances.decouvrir()
|
|
|
|
|
|
if not trouvees:
|
|
|
|
|
|
return True, "Aucune instance decouverte : rien a verifier."
|
|
|
|
|
|
|
|
|
|
|
|
fautes: list[str] = []
|
|
|
|
|
|
verifiees: list[str] = []
|
|
|
|
|
|
for i in trouvees:
|
|
|
|
|
|
base = RACINE.parent / i["nom"]
|
|
|
|
|
|
inventaires = sorted((base / "inventories").glob("*")) if (base / "inventories").is_dir() else []
|
|
|
|
|
|
for inv in inventaires:
|
|
|
|
|
|
if not inv.is_dir():
|
|
|
|
|
|
continue
|
|
|
|
|
|
hosts = inv / "hosts.yml"
|
|
|
|
|
|
if not hosts.exists():
|
|
|
|
|
|
continue
|
|
|
|
|
|
# Un edge n'est requis que si l'ecosysteme en declare un.
|
|
|
|
|
|
if "serveur_nginx" not in hosts.read_text(encoding="utf-8", errors="ignore"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
f = inv / "group_vars" / "serveur_nginx.yml"
|
|
|
|
|
|
nom = f"{i['nom']}/{inv.name}"
|
|
|
|
|
|
if not f.exists():
|
|
|
|
|
|
fautes.append(f"{nom} : aucun group_vars/serveur_nginx.yml — l'edge n'emettra "
|
|
|
|
|
|
f"pas de cert pour les noms qu'il publie")
|
|
|
|
|
|
continue
|
|
|
|
|
|
texte = f.read_text(encoding="utf-8", errors="ignore")
|
|
|
|
|
|
if "sans_exposition" not in texte:
|
|
|
|
|
|
fautes.append(f"{nom} : client_pki_sans n'etend pas les SAN avec `sans_exposition`")
|
|
|
|
|
|
if "/etc/step/certs/" not in texte:
|
|
|
|
|
|
fautes.append(f"{nom} : serveur_nginx_certificat ne pointe pas vers l'AC interne")
|
|
|
|
|
|
verifiees.append(nom)
|
|
|
|
|
|
|
|
|
|
|
|
if fautes:
|
|
|
|
|
|
return False, (f"{len(fautes)} ecosysteme(s) publient sans porter leurs noms : "
|
|
|
|
|
|
+ " | ".join(fautes[:4]) + ("…" if len(fautes) > 4 else ""))
|
|
|
|
|
|
if not verifiees:
|
|
|
|
|
|
return True, "Aucun ecosysteme ne declare d'edge : rien a publier."
|
|
|
|
|
|
return True, (f"{len(verifiees)} edge(s) emettent un certificat portant les noms publies "
|
|
|
|
|
|
f"({', '.join(verifiees)}).")
|
|
|
|
|
|
|
|
|
|
|
|
|
filiation : signer, inscrire la parente, et compter les temoins
L'exploitant : « j'ai une intuition : blockchain ». L'intuition visait le bon probleme —
une memoire partagee, verifiable, sans centre — mais la reponse etait deja dans git.
GIT EST DEJA UNE CHAINE DE HACHAGE : chaque commit porte l'empreinte de son parent, un
arbre de Merkle. Ce qui manquait n'etait pas la chaine mais l'AUTEUR : `user.name` est
declaratif, et toute la soiree du 20 des commits ont porte « Daniel Allaire » sans qu'aucune
preuve ne les lie a une cle (verifie : 8 commits, 0 signature, 0 etiquette).
POSE AUJOURD'HUI :
- signature par cle SSH (celle que la forge connait deja), etiquettes signees par defaut ;
premiere etiquette v2026.08.21, verifiee par `git verify-tag` ;
- `.git-allowed-signers` VERSIONNE : qui clone verifie sans rien demander a la forge, et
sans lui faire confiance. Retirer une ligne revoque pour la suite ; le passe signe reste
verifiable ;
- `scripts/genome.py` + trois cibles make : les QUATRE depots sans lesquels un ecosysteme
ne renait pas (moteur, instance, hebergeur, modeles), DERIVES et non declares ;
- `parente.yml` par ecosysteme : de quel moteur il descend, a quel commit, sous quelle
etiquette. Patient 0 descend de 742bcbf, etiquette v2026.08.21 ;
- P40 : la parente est inscrite, chaque depot se retrouve, chaque commit inscrit EXISTE
encore (une histoire reecrite se voit la), chacun porte un remote. Sautee proprement
quand l'instance n'est pas un depot git — le modele jetable de la CI.
POURQUOI PAS DE BLOCKCHAIN. Elle resout : qui ecrit ensuite, quand personne ne fait
confiance a personne et qu'il y a de l'argent en jeu. Aucun des trois ici. Et la
multiplicite qu'elle achete cher, la lignee la produit comme effet secondaire : chaque
enfant porte une copie du code dont il descend, donc reecrire l'histoire suppose de
convaincre TOUS les descendants. Le jour ou l'Alliance certifiera, ce sera un JOURNAL DE
TRANSPARENCE (Certificate Transparency, Sigstore), pas une chaine.
ENSEIGNE, pas seulement pose : nouvelle unite « Filiation, signatures et temoins » (moule
en quatre temps), onze termes au glossaire, et P39 les exige desormais.
make verifier 40 OK, 0 echec, 0 saute ; make ci idem.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:56:29 -04:00
|
|
|
|
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 "")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-25 14:11:39 -04:00
|
|
|
|
def preuve_devis_frontiere_du_site() -> tuple[bool, str]:
|
|
|
|
|
|
"""Le devis de la frontiere VOIT les machines du site — lues independamment.
|
|
|
|
|
|
|
|
|
|
|
|
POURQUOI CETTE PREUVE EXISTE (2026-08-25). En deplacant le plan du site hors de
|
|
|
|
|
|
`underlay.yml`, `underlay.machines()` s'est vide sans que `devis_opnsense` soit
|
|
|
|
|
|
rebranche. Le devis a cesse de voir le site et a propose de RETIRER 36 objets — tous
|
|
|
|
|
|
ses alias et toutes ses regles. La frontiere aurait laisse tomber la forge du genome,
|
|
|
|
|
|
le cache racine et le runner du site, d'un seul `CONFIRMER=true`.
|
|
|
|
|
|
|
|
|
|
|
|
Rien ne l'a signale : le harnais est reste vert, `ansible-lint` aussi. Seule la
|
|
|
|
|
|
lecture MANUELLE du plan avant application l'a attrape.
|
|
|
|
|
|
|
|
|
|
|
|
ELLE LIT LE PLAN ELLE-MEME, ET C'EST TOUT SON INTERET. Un premier jet passait par
|
|
|
|
|
|
`devis_opnsense._machines_du_plan_site()` — la fonction meme dont la panne etait a
|
|
|
|
|
|
detecter. Eprouve sur la regression reelle, il SE TAISAIT : les deux voyaient le vide,
|
|
|
|
|
|
et la preuve concluait « rien a prouver ». Une preuve qui partage la source de ce
|
|
|
|
|
|
qu'elle verifie ne verifie rien.
|
|
|
|
|
|
|
|
|
|
|
|
Elle se tait quand il n'y a pas de plan de site — un depot sans underlay monte n'a
|
|
|
|
|
|
rien a prouver ici — mais jamais quand le plan existe et que le devis l'ignore.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import ipaddress
|
|
|
|
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
|
|
|
|
import underlay as underlay_mod
|
|
|
|
|
|
import devis_opnsense as D
|
|
|
|
|
|
import devis_reseau
|
|
|
|
|
|
|
|
|
|
|
|
# --- LE TEMOIN : le plan, lu directement, sans passer par le devis ---------
|
|
|
|
|
|
chemin = underlay_mod.chemin()
|
|
|
|
|
|
plan = (chemin.resolve().parent / "plan") if chemin else None
|
|
|
|
|
|
f_srv = (plan / "serveurs.yml") if plan else None
|
|
|
|
|
|
if not f_srv or not f_srv.is_file():
|
|
|
|
|
|
return True, "Aucun plan de site (pas de `plan/serveurs.yml`) : rien a exiger."
|
|
|
|
|
|
serveurs = (yaml.safe_load(f_srv.read_text(encoding="utf-8")) or {}).get("serveurs") or {}
|
|
|
|
|
|
attendus = {n: s for n, s in serveurs.items()
|
|
|
|
|
|
if str(s.get("etat", "actif")) == "actif"}
|
|
|
|
|
|
if not attendus:
|
|
|
|
|
|
return True, "Plan de site sans machine active : rien a exiger."
|
|
|
|
|
|
|
|
|
|
|
|
f_app = plan / "applications.yml"
|
|
|
|
|
|
apps = ((yaml.safe_load(f_app.read_text(encoding="utf-8")) or {}).get("applications") or {}
|
|
|
|
|
|
) if f_app.is_file() else {}
|
|
|
|
|
|
roles_par_hote: dict[str, list[str]] = {}
|
|
|
|
|
|
for app in apps.values():
|
|
|
|
|
|
if app.get("hote") and app.get("groupe"):
|
|
|
|
|
|
roles_par_hote.setdefault(str(app["hote"]), []).append(str(app["groupe"]))
|
|
|
|
|
|
|
|
|
|
|
|
# --- CE QUE LE DEVIS PRODUIT, au meme perimetre que `make frontiere-plan` --
|
|
|
|
|
|
devis = D.construire(devis_reseau.decouvrir_du_site())
|
|
|
|
|
|
alias = devis.get("alias") or {}
|
|
|
|
|
|
regles = devis.get("regles") or []
|
|
|
|
|
|
manques: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
reseaux = [str(x) for x in ((alias.get("SETOPS_SITE") or {}).get("contenu") or [])]
|
|
|
|
|
|
if not reseaux:
|
|
|
|
|
|
manques.append(f"`SETOPS_SITE` est absent ou vide, alors que le plan declare "
|
|
|
|
|
|
f"{len(attendus)} machine(s) : le devis ne voit pas le site")
|
|
|
|
|
|
else:
|
|
|
|
|
|
for nom, srv in attendus.items():
|
|
|
|
|
|
if not any(ipaddress.ip_address(str(srv["ip"])) in
|
|
|
|
|
|
ipaddress.ip_network(r, strict=False) for r in reseaux):
|
|
|
|
|
|
manques.append(f"{nom} ({srv['ip']}) n'est dans aucun sous-reseau de "
|
|
|
|
|
|
f"`SETOPS_SITE`")
|
|
|
|
|
|
|
|
|
|
|
|
for hote, groupes in roles_par_hote.items():
|
|
|
|
|
|
if hote not in attendus:
|
|
|
|
|
|
continue
|
|
|
|
|
|
for g in groupes:
|
|
|
|
|
|
attendu = D.nom_alias(g, "SITE")
|
|
|
|
|
|
if attendu not in alias:
|
|
|
|
|
|
manques.append(f"{hote} porte `{g}` au plan, mais l'alias `{attendu}` "
|
|
|
|
|
|
f"n'est pas au devis")
|
|
|
|
|
|
|
|
|
|
|
|
n_site = sum(1 for r in regles if str(r.get("tenant")) == "SITE")
|
|
|
|
|
|
if not n_site:
|
|
|
|
|
|
manques.append("aucune regle du devis ne porte sur le site : la frontiere ne "
|
|
|
|
|
|
"laisserait rien passer vers ses machines")
|
|
|
|
|
|
|
|
|
|
|
|
if manques:
|
|
|
|
|
|
return False, ("Le devis de la frontiere ne voit pas le site :\n - "
|
|
|
|
|
|
+ "\n - ".join(manques))
|
|
|
|
|
|
return True, (f"Devis de la frontiere : {len(attendus)} machine(s) du plan retrouvees, "
|
|
|
|
|
|
f"{n_site} regle(s) du site.")
|
|
|
|
|
|
|
|
|
|
|
|
|
site : quatre zones d'autorite, et l'ordre inscrit dans les integrations
Le site n'est plus un /24 plat. Une zone par nature d'autorite — pilotage, autorite,
genome, service — chacune son VLAN et sa patte sur la frontiere. L'inversion corrigee,
mesuree : les cinq VM du site n'avaient AUCUN filtrage est-ouest, contre policy_in=DROP
sur une machine de tenant. La plus autoritaire etait la moins protegee.
Filtrage nord-sud par choix de l'exploitant : un seul point de police, un seul devis.
90 -> 117 regles. Chaque flux `flotte` produit une regle par zone SOURCE, destination
nommee — ce qui etait gratuit devient police.
L'ORDRE FAIT PARTIE DE L'INTEGRATION. client_pki tournait en parallele sur tous les hotes ;
sur celui qui porte l'autorite il recharge step-ca, et les quatre autres echouaient dans
cette fenetre sur `TLS handshake timeout` — un message qui accuse le reseau. Chaque
integration declare desormais sa dependance dans meta/integration.yml, les playbooks en
sont le miroir genere, et P44 refuse l'ecart (4 controles negatifs). `serveur: ~` est une
reponse valable : client_metrique pose un exportateur qu'on vient LIRE.
Autres defauts du meme soir :
- le plancher /etc/hosts venait APRES le premier apt, qui vise le cache par son NOM :
boucle fermee des que les adresses changent. Il ne s'installe pas, il rend installable.
- dns_amorcage ecrit en dur a eu tort deux fois ; il se derive de serveur_resolveur.
- l'ACL du resolveur derivait d'un seul sous-reseau : trois zones refusees sur quatre.
ET UNE ERREUR A MOI : j'ai diagnostique un trou noir de MTU et declare 1450. Faux — pas de
VXLAN sur ce chemin, tout est a 1500 de bout en bout. Ma mesure etait reelle, mon
interpretation non : je venais de debrancher la carte a chaud, et chaque `ip link set mtu`
reconfigurait l'interface. C'est la reconfiguration qui debloquait, pas la valeur.
44 preuves vertes, ansible-lint profil production.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:31:07 -04:00
|
|
|
|
def preuve_ordre_des_integrations() -> tuple[bool, str]:
|
|
|
|
|
|
"""Une integration applique SON SERVEUR avant ceux qui s'y adressent.
|
|
|
|
|
|
|
|
|
|
|
|
POURQUOI (2026-08-25). `client_pki` s'executait sur tous les hotes en parallele. Sur
|
|
|
|
|
|
celui qui porte l'autorite, le role reemet le certificat de `step-ca` et RECHARGE le
|
|
|
|
|
|
service ; les quatre autres l'interrogeaient dans cette fenetre et echouaient ensemble
|
|
|
|
|
|
sur `net/http: TLS handshake timeout` — un message qui accuse le reseau alors que la
|
|
|
|
|
|
cause est une course de dix secondes.
|
|
|
|
|
|
|
|
|
|
|
|
Ce n'est pas propre a la PKI. Toute integration a la meme forme : un `client_*`
|
|
|
|
|
|
s'adresse a un `serveur_*`, et l'ordre compte. Le cas le plus dangereux n'est pas le
|
|
|
|
|
|
certificat mais le RESOLVEUR — un hote bascule sur un resolveur pas encore pret
|
|
|
|
|
|
devient muet, et le runner qui devrait le reparer tombe avec lui.
|
|
|
|
|
|
|
|
|
|
|
|
La dependance se declare dans `roles/<client>/meta/integration.yml` (`serveur:`), et
|
|
|
|
|
|
le playbook de groupe en est le miroir. Cette preuve refuse l'ecart entre les deux :
|
|
|
|
|
|
sans elle, la declaration deviendrait un commentaire, et l'ordre une coincidence.
|
|
|
|
|
|
|
|
|
|
|
|
`serveur: ~` est une reponse valable — `client_metrique` pose un exportateur que
|
|
|
|
|
|
Prometheus vient LIRE, il n'attend personne. Toutes les integrations ne dependent pas
|
|
|
|
|
|
d'un service debout, et le dire explicitement vaut mieux que l'omettre.
|
|
|
|
|
|
"""
|
|
|
|
|
|
manques: list[str] = []
|
|
|
|
|
|
vus = 0
|
|
|
|
|
|
for meta in sorted((RACINE / "roles").glob("client_*/meta/integration.yml")):
|
|
|
|
|
|
role = meta.parent.parent.name
|
|
|
|
|
|
d = (yaml.safe_load(meta.read_text(encoding="utf-8")) or {}).get("integration") or {}
|
|
|
|
|
|
if "serveur" not in d:
|
|
|
|
|
|
manques.append(f"{role} : `meta/integration.yml` ne declare pas `serveur:` — "
|
|
|
|
|
|
f"on ne peut donc pas savoir si son ordre est correct "
|
|
|
|
|
|
f"(mettre `~` si l'integration n'attend aucun service)")
|
|
|
|
|
|
continue
|
|
|
|
|
|
srv = d.get("serveur")
|
|
|
|
|
|
pb = RACINE / "playbooks" / "groupes" / f"{role}.yml"
|
|
|
|
|
|
if not pb.is_file():
|
|
|
|
|
|
continue
|
|
|
|
|
|
texte = pb.read_text(encoding="utf-8")
|
|
|
|
|
|
if not srv:
|
|
|
|
|
|
continue # rien a ordonner, et c'est declare
|
|
|
|
|
|
vus += 1
|
|
|
|
|
|
if f"hosts: {role}:&{srv}" not in texte:
|
|
|
|
|
|
manques.append(f"{role} : declare dependre de `{srv}`, mais son playbook "
|
|
|
|
|
|
f"n'applique pas d'abord `{role}:&{srv}`")
|
|
|
|
|
|
if f"hosts: {role}:!{srv}" not in texte:
|
|
|
|
|
|
manques.append(f"{role} : son playbook n'exclut pas `{srv}` du second play — "
|
|
|
|
|
|
f"le serveur serait reconfigure deux fois, et l'ordre perdu")
|
|
|
|
|
|
if "any_errors_fatal: true" not in texte:
|
|
|
|
|
|
manques.append(f"{role} : sans `any_errors_fatal` sur le premier play, un "
|
|
|
|
|
|
f"serveur en echec laisse ses clients s'y raccrocher pour rien")
|
|
|
|
|
|
if manques:
|
|
|
|
|
|
return False, ("L'ordre des integrations n'est pas garanti :\n - "
|
|
|
|
|
|
+ "\n - ".join(manques))
|
|
|
|
|
|
return True, (f"{vus} integration(s) appliquent leur serveur avant leurs clients.")
|
|
|
|
|
|
|
|
|
|
|
|
|
proxmox : le pare-feu ne s'arme que dans le SDN — et 45e preuve
Le decoupage du site en quatre zones a revele un defaut qui dormait dans le
moteur. `firewall=1` sur l'interface d'une VM fait passer tout le trafic ponte
par conntrack. Sur un VNet SDN c'est sans consequence : en EVPN le routage
inter-VNet se fait dans le VRF, SUR LE NOEUD, et le flux ne quitte jamais
l'hyperviseur. Sur un pont classique route par une frontiere externe, deux VM du
MEME noeud dans deux VLAN differents ne se parlent qu'en EPINGLE : la trame sort
par le lien physique, la frontiere la route, elle revient sur le meme pont. La
meme table conntrack voit alors les deux moities de la connexion, classe le
retour INVALID, et PVEFW-FORWARD le jette.
La mesure a tranche :
ops(asgard) -> pki(asgard) 0/8 dns(gandalf) -> cache(gandalf) 0/8
ops(asgard) -> forge(vishnu) 6/8 dns(gandalf) -> pki(asgard) 6/8
ops(asgard) -> cache(gandalf) 8/8 dns(gandalf) -> forge(vishnu) 8/8
Toutes les paires intra-noeud echouent, toutes les paires inter-noeuds passent.
Douze tentatives faisaient monter le compteur `ctstate INVALID` de +112 sur
asgard et +116 sur gandalf ; `firewall=0` pose, il ne bouge plus — 0 sur les
deux, pour le meme trafic.
Le defaut se deguise en panne reseau : la poignee TCP ABOUTIT, et ce sont les
paquets de DONNEES qui disparaissent. L'AC le disait dans son propre journal
(`TLS handshake error ... i/o timeout` : elle accepte et attend un ClientHello
qui n'arrive jamais). Ecartes un par un, par la mesure : regles identiques champ
par champ, alias corrects, assignation des interfaces confirmee, ARP et routes
saines, IPS desactive, shaper vide, NAT source limite a `wan`, MTU a 1500 de
bout en bout — et la taille sans effet, 100 octets se perdant comme 1460.
L'INTENTION DECLAREE NE SUFFIT PAS : un tenant declare
`proxmox_clone_parefeu_interface: true` avec `proxmox_clone_pont: vmbr1` comme
valeur PAR DEFAUT, chaque hote la remplacant par son VNet derive. L'hote qui
retombe sur `vmbr1` naitrait arme sur un pont classique. `cloner_vm_debian.yml`
croise donc l'intention avec le pont REELLEMENT utilise, et le dit quand il
desarme — un desarmement muet serait le meme piege, en silence.
P45 EVALUE l'expression du playbook sur quatre cas plutot que d'en lire le
texte, dont un qui DOIT rendre vrai : sans lui, une expression constamment
fausse passerait la preuve sans rien garantir. Controle negatif verifie — le
drapeau remis a plat fait echouer la preuve.
Ce commit emporte aussi le desarmement d'`unattended-upgrades` dans
`common_packages`, jusqu'ici applique sur les machines mais pas versionne : le
verrou dpkg tenu 48 minutes par une vague de maj automatiques a coute deux
deploiements.
Le site a revele ce defaut parce qu'il a ete le premier a porter plusieurs
zones. Ce n'est pas une particularite du site : un tenant derive les siennes du
meme principe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:50:53 -04:00
|
|
|
|
def preuve_parefeu_proxmox_sdn_seulement() -> tuple[bool, str]:
|
|
|
|
|
|
"""Le pare-feu Proxmox ne s'arme que sur un VNet SDN, jamais sur un pont classique.
|
|
|
|
|
|
|
|
|
|
|
|
POURQUOI (2026-08-25). `firewall=1` fait passer tout le trafic ponte par conntrack.
|
|
|
|
|
|
Sur un VNet SDN c'est sans consequence : en EVPN le routage inter-VNet se fait dans le
|
|
|
|
|
|
VRF, SUR LE NOEUD, et le flux ne quitte jamais l'hyperviseur.
|
|
|
|
|
|
|
|
|
|
|
|
Sur un pont classique route par une frontiere externe, le meme reglage devient un
|
|
|
|
|
|
piege. Deux VM du meme noeud dans deux VLAN differents ne se parlent qu'en EPINGLE :
|
|
|
|
|
|
la trame sort par le lien physique, la frontiere la route, elle revient sur LE MEME
|
|
|
|
|
|
PONT. La meme table conntrack voit les deux moities de la connexion, classe le retour
|
|
|
|
|
|
`INVALID`, et `PVEFW-FORWARD` le jette.
|
|
|
|
|
|
|
|
|
|
|
|
Mesure : sur le site decoupe en quatre zones, les paires intra-noeud etaient a 0/8 et
|
|
|
|
|
|
toutes les paires inter-noeuds a 8/8 ; douze tentatives faisaient monter le compteur
|
|
|
|
|
|
`ctstate INVALID` de +112 sur asgard et +116 sur gandalf. `firewall=0` pose, il ne
|
|
|
|
|
|
bouge plus. Le defaut se deguise en panne reseau : la poignee TCP aboutit, et ce sont
|
|
|
|
|
|
les paquets de DONNEES qui disparaissent — on accuse le MTU, la frontiere, la zone.
|
|
|
|
|
|
|
|
|
|
|
|
L'INTENTION DECLAREE NE SUFFIT PAS : un tenant declare
|
|
|
|
|
|
`proxmox_clone_parefeu_interface: true` avec `proxmox_clone_pont: vmbr1` comme valeur
|
|
|
|
|
|
PAR DEFAUT, chaque hote la remplacant par son VNet derive. L'hote qui retombe sur
|
|
|
|
|
|
`vmbr1` naitrait arme sur un pont classique.
|
|
|
|
|
|
|
|
|
|
|
|
Cette preuve n'inspecte pas le texte du playbook : elle EVALUE l'expression qu'il
|
|
|
|
|
|
porte, sur quatre cas. Le cas « intention vraie sur un VNet » doit rendre VRAI —
|
|
|
|
|
|
sinon une expression constamment fausse passerait la preuve sans rien garantir.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import jinja2
|
|
|
|
|
|
|
|
|
|
|
|
pb = RACINE / "playbooks" / "proxmox" / "cloner_vm_debian.yml"
|
|
|
|
|
|
if not pb.is_file():
|
|
|
|
|
|
return False, f"{pb.relative_to(RACINE)} est introuvable."
|
|
|
|
|
|
lignes = [l for l in pb.read_text(encoding="utf-8").splitlines()
|
|
|
|
|
|
if re.match(r"\s*firewall:\s*", l)]
|
|
|
|
|
|
if len(lignes) != 1:
|
|
|
|
|
|
return False, (f"{pb.relative_to(RACINE)} porte {len(lignes)} ligne(s) "
|
|
|
|
|
|
f"`firewall:` — la preuve en attend exactement une, sinon elle ne "
|
|
|
|
|
|
f"sait pas laquelle gouverne la carte.")
|
|
|
|
|
|
expr = lignes[0].split("firewall:", 1)[1].strip().strip('"').strip("'")
|
|
|
|
|
|
|
|
|
|
|
|
# Jinja2 seul ne connait ni le test `match` ni le filtre `bool` — ils viennent
|
|
|
|
|
|
# d'Ansible. On les redonne a l'identique, sinon la preuve echouerait sur l'outil
|
|
|
|
|
|
# plutot que sur ce qu'elle mesure.
|
|
|
|
|
|
env = jinja2.Environment(undefined=jinja2.StrictUndefined)
|
|
|
|
|
|
env.tests["match"] = lambda v, motif: re.match(motif, str(v)) is not None
|
|
|
|
|
|
env.filters["bool"] = lambda v: (v if isinstance(v, bool)
|
|
|
|
|
|
else str(v).strip().lower() in ("true", "yes", "on", "1"))
|
|
|
|
|
|
|
|
|
|
|
|
cas = [ # (intention, pont, attendu, pourquoi)
|
|
|
|
|
|
(True, "t29serv", True, "un VNet SDN : le routage inter-VNet reste sur le noeud"),
|
|
|
|
|
|
(True, "vmbr3", False, "un pont classique : le retour en epingle serait jete"),
|
|
|
|
|
|
(True, "vmbr1", False, "le pont par defaut d'un tenant, si l'hote y retombe"),
|
|
|
|
|
|
(False, "t29serv", False, "l'ecosysteme ne demande pas de filtrage est-ouest"),
|
|
|
|
|
|
]
|
|
|
|
|
|
manques: list[str] = []
|
|
|
|
|
|
for intention, pont, attendu, pourquoi in cas:
|
|
|
|
|
|
try:
|
|
|
|
|
|
rendu = env.from_string(expr).render(
|
|
|
|
|
|
proxmox_clone_parefeu_interface=intention, proxmox_clone_pont=pont)
|
|
|
|
|
|
except jinja2.UndefinedError as e:
|
|
|
|
|
|
manques.append(f"intention={intention} pont={pont} : l'expression reclame "
|
|
|
|
|
|
f"une variable que la preuve ne fournit pas ({e})")
|
|
|
|
|
|
continue
|
|
|
|
|
|
obtenu = str(rendu).strip().lower() in ("true", "yes", "1")
|
|
|
|
|
|
if obtenu is not attendu:
|
|
|
|
|
|
manques.append(f"intention={intention} pont={pont} : rend {obtenu}, "
|
|
|
|
|
|
f"attendu {attendu} — {pourquoi}")
|
|
|
|
|
|
if manques:
|
|
|
|
|
|
return False, ("Le pare-feu Proxmox n'est pas correctement croise avec le pont :"
|
|
|
|
|
|
"\n - " + "\n - ".join(manques))
|
|
|
|
|
|
return True, ("Le pare-feu Proxmox ne s'arme que sur un VNet SDN "
|
|
|
|
|
|
f"({len(cas)} cas evalues, dont un qui doit rendre VRAI).")
|
|
|
|
|
|
|
|
|
|
|
|
|
site : quatre zones d'autorite, et l'ordre inscrit dans les integrations
Le site n'est plus un /24 plat. Une zone par nature d'autorite — pilotage, autorite,
genome, service — chacune son VLAN et sa patte sur la frontiere. L'inversion corrigee,
mesuree : les cinq VM du site n'avaient AUCUN filtrage est-ouest, contre policy_in=DROP
sur une machine de tenant. La plus autoritaire etait la moins protegee.
Filtrage nord-sud par choix de l'exploitant : un seul point de police, un seul devis.
90 -> 117 regles. Chaque flux `flotte` produit une regle par zone SOURCE, destination
nommee — ce qui etait gratuit devient police.
L'ORDRE FAIT PARTIE DE L'INTEGRATION. client_pki tournait en parallele sur tous les hotes ;
sur celui qui porte l'autorite il recharge step-ca, et les quatre autres echouaient dans
cette fenetre sur `TLS handshake timeout` — un message qui accuse le reseau. Chaque
integration declare desormais sa dependance dans meta/integration.yml, les playbooks en
sont le miroir genere, et P44 refuse l'ecart (4 controles negatifs). `serveur: ~` est une
reponse valable : client_metrique pose un exportateur qu'on vient LIRE.
Autres defauts du meme soir :
- le plancher /etc/hosts venait APRES le premier apt, qui vise le cache par son NOM :
boucle fermee des que les adresses changent. Il ne s'installe pas, il rend installable.
- dns_amorcage ecrit en dur a eu tort deux fois ; il se derive de serveur_resolveur.
- l'ACL du resolveur derivait d'un seul sous-reseau : trois zones refusees sur quatre.
ET UNE ERREUR A MOI : j'ai diagnostique un trou noir de MTU et declare 1450. Faux — pas de
VXLAN sur ce chemin, tout est a 1500 de bout en bout. Ma mesure etait reelle, mon
interpretation non : je venais de debrancher la carte a chaud, et chaque `ip link set mtu`
reconfigurait l'interface. C'est la reconfiguration qui debloquait, pas la valeur.
44 preuves vertes, ansible-lint profil production.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:31:07 -04:00
|
|
|
|
|
glossaire : le metier n'etait explique nulle part
Demande de l'exploitant apres une soiree passee a croiser « strophe FRR », VRF, VNet et
nexthop-vrf : cet ecosysteme doit rester pilotable par un humain, idealement un seul ;
que chaque notion sous-jacente soit ENSEIGNEE.
MESURE AVANT D'ECRIRE : 40 termes employes par le depot et absents du glossaire — LDAP
184 fois, playbook 165, underlay 106, EVPN 66, VRF 33, LMTP 25. Le glossaire expliquait le
vocabulaire propre a Set-OPS (plan, index, voute, zone) et laissait dehors tout ce qui
vient du metier. Or c'est le metier qui perd le lecteur.
CE N'EST PAS UN DEFAUT DE REDACTION. La regle fondatrice du depot est qu'un humain pilote
sans IA. Chaque mot obscur retire une personne a la liste de celles qui peuvent reprendre
le systeme : un vocabulaire non explique est un defaut de CONCEPTION.
- Glossaire reecrit : 67 termes groupes par famille (plan, machines, Ansible, reseau,
noms, confiance, identite, courriel, etat et preuve). Chaque entree dit ce que c'est ET
pourquoi ce depot s'en sert, avec renvoi vers l'unite qui developpe.
- Unite d'apprentissage manquante : « Le reseau des tenants ». Dix-sept des quarante
termes y vivaient sans domicile. Elle suit l'ordre ou les problemes se sont poses : deux
clients sur un cable -> VLAN -> ses deux limites -> encapsulation -> pourquoi 1450 ->
EVPN -> le VRF, qui n'est pas une interdiction mais une ignorance structurelle.
- Navigation : la nouvelle unite est au sidebar ; le plan de recette regenere (P22 l'a
exige des l'ajout de la page — le harnais a mordu).
P39 verifie : chaque terme du jargon a une entree ; chaque lien du glossaire mene a une
page existante ; chaque page du wiki est atteignable depuis la navigation.
LA LISTE EST DECLAREE, ET C'EST UN CHOIX MESURE. La derivation automatique a ete essayee :
153 acronymes dans le wiki et le README, dont la moitie sont des mots francais en
capitales (AUCUNE, AVANT, TOUS). Un controle qui exige une entree pour « AUCUNE » finit
desactive, et une preuve desactivee ne garde rien. La preuve dit elle-meme cet angle mort.
EPROUVEE EN NEGATIF contre le glossaire d'avant : 49 termes manquants, nommes un par un.
make verifier 39 OK, 0 echec, 0 saute ; make ci idem.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:15:55 -04:00
|
|
|
|
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.")
|
|
|
|
|
|
|
|
|
|
|
|
|
catalogue : la carte des services avait quatre mois de retard sur le moteur
`catalogue-services.md` est le document qu'on lit pour savoir ce que Set-OPS FAIT :
l'hebergeur d'un second site, un futur client, un mainteneur qui arrive. Verifie role par
role contre roles/, voici ce qu'il disait de faux.
- « Capacites futures encore a implementer : collaboration (Nextcloud/Collabora) et couche
web (frontal/dorsal) » — les quatre roles existent, collab-01, web-frontal-01 et
web-dorsal-01 sont ACTIFS, et les deux roles web sont codifies depuis les spikes du
2026-07-05.
- « La federation LDAP n'est pas automatisee dans le role ; Keycloak n'est pas expose » —
serveur_keycloak/tasks/federation-ldap.yml existe, et le plan declare
`expose: auth.<domaine>`.
- `infra-mail-01` : « Sendmail MTA » — c'est Dovecot ; Sendmail est retire depuis le
2026-07-04. La table des hotes datait d'avant la separation edge-mta / mailstore.
- `client_supervision` annonce comme integration — n'a JAMAIS eu ni role ni playbook. La
supervision ne pose rien sur les hotes : controles actifs depuis le coeur, resultats
passifs pousses par l'API (c'est backup-01 qui rapporte l'etat de ses depots).
- Une colonne « Role » decorative inventait des noms (`nextcloud`, `client_metriques`) : le
role porte le nom du GROUPE. Colonne retiree.
Et NEUF roles vivants ne figuraient dans aucune table — le socle, toute la pile courriel,
les sauvegardes, Icinga Web 2, oauth2-proxy, Unbound. Deux (`serveur_backup`,
`client_backup`) n'etaient nommes NULLE PART.
P38 — CE QUE P31 NE POUVAIT PAS VOIR. P31 verifie que tout est nomme et atteignable, pas
qu'un document dise vrai : une carte peut etre complete et perimee. P38 confronte le
catalogue au code dans les deux sens, et c'est la TABLE qui fait foi des deux cotes : tout
role figure dans une ligne de table (la prose ne suffit pas — la pile courriel y etait
racontee et introuvable pour qui lit un index), et tout groupe cite en table existe
reellement (role, ou playbook de groupe pour `serveur_durci`, qui en compose onze).
Deux exemptions nommees : la prose peut citer les roles RETIRES, sinon on ne peut plus
ecrire d'ou l'on vient ; et P38 ne juge pas si une description est JUSTE — cela se revoit
contre le CHANGELOG, le mecaniser serait se mentir.
EPROUVEE EN NEGATIF : rejouee contre la version d'avant, elle echoue en nommant les neuf
roles absents et les trois cases fantomes.
Le catalogue dit aussi desormais ou il s'arrete : la reconstruction prouve qu'une machine
nue atteint l'etat voulu, pas la tenue sous charge ; et l'usage reel de Nextcloud n'est pas
consigne comme preuve. Lacune nommee au passage : la dependance causale
serveur_web_frontal -> serveur_nginx n'est toujours pas declaree.
make prouver : 38 OK, 0 echec, 0 saute. make test inchange.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:07:59 -04:00
|
|
|
|
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.")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-08 13:21:23 -04:00
|
|
|
|
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 ""))
|
|
|
|
|
|
|
2026-08-11 00:52:21 -04:00
|
|
|
|
# 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")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-08 13:21:23 -04:00
|
|
|
|
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
|
2026-08-11 00:52:21 -04:00
|
|
|
|
if f.name in ansible_yaml: # invoque par un role ou un playbook
|
|
|
|
|
|
continue
|
2026-08-08 13:21:23 -04:00
|
|
|
|
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()
|
2026-08-08 14:34:00 -04:00
|
|
|
|
# `[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
|
2026-08-08 13:21:23 -04:00
|
|
|
|
]
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-08-08 14:34:00 -04:00
|
|
|
|
n_c = len([l for l in makefile.splitlines() if re.match(r"^[A-Za-z][A-Za-z0-9_-]*:", l)])
|
2026-08-08 13:21:23 -04:00
|
|
|
|
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.")
|
|
|
|
|
|
|
|
|
|
|
|
|
Mise en conformité prouvable : registre d'affirmations + make prouver
Le dépôt fait / explique / prouve ce qu'il affirme, vérifiable en une commande.
- Phase 1 : docs/audit/affirmations.md — 54 affirmations publiques tracées vers
une commande de preuve et un statut (✅/🟡/❌/⚪).
- Phase 2 : CLAUDE.md réduit à un pointeur mince ; contradiction SSH levée (le
code applique déjà PasswordAuthentication no + AuthenticationMethods publickey,
conforme à AGENTS.md) ; section AGENTS « Codex » → « agents IA ».
- Phase 3 : parcours démarrage réparé (QUICKSTART renvoyait à un modèle absent,
chemins de voûte faux, commandes make périmées) ; make verifier vert
(ansible-lint 33 → 0 : site.yml généré nommé, pipefail, name[template]) ;
voûte Proxmox unifiée lue par le clonage (all/vault.yml).
- Phase 4 : make prouver → docs/audit/preuve-<date>.md, harnais rejouable qui
rappelle l'outillage existant (aucune validation réimplémentée).
- Phase 5 : parcours QUICKSTART prouvé hors-ligne sur le socle ; modèle socle
rendu valide (autorite interne → auto-heberge) ; split-brain d'inventaire
corrigé (repli sur le répertoire existant, pas principal/).
make prouver : 15 OK, 0 échec, 1 sautée (voûte). ansible-lint : 0 failure.
Écarts découverts en cours de traitement (AFF-097..100) : tous résolus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:53:18 -04:00
|
|
|
|
# --- 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": [],
|
2026-08-08 14:22:04 -04:00
|
|
|
|
"cmds": [[sys.executable, "scripts/tests/test_inventory_host.py"],
|
|
|
|
|
|
[sys.executable, "scripts/tests/test_raser.py"]]},
|
2026-08-12 17:21:09 -04:00
|
|
|
|
{"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},
|
Mise en conformité prouvable : registre d'affirmations + make prouver
Le dépôt fait / explique / prouve ce qu'il affirme, vérifiable en une commande.
- Phase 1 : docs/audit/affirmations.md — 54 affirmations publiques tracées vers
une commande de preuve et un statut (✅/🟡/❌/⚪).
- Phase 2 : CLAUDE.md réduit à un pointeur mince ; contradiction SSH levée (le
code applique déjà PasswordAuthentication no + AuthenticationMethods publickey,
conforme à AGENTS.md) ; section AGENTS « Codex » → « agents IA ».
- Phase 3 : parcours démarrage réparé (QUICKSTART renvoyait à un modèle absent,
chemins de voûte faux, commandes make périmées) ; make verifier vert
(ansible-lint 33 → 0 : site.yml généré nommé, pipefail, name[template]) ;
voûte Proxmox unifiée lue par le clonage (all/vault.yml).
- Phase 4 : make prouver → docs/audit/preuve-<date>.md, harnais rejouable qui
rappelle l'outillage existant (aucune validation réimplémentée).
- Phase 5 : parcours QUICKSTART prouvé hors-ligne sur le socle ; modèle socle
rendu valide (autorite interne → auto-heberge) ; split-brain d'inventaire
corrigé (repli sur le répertoire existant, pas principal/).
make prouver : 15 OK, 0 échec, 1 sautée (voûte). ansible-lint : 0 failure.
Écarts découverts en cours de traitement (AFF-097..100) : tous résolus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:53:18 -04:00
|
|
|
|
{"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]]},
|
audit : les preuves réseau étaient accrochées à la mauvaise affirmation
P21, P23 et P24 renvoyaient à AFF-001 — « Set-OPS est un moteur Ansible
générique » — sans rapport avec la fédération, l'underlay ni la frontière.
P17, P19 et P20 n'avaient aucune référence. Une preuve accrochée à la
mauvaise affirmation passe au vert et n'atteste de rien de ce qu'on croit.
Ajouté §10 du registre : six affirmations (AFF-101..106) pour l'architecture
réseau et la fédération. La couverture du plan par le panneau est en 🟡, avec
ses exceptions nommées — listes de tables de l'underlay, ports physiques,
nœud de sortie.
Volontairement absente : la justesse des devis. Leur syntaxe dépend d'un
matériel que le dépôt ne possède pas ; six familles ont été confrontées au
commutateur réel, deux étaient fausses, mais c'est une vérification datée et
non une preuve rejouable. Le dépôt n'affirme pas que ses devis s'appliquent,
il affirme qu'ils dérivent.
Corrigé aussi : P03, P06, P12 et P13 portent maintenant les références que la
table leur attribuait déjà — la correspondance existait en double et seul le
document la tenait. Et la table attribuait AFF-030 (« inventaire complet ») à
P15, qui valide le modèle socle ; c'est P16 qui exécute
`ansible-inventory --list`.
35 affirmations référencées, aucune référence orpheline.
Preuves : 24 OK, 0 échec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 09:36:30 -04:00
|
|
|
|
{"id": "P06", "titre": "Validateurs de registres (serveurs/apps/bases/domaines)", "refs": ["AFF-003"],
|
Mise en conformité prouvable : registre d'affirmations + make prouver
Le dépôt fait / explique / prouve ce qu'il affirme, vérifiable en une commande.
- Phase 1 : docs/audit/affirmations.md — 54 affirmations publiques tracées vers
une commande de preuve et un statut (✅/🟡/❌/⚪).
- Phase 2 : CLAUDE.md réduit à un pointeur mince ; contradiction SSH levée (le
code applique déjà PasswordAuthentication no + AuthenticationMethods publickey,
conforme à AGENTS.md) ; section AGENTS « Codex » → « agents IA ».
- Phase 3 : parcours démarrage réparé (QUICKSTART renvoyait à un modèle absent,
chemins de voûte faux, commandes make périmées) ; make verifier vert
(ansible-lint 33 → 0 : site.yml généré nommé, pipefail, name[template]) ;
voûte Proxmox unifiée lue par le clonage (all/vault.yml).
- Phase 4 : make prouver → docs/audit/preuve-<date>.md, harnais rejouable qui
rappelle l'outillage existant (aucune validation réimplémentée).
- Phase 5 : parcours QUICKSTART prouvé hors-ligne sur le socle ; modèle socle
rendu valide (autorite interne → auto-heberge) ; split-brain d'inventaire
corrigé (repli sur le répertoire existant, pas principal/).
make prouver : 15 OK, 0 échec, 1 sautée (voûte). ansible-lint : 0 failure.
Écarts découverts en cours de traitement (AFF-097..100) : tous résolus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:53:18 -04:00
|
|
|
|
"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"]]},
|
audit : les preuves réseau étaient accrochées à la mauvaise affirmation
P21, P23 et P24 renvoyaient à AFF-001 — « Set-OPS est un moteur Ansible
générique » — sans rapport avec la fédération, l'underlay ni la frontière.
P17, P19 et P20 n'avaient aucune référence. Une preuve accrochée à la
mauvaise affirmation passe au vert et n'atteste de rien de ce qu'on croit.
Ajouté §10 du registre : six affirmations (AFF-101..106) pour l'architecture
réseau et la fédération. La couverture du plan par le panneau est en 🟡, avec
ses exceptions nommées — listes de tables de l'underlay, ports physiques,
nœud de sortie.
Volontairement absente : la justesse des devis. Leur syntaxe dépend d'un
matériel que le dépôt ne possède pas ; six familles ont été confrontées au
commutateur réel, deux étaient fausses, mais c'est une vérification datée et
non une preuve rejouable. Le dépôt n'affirme pas que ses devis s'appliquent,
il affirme qu'ils dérivent.
Corrigé aussi : P03, P06, P12 et P13 portent maintenant les références que la
table leur attribuait déjà — la correspondance existait en double et seul le
document la tenait. Et la table attribuait AFF-030 (« inventaire complet ») à
P15, qui valide le modèle socle ; c'est P16 qui exécute
`ansible-inventory --list`.
35 affirmations référencées, aucune référence orpheline.
Preuves : 24 OK, 0 échec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 09:36:30 -04:00
|
|
|
|
{"id": "P12", "titre": "Existence des runbooks cites", "refs": ["AFF-010", "AFF-011", "AFF-012"],
|
Mise en conformité prouvable : registre d'affirmations + make prouver
Le dépôt fait / explique / prouve ce qu'il affirme, vérifiable en une commande.
- Phase 1 : docs/audit/affirmations.md — 54 affirmations publiques tracées vers
une commande de preuve et un statut (✅/🟡/❌/⚪).
- Phase 2 : CLAUDE.md réduit à un pointeur mince ; contradiction SSH levée (le
code applique déjà PasswordAuthentication no + AuthenticationMethods publickey,
conforme à AGENTS.md) ; section AGENTS « Codex » → « agents IA ».
- Phase 3 : parcours démarrage réparé (QUICKSTART renvoyait à un modèle absent,
chemins de voûte faux, commandes make périmées) ; make verifier vert
(ansible-lint 33 → 0 : site.yml généré nommé, pipefail, name[template]) ;
voûte Proxmox unifiée lue par le clonage (all/vault.yml).
- Phase 4 : make prouver → docs/audit/preuve-<date>.md, harnais rejouable qui
rappelle l'outillage existant (aucune validation réimplémentée).
- Phase 5 : parcours QUICKSTART prouvé hors-ligne sur le socle ; modèle socle
rendu valide (autorite interne → auto-heberge) ; split-brain d'inventaire
corrigé (repli sur le répertoire existant, pas principal/).
make prouver : 15 OK, 0 échec, 1 sautée (voûte). ansible-lint : 0 failure.
Écarts découverts en cours de traitement (AFF-097..100) : tous résolus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:53:18 -04:00
|
|
|
|
"refs": ["AFF-010", "AFF-011", "AFF-012", "AFF-083"], "func": preuve_runbooks},
|
audit : les preuves réseau étaient accrochées à la mauvaise affirmation
P21, P23 et P24 renvoyaient à AFF-001 — « Set-OPS est un moteur Ansible
générique » — sans rapport avec la fédération, l'underlay ni la frontière.
P17, P19 et P20 n'avaient aucune référence. Une preuve accrochée à la
mauvaise affirmation passe au vert et n'atteste de rien de ce qu'on croit.
Ajouté §10 du registre : six affirmations (AFF-101..106) pour l'architecture
réseau et la fédération. La couverture du plan par le panneau est en 🟡, avec
ses exceptions nommées — listes de tables de l'underlay, ports physiques,
nœud de sortie.
Volontairement absente : la justesse des devis. Leur syntaxe dépend d'un
matériel que le dépôt ne possède pas ; six familles ont été confrontées au
commutateur réel, deux étaient fausses, mais c'est une vérification datée et
non une preuve rejouable. Le dépôt n'affirme pas que ses devis s'appliquent,
il affirme qu'ils dérivent.
Corrigé aussi : P03, P06, P12 et P13 portent maintenant les références que la
table leur attribuait déjà — la correspondance existait en double et seul le
document la tenait. Et la table attribuait AFF-030 (« inventaire complet ») à
P15, qui valide le modèle socle ; c'est P16 qui exécute
`ansible-inventory --list`.
35 affirmations référencées, aucune référence orpheline.
Preuves : 24 OK, 0 échec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 09:36:30 -04:00
|
|
|
|
{"id": "P13", "titre": "Invariants structurels/doctrinaux", "refs": ["AFF-015", "AFF-022", "AFF-037", "AFF-038", "AFF-062"],
|
Mise en conformité prouvable : registre d'affirmations + make prouver
Le dépôt fait / explique / prouve ce qu'il affirme, vérifiable en une commande.
- Phase 1 : docs/audit/affirmations.md — 54 affirmations publiques tracées vers
une commande de preuve et un statut (✅/🟡/❌/⚪).
- Phase 2 : CLAUDE.md réduit à un pointeur mince ; contradiction SSH levée (le
code applique déjà PasswordAuthentication no + AuthenticationMethods publickey,
conforme à AGENTS.md) ; section AGENTS « Codex » → « agents IA ».
- Phase 3 : parcours démarrage réparé (QUICKSTART renvoyait à un modèle absent,
chemins de voûte faux, commandes make périmées) ; make verifier vert
(ansible-lint 33 → 0 : site.yml généré nommé, pipefail, name[template]) ;
voûte Proxmox unifiée lue par le clonage (all/vault.yml).
- Phase 4 : make prouver → docs/audit/preuve-<date>.md, harnais rejouable qui
rappelle l'outillage existant (aucune validation réimplémentée).
- Phase 5 : parcours QUICKSTART prouvé hors-ligne sur le socle ; modèle socle
rendu valide (autorite interne → auto-heberge) ; split-brain d'inventaire
corrigé (repli sur le répertoire existant, pas principal/).
make prouver : 15 OK, 0 échec, 1 sautée (voûte). ansible-lint : 0 failure.
Écarts découverts en cours de traitement (AFF-097..100) : tous résolus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:53:18 -04:00
|
|
|
|
"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},
|
audit : les preuves réseau étaient accrochées à la mauvaise affirmation
P21, P23 et P24 renvoyaient à AFF-001 — « Set-OPS est un moteur Ansible
générique » — sans rapport avec la fédération, l'underlay ni la frontière.
P17, P19 et P20 n'avaient aucune référence. Une preuve accrochée à la
mauvaise affirmation passe au vert et n'atteste de rien de ce qu'on croit.
Ajouté §10 du registre : six affirmations (AFF-101..106) pour l'architecture
réseau et la fédération. La couverture du plan par le panneau est en 🟡, avec
ses exceptions nommées — listes de tables de l'underlay, ports physiques,
nœud de sortie.
Volontairement absente : la justesse des devis. Leur syntaxe dépend d'un
matériel que le dépôt ne possède pas ; six familles ont été confrontées au
commutateur réel, deux étaient fausses, mais c'est une vérification datée et
non une preuve rejouable. Le dépôt n'affirme pas que ses devis s'appliquent,
il affirme qu'ils dérivent.
Corrigé aussi : P03, P06, P12 et P13 portent maintenant les références que la
table leur attribuait déjà — la correspondance existait en double et seul le
document la tenait. Et la table attribuait AFF-030 (« inventaire complet ») à
P15, qui valide le modèle socle ; c'est P16 qui exécute
`ansible-inventory --list`.
35 affirmations référencées, aucune référence orpheline.
Preuves : 24 OK, 0 échec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 09:36:30 -04:00
|
|
|
|
{"id": "P17", "titre": "Tous les modeles valident (registres + underlay)", "refs": ["AFF-105"],
|
2026-07-22 21:32:42 -04:00
|
|
|
|
"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"]]},
|
audit : les preuves réseau étaient accrochées à la mauvaise affirmation
P21, P23 et P24 renvoyaient à AFF-001 — « Set-OPS est un moteur Ansible
générique » — sans rapport avec la fédération, l'underlay ni la frontière.
P17, P19 et P20 n'avaient aucune référence. Une preuve accrochée à la
mauvaise affirmation passe au vert et n'atteste de rien de ce qu'on croit.
Ajouté §10 du registre : six affirmations (AFF-101..106) pour l'architecture
réseau et la fédération. La couverture du plan par le panneau est en 🟡, avec
ses exceptions nommées — listes de tables de l'underlay, ports physiques,
nœud de sortie.
Volontairement absente : la justesse des devis. Leur syntaxe dépend d'un
matériel que le dépôt ne possède pas ; six familles ont été confrontées au
commutateur réel, deux étaient fausses, mais c'est une vérification datée et
non une preuve rejouable. Le dépôt n'affirme pas que ses devis s'appliquent,
il affirme qu'ils dérivent.
Corrigé aussi : P03, P06, P12 et P13 portent maintenant les références que la
table leur attribuait déjà — la correspondance existait en double et seul le
document la tenait. Et la table attribuait AFF-030 (« inventaire complet ») à
P15, qui valide le modèle socle ; c'est P16 qui exécute
`ansible-inventory --list`.
35 affirmations référencées, aucune référence orpheline.
Preuves : 24 OK, 0 échec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 09:36:30 -04:00
|
|
|
|
{"id": "P19", "titre": "Le GUI couvre le schema du plan", "refs": ["AFF-106"],
|
2026-07-22 21:32:42 -04:00
|
|
|
|
"refs": ["AFF-002", "AFF-095"],
|
|
|
|
|
|
"cmds": [[sys.executable, "scripts/couverture_gui.py", "verifier",
|
|
|
|
|
|
"--tolerer", "nomenclature"]]},
|
audit : les preuves réseau étaient accrochées à la mauvaise affirmation
P21, P23 et P24 renvoyaient à AFF-001 — « Set-OPS est un moteur Ansible
générique » — sans rapport avec la fédération, l'underlay ni la frontière.
P17, P19 et P20 n'avaient aucune référence. Une preuve accrochée à la
mauvaise affirmation passe au vert et n'atteste de rien de ce qu'on croit.
Ajouté §10 du registre : six affirmations (AFF-101..106) pour l'architecture
réseau et la fédération. La couverture du plan par le panneau est en 🟡, avec
ses exceptions nommées — listes de tables de l'underlay, ports physiques,
nœud de sortie.
Volontairement absente : la justesse des devis. Leur syntaxe dépend d'un
matériel que le dépôt ne possède pas ; six familles ont été confrontées au
commutateur réel, deux étaient fausses, mais c'est une vérification datée et
non une preuve rejouable. Le dépôt n'affirme pas que ses devis s'appliquent,
il affirme qu'ils dérivent.
Corrigé aussi : P03, P06, P12 et P13 portent maintenant les références que la
table leur attribuait déjà — la correspondance existait en double et seul le
document la tenait. Et la table attribuait AFF-030 (« inventaire complet ») à
P15, qui valide le modèle socle ; c'est P16 qui exécute
`ansible-inventory --list`.
35 affirmations référencées, aucune référence orpheline.
Preuves : 24 OK, 0 échec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 09:36:30 -04:00
|
|
|
|
{"id": "P20", "titre": "Adressage 100% derive du seed (aucun stocke)", "refs": ["AFF-101"],
|
Adressage derive du seul seed index (rupture, mode compact retire)
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>
2026-07-23 02:58:15 -04:00
|
|
|
|
"refs": ["AFF-001", "AFF-003"], "func": preuve_nomenclature_derivee},
|
audit : les preuves réseau étaient accrochées à la mauvaise affirmation
P21, P23 et P24 renvoyaient à AFF-001 — « Set-OPS est un moteur Ansible
générique » — sans rapport avec la fédération, l'underlay ni la frontière.
P17, P19 et P20 n'avaient aucune référence. Une preuve accrochée à la
mauvaise affirmation passe au vert et n'atteste de rien de ce qu'on croit.
Ajouté §10 du registre : six affirmations (AFF-101..106) pour l'architecture
réseau et la fédération. La couverture du plan par le panneau est en 🟡, avec
ses exceptions nommées — listes de tables de l'underlay, ports physiques,
nœud de sortie.
Volontairement absente : la justesse des devis. Leur syntaxe dépend d'un
matériel que le dépôt ne possède pas ; six familles ont été confrontées au
commutateur réel, deux étaient fausses, mais c'est une vérification datée et
non une preuve rejouable. Le dépôt n'affirme pas que ses devis s'appliquent,
il affirme qu'ils dérivent.
Corrigé aussi : P03, P06, P12 et P13 portent maintenant les références que la
table leur attribuait déjà — la correspondance existait en double et seul le
document la tenait. Et la table attribuait AFF-030 (« inventaire complet ») à
P15, qui valide le modèle socle ; c'est P16 qui exécute
`ansible-inventory --list`.
35 affirmations référencées, aucune référence orpheline.
Preuves : 24 OK, 0 échec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 09:36:30 -04:00
|
|
|
|
{"id": "P21", "titre": "Federation : aucun index en collision", "refs": ["AFF-102"],
|
2026-07-23 11:26:01 -04:00
|
|
|
|
"cmds": [[sys.executable, "scripts/instances.py", "--verifier"]]},
|
2026-07-23 15:49:16 -04:00
|
|
|
|
{"id": "P22", "titre": "Plan de recette a jour (genere du wiki)", "refs": ["AFF-002"],
|
|
|
|
|
|
"cmds": [[sys.executable, "scripts/plan_recette.py", "--verifier"]]},
|
audit : les preuves réseau étaient accrochées à la mauvaise affirmation
P21, P23 et P24 renvoyaient à AFF-001 — « Set-OPS est un moteur Ansible
générique » — sans rapport avec la fédération, l'underlay ni la frontière.
P17, P19 et P20 n'avaient aucune référence. Une preuve accrochée à la
mauvaise affirmation passe au vert et n'atteste de rien de ce qu'on croit.
Ajouté §10 du registre : six affirmations (AFF-101..106) pour l'architecture
réseau et la fédération. La couverture du plan par le panneau est en 🟡, avec
ses exceptions nommées — listes de tables de l'underlay, ports physiques,
nœud de sortie.
Volontairement absente : la justesse des devis. Leur syntaxe dépend d'un
matériel que le dépôt ne possède pas ; six familles ont été confrontées au
commutateur réel, deux étaient fausses, mais c'est une vérification datée et
non une preuve rejouable. Le dépôt n'affirme pas que ses devis s'appliquent,
il affirme qu'ils dérivent.
Corrigé aussi : P03, P06, P12 et P13 portent maintenant les références que la
table leur attribuait déjà — la correspondance existait en double et seul le
document la tenait. Et la table attribuait AFF-030 (« inventaire complet ») à
P15, qui valide le modèle socle ; c'est P16 qui exécute
`ansible-inventory --list`.
35 affirmations référencées, aucune référence orpheline.
Preuves : 24 OK, 0 échec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 09:36:30 -04:00
|
|
|
|
{"id": "P23", "titre": "Underlay sans collision avec la plage tenant", "refs": ["AFF-103"],
|
2026-07-24 14:56:38 -04:00
|
|
|
|
"cmds": [[sys.executable, "scripts/underlay.py", "--verifier"]]},
|
pare-feu Proxmox : le filtrage est-ouest intra-tenant, dérivé (P25)
`make devis-proxmox-fw` : 34 groupes de sécurité, 40 règles, 2 tenants —
depuis les 57 flux intra-tenant que le registre connaissait déjà.
Défense en profondeur, pas remplacement : l'hyperviseur filtre puis l'hôte
destinataire filtre à nouveau. Coût de maintenance nul, les deux barrières
lisent le registre par les MÊMES fonctions — la duplication est dans
l'application, jamais dans la décision.
Un IPSet par rôle porte les membres, les groupes y renvoient : ajouter un
hôte à un rôle met à jour toutes les règles qui l'autorisent, en un endroit.
Garde ajoutée après coup : Proxmox limite un nom de groupe à 18 caractères.
Ma première version tronquait sans vérifier — deux rôles tronqués au même nom
auraient fusionné leurs règles, donnant à une VM les autorisations d'un rôle
qu'elle ne porte pas, silencieusement. Le préfixe porte maintenant l'index
plutôt que l'étiquette, et une garde échoue sur toute collision. Exercée.
Conséquence consignée : tout ce qui entre dans un tenant passant par la
frontière, le contrôleur Ansible aussi — l'OPNsense devient un prérequis de
déploiement, pas une étape parmi d'autres.
Preuves : 25 OK, 0 échec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:43:32 -04:00
|
|
|
|
{"id": "P25", "titre": "Pare-feu Proxmox : est-ouest intra-tenant derive", "refs": ["AFF-107"],
|
|
|
|
|
|
"cmds": [[sys.executable, "scripts/devis_proxmox_fw.py", "--verifier"]]},
|
audit : les preuves réseau étaient accrochées à la mauvaise affirmation
P21, P23 et P24 renvoyaient à AFF-001 — « Set-OPS est un moteur Ansible
générique » — sans rapport avec la fédération, l'underlay ni la frontière.
P17, P19 et P20 n'avaient aucune référence. Une preuve accrochée à la
mauvaise affirmation passe au vert et n'atteste de rien de ce qu'on croit.
Ajouté §10 du registre : six affirmations (AFF-101..106) pour l'architecture
réseau et la fédération. La couverture du plan par le panneau est en 🟡, avec
ses exceptions nommées — listes de tables de l'underlay, ports physiques,
nœud de sortie.
Volontairement absente : la justesse des devis. Leur syntaxe dépend d'un
matériel que le dépôt ne possède pas ; six familles ont été confrontées au
commutateur réel, deux étaient fausses, mais c'est une vérification datée et
non une preuve rejouable. Le dépôt n'affirme pas que ses devis s'appliquent,
il affirme qu'ils dérivent.
Corrigé aussi : P03, P06, P12 et P13 portent maintenant les références que la
table leur attribuait déjà — la correspondance existait en double et seul le
document la tenait. Et la table attribuait AFF-030 (« inventaire complet ») à
P15, qui valide le modèle socle ; c'est P16 qui exécute
`ansible-inventory --list`.
35 affirmations référencées, aucune référence orpheline.
Preuves : 24 OK, 0 échec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 09:36:30 -04:00
|
|
|
|
{"id": "P24", "titre": "Frontiere nord/sud : acces d'administration declare", "refs": ["AFF-104"],
|
frontière nord/sud : devis dérivé, lien de transit et les deux routes
La bordure devient un artefact dérivé, comme le devis switch — et le chemin
qui y mène est enfin déclaré.
`make devis-opnsense` (+ preuve P24) dérive la politique de bordure du
registre des flux : les flux `pair: externe`, que `resoudre_flux.py` saute
volontairement parce qu'ils relèvent de la frontière et non du pare-feu
d'hôte. Aucun port, aucune adresse, aucun nom d'hôte dans le générateur.
Le lien manquait dans tous les fichiers : le devis switch ne contenait pas
une seule `ip route`. Un réseau underlay portant `passerelle_sortie` le
déclare — il vit dans l'underlay et non dans un tenant parce que la
frontière route vers TOUS les supernets tenants par le même saut, donc il
ne peut dériver d'aucun `index`. `devis-reseau` en tire deux routes :
l'aller (sortie générale) et le retour vers l'administration, dont l'absence
a coûté la passe de déploiement du 2026-07-29 — la réponse revient au
pare-feu par une autre interface que celle où l'état a été créé, et se fait
jeter en silence.
Les réseaux d'administration viennent de l'intrant `nftables_admin_ssh` :
même source unique que la garde anti-lockout des nftables et l'alias
SETOPS_ADMIN. Les trois pare-feux et les routes ne peuvent plus diverger.
La frontière est réglable depuis la console (section « Frontière » du
panneau Intrants) ; les identifiants d'API restent interdits d'écriture par
le GUI et vivent dans la voûte.
Correctifs de la même passe :
- le panneau refusait d'enregistrer les intrants de la frontière : le
garde-fou confondait une référence de voûte `{{ vault_* }}` préservée
avec un secret soumis. Il regarde désormais la valeur, pas le nom.
- `supprimer_vm_debian.yml` ne chargeait que `proxmox.vault.yml` pour ses
secrets ; retirer ce reliquat aurait cassé `make detruire`. Aligné sur le
playbook de clonage, voûte unique en dernier.
- documentation : la voûte est unique, `proxmox.vault.yml` n'est qu'un
reliquat de compatibilité.
Preuves : 24 OK, 0 échec. Cas de rejet du validateur d'underlay exercés un
par un ; résolution du jeton Proxmox vérifiée en exécution réelle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:32:04 -04:00
|
|
|
|
"cmds": [[sys.executable, "scripts/devis_opnsense.py", "--verifier"]]},
|
intégrations : le rôle déclare sa politique ; le cluster passe à l'hébergeur
Deux corrections de propriété, l'une dans le plan, l'autre dans les intrants.
1. Intégrations universelles (D-33/D-34, P26)
Le plan portait 57 lignes d'intégration écrites à la main, dont 28 disaient oui à
quelque chose de vrai pour tous les hôtes. Elles n'existaient que pour être
oubliées — et elles l'avaient été : dans Chezlepro, backup-01 et infra-pki-01
n'étaient ni supervisés, ni journalisés, ni certifiés.
Le rôle déclare désormais sa politique une fois, dans meta/integration.yml ; le
plan ne garde que les vrais choix et refuse la recopie. Les exemptions se
dérivent du service rendu (sauf_role), jamais d'un nom d'hôte : l'AC ne s'enrôle
pas auprès d'elle-même, et l'exemption suit step-ca si on le déplace.
Une seule fonction de résolution — integrations_de() — lue par l'inventaire, la
voûte et le panneau. Sans le passage par la voûte, les secrets des intégrations
universelles auraient cessé d'être exigés et P18 serait passé au vert sur une
voûte incomplète.
Vérifié : diff vide sur Technolibre (la politique reproduit exactement les 41
lignes retirées) ; sur Chezlepro, exactement les groupes manquants, et pas
client_pki sur infra-pki-01.
2. Vue Intégrations : la matrice
La fiche montrait les intégrations d'UN serveur ; le trou de Chezlepro n'a pas
été trouvé par le panneau mais par le devis de pare-feu. Matrice serveurs x
intégrations : colonnes de politique en lecture seule, facultatives cochables sur
place, ligne de couverture n/N qui rend le motif visible sans le juger.
3. Propriété des intrants (D-35/D-36, P27)
Le cluster Proxmox appartient à l'hébergeur, comme sa fabric et sa frontière.
Recopié chez chaque tenant, son inventaire avait déjà divergé : deux listes de
stockages contradictoires pour le même matériel. API/nœuds/stockages/ponts vont
dans proxmox-hebergeur.yml, à côté d'underlay.yml, dont le chemin se dérive —
l'hébergeur reste non déclaré (D-17). Restent au tenant son golden template et
ses défauts de placement.
Le panneau nomme désormais le propriétaire de chaque section : éditer une section
« hébergeur » vaut pour tous ses tenants, et l'écran ne le disait pas.
26 preuves OK, 0 échec. --syntax-check des deux playbooks Proxmox.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:09:22 -04:00
|
|
|
|
{"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},
|
pools Proxmox : un par tenant, dérivé de l'index (D-37, P28)
Onze des quatorze serveurs portent le même nom court chez Chezlepro et chez
Technolibre. Vérifié un par un, ce n'est pas un problème technique : tout le
reste dérive du seed et diverge (10.27.19.21 contre 10.21.19.21, VMID 117402101
contre 111402101, VLAN 1174 contre 1114, deux domaines internes), et rien n'est
indexé sur le nom court — les opérations Proxmox portent toutes un vmid, les
certificats un FQDN, et client_backup_repo vise backup-01.{{ domaine_interne }}.
Le coût est humain : la console Proxmox affiche le nom, et deux infra-pki-01 y
sont indiscernables à l'œil. Le VMID porte le tenant, encore faut-il connaître le
codage.
Un pool par tenant, dérivé du dossier d'instance et de l'index — déjà unique par
P21, donc aucun registre de plus : Chezlepro-17, Technolibre-11.
make devis-proxmox-pools rattrape la flotte existante (création du pool, puis
affectation des VM actives). Les VM créées ensuite entrent d'elles-mêmes :
make creer-vm dérive le pool par la même fonction et le passe à la création. Le
playbook crée le pool au préalable — proxmox_kvm échoue sur un pool inconnu, et
l'API ne sait pas changer le pool d'une VM existante ; c'est aussi pourquoi le
rattrapage passe par les membres.
P28 garde deux collisions : même nom de pool entre tenants, et surtout même VMID
— une machine appartenant à deux tenants serait pire qu'une homonymie.
Rien n'est renommé : les homonymes sont la preuve que la nomenclature est un vrai
gabarit. Le devis ne lit pas le cluster, il dit l'état cible et non l'écart.
27 preuves OK, 0 échec. --syntax-check du playbook de clonage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:15:14 -04:00
|
|
|
|
{"id": "P28", "titre": "Pools Proxmox : un par tenant, sans collision", "refs": ["AFF-110"],
|
|
|
|
|
|
"cmds": [[sys.executable, "scripts/devis_proxmox_pools.py", "--verifier"]]},
|
authentification : chaque rôle déclare sa position, gardé par P29
Une règle qu'aucune garde ne vérifie finit par ne plus être vraie — c'est ce qui
était arrivé aux 28 lignes d'intégration recopiées. Chaque rôle serveur_* porte
un meta/authentification.yml, confronté à son code par P29.
web-sso 5, socle-identite 2 (keycloak/openldap : ils SONT la chaîne d'identité),
ldap-direct 2, interne-sans-auth 2, sans-auth-humaine 12.
La preuve refuse l'oubli ET le mensonge. Éprouvée par sabotage sur sept cas :
déclaration supprimée, portée inventée, secours retiré, posture de formulaire
retirée, raison retirée, ldap-direct mensonger, réglage retiré des defaults.
Les deux derniers passaient dans la première version :
- le mensonge passait à cause d'un commentaire. Je cherchais le mot « ldap » dans
le rôle, et serveur_grafana/defaults/main.yml contient « désactiver quelqu'un
dans LDAP » : de la prose validait une déclaration fausse. La preuve exige
maintenant un indice nommé — variable <rôle>_oidc / <rôle>_ldap, ou URI ldap://
- le réglage retiré passait parce que le gabarit citait encore la variable alors
que plus rien ne lui donnait de valeur. La preuve lit defaults/main.yml en YAML
et exige que la clé y soit définie, pas mentionnée.
Elle a aussi forcé une valeur : oauth2-proxy était déclaré « formulaire local
fermé » alors qu'il n'a aucun compte local. D'où formulaire_local: aucun, qui
distingue « il n'y en a jamais eu » de « il y en a un, il est fermé ».
Correction d'une note de la veille : Prometheus et Loki ne sont PAS exposés
publiquement (aucun expose au plan). Seuls six groupes le sont. Le risque est
intra-tenant, pas frontalier. Les deux lacunes sont comptées à chaque exécution,
pas masquées.
AFF-111, D-42. 29 preuves OK, ansible-lint (production) sur 375 fichiers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:05:43 -04:00
|
|
|
|
{"id": "P29", "titre": "Authentification : chaque role declare sa position", "refs": ["AFF-111"],
|
|
|
|
|
|
"func": preuve_authentification},
|
SDN : make devis-sdn — zone, VNets et sous-réseaux dérivés du seed (D-43/44, P30)
Ajouter un tenant implique 1 zone EVPN + 6 VNets + 6 sous-réseaux sur le cluster.
Aucun générateur ne les produisait : dernière lacune dans un dépôt où tout dérive.
26 objets pour les deux tenants, VNI = 1000 + index×10 + zone, sous-réseau et
passerelle par les mêmes fonctions que l'inventaire.
Nommage, en deux temps. D'abord VRF0017 / v1174, alignés sur ce que le cluster
portait — réflexe inverse du bon : cette convention venait d'une création à la
main et ne disait pas de quel tenant il s'agissait. Forme retenue : t<index> pour
la zone, t<index><zone abrégée> pour le VNet (t17, t17serv). C'est le préfixe que
le pare-feu Proxmox utilisait déjà (t17-cli-metrique), donc un seul schéma dans
tout le dépôt. Abréviation = 4 premières lettres du libellé, accents retirés.
Pas de tiret entre index et zone, contrairement aux IPSets : t245-serv ferait 9
caractères, t245serv en fait 8 — la forme reste uniforme jusqu'au dernier index.
Contrainte cadrante : zones ET VNets sont limités à 8 caractères par Proxmox.
sdn-evpn.md annonçait chez17-services-infra (21) : il aurait été refusé à
l'application. P30 refuse tout dépassement et toute collision de nom, de VNI ou
de sous-réseau. Éprouvé aux bornes et par sabotage.
Vérification la plus forte : avant renommage, la dérivation reproduisait à
l'identique les deux zones créées à la main — nom, VNI de VRF, MTU, contrôleur.
voute.py saisir : le pendant de la génération. On génère un secret dont le dépôt
est la source, on saisit celui dont un tiers est la source — inventer une clé
d'API OPNsense donnerait une valeur refusée à la première requête, avec P18 au
vert sur une voûte inutilisable. Sans écho, double confirmation, rien sur la
ligne de commande.
Reste ouvert : aucun nœud de sortie déclaré. Le devis émet un marqueur, pas une
valeur plausible. Deux points à trancher — le nœud de sortie route selon sa
propre table (défaut actuel : 192.168.11.254, pas la frontière), et l'entrée
n'est pas redondante puisqu'elle dépend d'une route statique d'OPNsense vers un
seul nœud.
D-45 : l'affinité de VM attend Proxmox 9 (cluster en 8.4.19), tenue à la main.
30 preuves OK.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:06:42 -04:00
|
|
|
|
{"id": "P30", "titre": "SDN EVPN : zones, VNets et sous-reseaux derives", "refs": ["AFF-112"],
|
|
|
|
|
|
"cmds": [[sys.executable, "scripts/devis_sdn.py", "--verifier"]]},
|
2026-08-08 13:21:23 -04:00
|
|
|
|
{"id": "P31", "titre": "Documentation : tout ce que le depot FAIT est nomme", "refs": [],
|
|
|
|
|
|
"func": preuve_documentation_outillage},
|
P32 / D-72 : tout intrant exige par un role est fourni par l'instance
Premier des trois chantiers rendus evidents par la reconstruction. Il aurait
trouve son defaut n1 — amorcage_acces_courriel — SANS RIEN DETRUIRE.
Un assert de role declare un contrat ; rien ne verifiait que l'instance
l'honore, et le manque ne se voit qu'au moment ou la garde s'execute — donc,
pour un intrant d'amorcage, seulement en repartant de rien.
Satisfait par : defaut non vide (vault_* compris, gardes par P18), set_fact de
resolveur, ou declaration de l'inventaire. Aucune voute dechiffree : la preuve
reste statique.
Deux fois mon instrument a accuse le composant a sa place, avant meme sa
premiere execution utile : il criait au manque sur
serveur_postfix_mailstore_hote, pourtant fourni — je ne lisais pas le fichier
d'inventaire, puis je n'y cherchais que les blocs vars: alors qu'instancier
ecrit sous le nom d'hote.
Verifie dans les deux sens : 30 exigences satisfaites sur le reel ; sur un
double sans la declaration d'hier, le defaut n1 est nomme, code 1.
Harnais : 32 preuves, 0 echec, 0 sautee.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 09:40:25 -04:00
|
|
|
|
{"id": "P32", "titre": "Intrants exiges par les roles : tous fournis", "refs": [],
|
|
|
|
|
|
"cmds": [[sys.executable, "scripts/verifier_intrants.py"]]},
|
preuve : P34 — chaque document declare son lecteur (D-74)
La refonte de ce matin posait une convention. Une convention qu'on n'outille
pas tient tant que quelqu'un y pense : c'est le raisonnement de D-70, applique
au corpus documentaire.
Etat de depart mesure : 2 documents sur 34 declaraient leur lecteur. Les 32
autres disaient leur SUJET — ce qui avait enfoui le runbook de reprise le plus
utile du depot au §6 de autorisation.md.
Les 38 le declarent desormais, lecteur determine document par document et non
colle au gabarit : l'exploitant (devis, migration de tenant, cycle de vie,
gabarit d'or), le mainteneur (conceptions, registres, carte), le lecteur
externe (ecosysteme-chezlepro), l'agent IA (MISE-A-JOUR-CODEX-CLAUDE).
Deux exemptions DERIVEES, pas listees — un chemin en dur aurait vieilli a la
premiere page ajoutee : un document qui s'annonce genere, et un fragment sans
titre. Les 13 exemptes verifies un par un ; aucun document ecrit a la main
n'est exempte par accident. La preuve ne lit que l'EN-TETE, ce qui empeche
frontiere-opnsense.md et plan-et-generation.md — qui parlent de generation
dans leur corps — d'etre exemptes a tort.
Eprouvee dans les deux sens. Elle a echoue seule des sa premiere execution en
nommant deux documents que mon inventaire avait manques (docs/audit/). Puis
test negatif delibere : declaration retiree de meta-classe.md -> ECHEC la
nommant ; restauree -> OK.
Ce qu'elle ne teste pas : que le lecteur declare soit le BON. Ca se juge en
revue ; elle garantit qu'on a du y penser.
P01–P34. Comptes perimes corriges au passage (AGENTS.md et devis-services.md
annoncaient encore 30 preuves).
Verifie : prouver.py 0 (34 OK), plan-recette inchange.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 07:53:04 -04:00
|
|
|
|
{"id": "P34", "titre": "Chaque document declare son lecteur", "refs": [],
|
|
|
|
|
|
"func": preuve_lecteur_declare},
|
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
|
|
|
|
{"id": "P35", "titre": "Toute application exigeant une base en a une au plan", "refs": [],
|
|
|
|
|
|
"func": preuve_base_par_consommateur},
|
sauvegarde : le catalogue derive du groupe, et P36 le prouve
Correction : l'entree precedente attribuait le defaut a la reconstruction
from-zero. C'est faux — le commit fondateur 7476a54 (2026-07-03) disait lui-meme
« Reste : jobs Tier 1 (pg_dump/slapcat/vmail/forgejo) ». Ils n'ont jamais ete
ecrits, et infra-pki-01 a ensuite perdu son integration client_backup.
Le role qui POSSEDE la donnee dit comment la sortir : client_backup_jobs est
l'intersection du catalogue et des group_names du noeud. Un tenant qui deplace un
service emporte sa sauvegarde avec lui. On sauvegarde l'etat NON REGENERABLE :
ni zones PowerDNS ni tableaux Grafana, ils se redeploient.
L'unite qui ment est RETIREE, pas rendue bloquante : refuser le deploiement aurait
casse infra-edge-01, infra-dns-01 et mon-01, qui ne detiennent legitimement rien.
Le defaut etait le timer qui echouait chaque nuit en donnant l'apparence d'une
sauvegarde.
P36 (D-75) lit les groupes detenteurs dans le catalogue : ajouter un role au
catalogue etend la preuve du meme geste. Elle a attrape infra-pki-01 — les cles
de l'AC — corrige au plan.
Mesure hors-noeud : collab-01 64,0 MiB/272, edge-mta-01 4,4 MiB/139,
data-sql-01 1,0 MiB (pg_dumpall complet), forge-01 26,4 KiB/68,
infra-pki-01 20,1 KiB/21, idm-01 2,3 KiB/5 (slapcat). 9 hotes, 9 success.
Reste : rien ne surveille l'unite — c'est ce silence qui a laisse le defaut
vivre un mois.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:50:46 -04:00
|
|
|
|
{"id": "P36", "titre": "Tout detenteur d'etat porte une sauvegarde", "refs": [],
|
|
|
|
|
|
"func": preuve_etat_sauvegarde},
|
D-80 : un tenant est agnostique de son underlay, a trois cles pres
Formulation de l'exploitant, meilleure que celle du depot. Le commentaire disait
« la fabric reste celle de l'hebergeur, quel que soit le tenant actif » — vrai,
mais centre sur l'HEBERGEUR. Le cadrage juste est centre sur le TENANT : les deux
symlinks composent deux axes INDEPENDANTS (instance = quel tenant, underlay.yml =
sur quelle fabric).
Tout l'adressage derive du seed index : le plan se deplace d'une fabric a l'autre
sans y toucher. Ce qui ne se deplace pas tient en TROIS CLES —
proxmox_clone_noeud, _stockage, _pont. Elles vivent cote tenant (c'est lui qui
choisit ou se poser) mais nomment des objets de l'hebergeur. Trois, pas trente :
c'est ce qui separe « portable » de « theoriquement portable ».
P37 confronte ces trois noms aux listes de proxmox-hebergeur.yml, trouve par
derivation du symlink underlay.yml. Un nom absent est un ecart STATIQUE (D-75) —
sans quoi une faute de frappe ne se decouvre qu'au premier clone, apres quarante
minutes. Eprouvee en negatif sur les trois cles.
Non verifie et dit comme tel : le gabarit (_vmid_modele) est un objet du cluster,
pas une liste declaree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 09:47:38 -04:00
|
|
|
|
{"id": "P37", "titre": "Le placement du tenant existe chez son hebergeur", "refs": [],
|
|
|
|
|
|
"func": preuve_placement_chez_hebergeur},
|
catalogue : la carte des services avait quatre mois de retard sur le moteur
`catalogue-services.md` est le document qu'on lit pour savoir ce que Set-OPS FAIT :
l'hebergeur d'un second site, un futur client, un mainteneur qui arrive. Verifie role par
role contre roles/, voici ce qu'il disait de faux.
- « Capacites futures encore a implementer : collaboration (Nextcloud/Collabora) et couche
web (frontal/dorsal) » — les quatre roles existent, collab-01, web-frontal-01 et
web-dorsal-01 sont ACTIFS, et les deux roles web sont codifies depuis les spikes du
2026-07-05.
- « La federation LDAP n'est pas automatisee dans le role ; Keycloak n'est pas expose » —
serveur_keycloak/tasks/federation-ldap.yml existe, et le plan declare
`expose: auth.<domaine>`.
- `infra-mail-01` : « Sendmail MTA » — c'est Dovecot ; Sendmail est retire depuis le
2026-07-04. La table des hotes datait d'avant la separation edge-mta / mailstore.
- `client_supervision` annonce comme integration — n'a JAMAIS eu ni role ni playbook. La
supervision ne pose rien sur les hotes : controles actifs depuis le coeur, resultats
passifs pousses par l'API (c'est backup-01 qui rapporte l'etat de ses depots).
- Une colonne « Role » decorative inventait des noms (`nextcloud`, `client_metriques`) : le
role porte le nom du GROUPE. Colonne retiree.
Et NEUF roles vivants ne figuraient dans aucune table — le socle, toute la pile courriel,
les sauvegardes, Icinga Web 2, oauth2-proxy, Unbound. Deux (`serveur_backup`,
`client_backup`) n'etaient nommes NULLE PART.
P38 — CE QUE P31 NE POUVAIT PAS VOIR. P31 verifie que tout est nomme et atteignable, pas
qu'un document dise vrai : une carte peut etre complete et perimee. P38 confronte le
catalogue au code dans les deux sens, et c'est la TABLE qui fait foi des deux cotes : tout
role figure dans une ligne de table (la prose ne suffit pas — la pile courriel y etait
racontee et introuvable pour qui lit un index), et tout groupe cite en table existe
reellement (role, ou playbook de groupe pour `serveur_durci`, qui en compose onze).
Deux exemptions nommees : la prose peut citer les roles RETIRES, sinon on ne peut plus
ecrire d'ou l'on vient ; et P38 ne juge pas si une description est JUSTE — cela se revoit
contre le CHANGELOG, le mecaniser serait se mentir.
EPROUVEE EN NEGATIF : rejouee contre la version d'avant, elle echoue en nommant les neuf
roles absents et les trois cases fantomes.
Le catalogue dit aussi desormais ou il s'arrete : la reconstruction prouve qu'une machine
nue atteint l'etat voulu, pas la tenue sous charge ; et l'usage reel de Nextcloud n'est pas
consigne comme preuve. Lacune nommee au passage : la dependance causale
serveur_web_frontal -> serveur_nginx n'est toujours pas declaree.
make prouver : 38 OK, 0 echec, 0 saute. make test inchange.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:07:59 -04:00
|
|
|
|
{"id": "P38", "titre": "Catalogue des services : la carte dit ce que le moteur fait", "refs": [],
|
|
|
|
|
|
"func": preuve_catalogue_a_jour},
|
glossaire : le metier n'etait explique nulle part
Demande de l'exploitant apres une soiree passee a croiser « strophe FRR », VRF, VNet et
nexthop-vrf : cet ecosysteme doit rester pilotable par un humain, idealement un seul ;
que chaque notion sous-jacente soit ENSEIGNEE.
MESURE AVANT D'ECRIRE : 40 termes employes par le depot et absents du glossaire — LDAP
184 fois, playbook 165, underlay 106, EVPN 66, VRF 33, LMTP 25. Le glossaire expliquait le
vocabulaire propre a Set-OPS (plan, index, voute, zone) et laissait dehors tout ce qui
vient du metier. Or c'est le metier qui perd le lecteur.
CE N'EST PAS UN DEFAUT DE REDACTION. La regle fondatrice du depot est qu'un humain pilote
sans IA. Chaque mot obscur retire une personne a la liste de celles qui peuvent reprendre
le systeme : un vocabulaire non explique est un defaut de CONCEPTION.
- Glossaire reecrit : 67 termes groupes par famille (plan, machines, Ansible, reseau,
noms, confiance, identite, courriel, etat et preuve). Chaque entree dit ce que c'est ET
pourquoi ce depot s'en sert, avec renvoi vers l'unite qui developpe.
- Unite d'apprentissage manquante : « Le reseau des tenants ». Dix-sept des quarante
termes y vivaient sans domicile. Elle suit l'ordre ou les problemes se sont poses : deux
clients sur un cable -> VLAN -> ses deux limites -> encapsulation -> pourquoi 1450 ->
EVPN -> le VRF, qui n'est pas une interdiction mais une ignorance structurelle.
- Navigation : la nouvelle unite est au sidebar ; le plan de recette regenere (P22 l'a
exige des l'ajout de la page — le harnais a mordu).
P39 verifie : chaque terme du jargon a une entree ; chaque lien du glossaire mene a une
page existante ; chaque page du wiki est atteignable depuis la navigation.
LA LISTE EST DECLAREE, ET C'EST UN CHOIX MESURE. La derivation automatique a ete essayee :
153 acronymes dans le wiki et le README, dont la moitie sont des mots francais en
capitales (AUCUNE, AVANT, TOUS). Un controle qui exige une entree pour « AUCUNE » finit
desactive, et une preuve desactivee ne garde rien. La preuve dit elle-meme cet angle mort.
EPROUVEE EN NEGATIF contre le glossaire d'avant : 49 termes manquants, nommes un par un.
make verifier 39 OK, 0 echec, 0 saute ; make ci idem.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:15:55 -04:00
|
|
|
|
{"id": "P39", "titre": "Glossaire : tout mot employe est enseigne", "refs": [],
|
|
|
|
|
|
"func": preuve_glossaire_enseigne},
|
filiation : signer, inscrire la parente, et compter les temoins
L'exploitant : « j'ai une intuition : blockchain ». L'intuition visait le bon probleme —
une memoire partagee, verifiable, sans centre — mais la reponse etait deja dans git.
GIT EST DEJA UNE CHAINE DE HACHAGE : chaque commit porte l'empreinte de son parent, un
arbre de Merkle. Ce qui manquait n'etait pas la chaine mais l'AUTEUR : `user.name` est
declaratif, et toute la soiree du 20 des commits ont porte « Daniel Allaire » sans qu'aucune
preuve ne les lie a une cle (verifie : 8 commits, 0 signature, 0 etiquette).
POSE AUJOURD'HUI :
- signature par cle SSH (celle que la forge connait deja), etiquettes signees par defaut ;
premiere etiquette v2026.08.21, verifiee par `git verify-tag` ;
- `.git-allowed-signers` VERSIONNE : qui clone verifie sans rien demander a la forge, et
sans lui faire confiance. Retirer une ligne revoque pour la suite ; le passe signe reste
verifiable ;
- `scripts/genome.py` + trois cibles make : les QUATRE depots sans lesquels un ecosysteme
ne renait pas (moteur, instance, hebergeur, modeles), DERIVES et non declares ;
- `parente.yml` par ecosysteme : de quel moteur il descend, a quel commit, sous quelle
etiquette. Patient 0 descend de 742bcbf, etiquette v2026.08.21 ;
- P40 : la parente est inscrite, chaque depot se retrouve, chaque commit inscrit EXISTE
encore (une histoire reecrite se voit la), chacun porte un remote. Sautee proprement
quand l'instance n'est pas un depot git — le modele jetable de la CI.
POURQUOI PAS DE BLOCKCHAIN. Elle resout : qui ecrit ensuite, quand personne ne fait
confiance a personne et qu'il y a de l'argent en jeu. Aucun des trois ici. Et la
multiplicite qu'elle achete cher, la lignee la produit comme effet secondaire : chaque
enfant porte une copie du code dont il descend, donc reecrire l'histoire suppose de
convaincre TOUS les descendants. Le jour ou l'Alliance certifiera, ce sera un JOURNAL DE
TRANSPARENCE (Certificate Transparency, Sigstore), pas une chaine.
ENSEIGNE, pas seulement pose : nouvelle unite « Filiation, signatures et temoins » (moule
en quatre temps), onze termes au glossaire, et P39 les exige desormais.
make verifier 40 OK, 0 echec, 0 saute ; make ci idem.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:56:29 -04:00
|
|
|
|
{"id": "P40", "titre": "Parente : l'ecosysteme sait de quoi il descend", "refs": [],
|
|
|
|
|
|
"func": preuve_parente_inscrite},
|
resolution d'instance : une seule, partagee — au lieu de neuf copies
Cinq jours, cinq defauts, tous de la meme famille : « quelle instance, quel inventaire ? »
Neuf modules portaient chacun leur reponse.
- 18 aout : P03 comparait chaque instance a l'inventaire d'une AUTRE ;
- 19 aout : verifier_ports codait `principal/` en dur ; verifier_intrants et
_frontiere_absente lisaient le symlink au lieu de la variable ;
- 20 aout : devis_placement rendait un verdict juste sur le mauvais tenant ;
- 22 aout : P35, puis P36 — la dixieme, trouvee par la preuve elle-meme.
Aucune n'etait une faute d'inattention : chacune avait ete ecrite de bonne foi, a un
moment ou le besoin semblait local. C'est le mode de panne de la duplication — pas
l'erreur, mais la DERIVE, invisible depuis l'interieur d'un fichier.
LA RESOLUTION UNIQUE. `inventory_rules` porte instance_courante(), inventaire_de(),
dossier_inventaire() et plan_de(). Trois niveaux de repli, dont le TROISIEME manquait a la
moitie des copies : un hosts.yml existant, puis un REPERTOIRE existant (instance neuve —
c'est ce qui faisait echouer `make instancier` sur le modele public), puis le defaut.
Vingt-huit modules y sont branches.
CE QUI REND CE REFACTOR SUR : avant de toucher quoi que ce soit, chaque module a ete
interroge sur ce qu'il resolvait, pour les DEUX ecosystemes. Apres refactor, meme mesure :
17 modules x 2 instances, diff VIDE. Aucune resolution n'a change — prouve, pas suppose.
P41 echoue des qu'un module reintroduit une copie. Eprouvee en negatif : une copie
replacee dans genome.py est signalee avec son numero de ligne. Trois exemptions nommees :
instances.py et inventory_gui.py manipulent le SYMLINK lui-meme (bascule d'instance), et
devis_opnsense lit deliberement quelle instance est ACTIVE. Elles parlent du lien, pas de
la resolution.
make verifier 41 OK, 0 echec, 0 saute ; make ci idem ; lint vert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:19:55 -04:00
|
|
|
|
{"id": "P41", "titre": "Resolution d'instance : une seule, partagee", "refs": [],
|
|
|
|
|
|
"func": preuve_resolution_unique},
|
serveur_ops : la difference entre une archive et une matrice
Un ecosysteme pouvait DETENIR son genome sans savoir l'executer. Le poste
d'exploitation porte Ansible epingle, le genome clone depuis SA PROPRE forge, et
une cle SSH qui n'appartient qu'a lui. Il n'emporte ni la voute ni son mot de
passe : la structure se reconstruit depuis la forge, les secrets depuis la
sauvegarde. Deux sources qu'un meme incident n'atteint pas ensemble.
Une machine neuve traverse tout le moteur sans rien heriter, et revele ce qu'un
etat anterieur masquait. Deux defauts silencieux en sont sortis.
setops_plan_dir pointait le lien `instance` en dur : neuf roles lisaient donc le
plan d'une AUTRE instance. Le plancher de patient 0 portait les FQDN de
Chezlepro, et son edge publiait server_name forge.chezlepro.internal. La
variable suit desormais l'inventaire reellement charge.
Trois instances sur quatre declaraient les SAN d'exposition de leur edge ; la
quatrieme et le modele public ne les avaient pas. La forge de patient 0 etait
donc publiee derriere le certificat auto-signe de Debian, sans que rien ne le
signale -- `git clone` fut le premier a refuser, a juste titre. P42 le reclame
maintenant pour tout ecosysteme qui declare un edge.
Le harnais a ecrit la moitie de ce role : 5 echecs sur la piece neuve, aucun
n'empechait le code de tourner, tous la rendaient invisible a la carte, au
graphe et au lecteur. 42 preuves, 0 echec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:58:10 -04:00
|
|
|
|
{"id": "P42", "titre": "L'edge porte les noms qu'il publie", "refs": [],
|
|
|
|
|
|
"func": preuve_edge_porte_ses_noms},
|
site : quatre zones d'autorite, et l'ordre inscrit dans les integrations
Le site n'est plus un /24 plat. Une zone par nature d'autorite — pilotage, autorite,
genome, service — chacune son VLAN et sa patte sur la frontiere. L'inversion corrigee,
mesuree : les cinq VM du site n'avaient AUCUN filtrage est-ouest, contre policy_in=DROP
sur une machine de tenant. La plus autoritaire etait la moins protegee.
Filtrage nord-sud par choix de l'exploitant : un seul point de police, un seul devis.
90 -> 117 regles. Chaque flux `flotte` produit une regle par zone SOURCE, destination
nommee — ce qui etait gratuit devient police.
L'ORDRE FAIT PARTIE DE L'INTEGRATION. client_pki tournait en parallele sur tous les hotes ;
sur celui qui porte l'autorite il recharge step-ca, et les quatre autres echouaient dans
cette fenetre sur `TLS handshake timeout` — un message qui accuse le reseau. Chaque
integration declare desormais sa dependance dans meta/integration.yml, les playbooks en
sont le miroir genere, et P44 refuse l'ecart (4 controles negatifs). `serveur: ~` est une
reponse valable : client_metrique pose un exportateur qu'on vient LIRE.
Autres defauts du meme soir :
- le plancher /etc/hosts venait APRES le premier apt, qui vise le cache par son NOM :
boucle fermee des que les adresses changent. Il ne s'installe pas, il rend installable.
- dns_amorcage ecrit en dur a eu tort deux fois ; il se derive de serveur_resolveur.
- l'ACL du resolveur derivait d'un seul sous-reseau : trois zones refusees sur quatre.
ET UNE ERREUR A MOI : j'ai diagnostique un trou noir de MTU et declare 1450. Faux — pas de
VXLAN sur ce chemin, tout est a 1500 de bout en bout. Ma mesure etait reelle, mon
interpretation non : je venais de debrancher la carte a chaud, et chaque `ip link set mtu`
reconfigurait l'interface. C'est la reconfiguration qui debloquait, pas la valeur.
44 preuves vertes, ansible-lint profil production.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:31:07 -04:00
|
|
|
|
{"id": "P44", "titre": "Integrations : le serveur avant ses clients", "refs": [],
|
|
|
|
|
|
"func": preuve_ordre_des_integrations},
|
proxmox : le pare-feu ne s'arme que dans le SDN — et 45e preuve
Le decoupage du site en quatre zones a revele un defaut qui dormait dans le
moteur. `firewall=1` sur l'interface d'une VM fait passer tout le trafic ponte
par conntrack. Sur un VNet SDN c'est sans consequence : en EVPN le routage
inter-VNet se fait dans le VRF, SUR LE NOEUD, et le flux ne quitte jamais
l'hyperviseur. Sur un pont classique route par une frontiere externe, deux VM du
MEME noeud dans deux VLAN differents ne se parlent qu'en EPINGLE : la trame sort
par le lien physique, la frontiere la route, elle revient sur le meme pont. La
meme table conntrack voit alors les deux moities de la connexion, classe le
retour INVALID, et PVEFW-FORWARD le jette.
La mesure a tranche :
ops(asgard) -> pki(asgard) 0/8 dns(gandalf) -> cache(gandalf) 0/8
ops(asgard) -> forge(vishnu) 6/8 dns(gandalf) -> pki(asgard) 6/8
ops(asgard) -> cache(gandalf) 8/8 dns(gandalf) -> forge(vishnu) 8/8
Toutes les paires intra-noeud echouent, toutes les paires inter-noeuds passent.
Douze tentatives faisaient monter le compteur `ctstate INVALID` de +112 sur
asgard et +116 sur gandalf ; `firewall=0` pose, il ne bouge plus — 0 sur les
deux, pour le meme trafic.
Le defaut se deguise en panne reseau : la poignee TCP ABOUTIT, et ce sont les
paquets de DONNEES qui disparaissent. L'AC le disait dans son propre journal
(`TLS handshake error ... i/o timeout` : elle accepte et attend un ClientHello
qui n'arrive jamais). Ecartes un par un, par la mesure : regles identiques champ
par champ, alias corrects, assignation des interfaces confirmee, ARP et routes
saines, IPS desactive, shaper vide, NAT source limite a `wan`, MTU a 1500 de
bout en bout — et la taille sans effet, 100 octets se perdant comme 1460.
L'INTENTION DECLAREE NE SUFFIT PAS : un tenant declare
`proxmox_clone_parefeu_interface: true` avec `proxmox_clone_pont: vmbr1` comme
valeur PAR DEFAUT, chaque hote la remplacant par son VNet derive. L'hote qui
retombe sur `vmbr1` naitrait arme sur un pont classique. `cloner_vm_debian.yml`
croise donc l'intention avec le pont REELLEMENT utilise, et le dit quand il
desarme — un desarmement muet serait le meme piege, en silence.
P45 EVALUE l'expression du playbook sur quatre cas plutot que d'en lire le
texte, dont un qui DOIT rendre vrai : sans lui, une expression constamment
fausse passerait la preuve sans rien garantir. Controle negatif verifie — le
drapeau remis a plat fait echouer la preuve.
Ce commit emporte aussi le desarmement d'`unattended-upgrades` dans
`common_packages`, jusqu'ici applique sur les machines mais pas versionne : le
verrou dpkg tenu 48 minutes par une vague de maj automatiques a coute deux
deploiements.
Le site a revele ce defaut parce qu'il a ete le premier a porter plusieurs
zones. Ce n'est pas une particularite du site : un tenant derive les siennes du
meme principe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:50:53 -04:00
|
|
|
|
{"id": "P45", "titre": "Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs",
|
|
|
|
|
|
"refs": [], "func": preuve_parefeu_proxmox_sdn_seulement},
|
2026-08-25 14:11:39 -04:00
|
|
|
|
{"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [],
|
|
|
|
|
|
"func": preuve_devis_frontiere_du_site},
|
2026-08-09 09:46:17 -04:00
|
|
|
|
{"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],
|
|
|
|
|
|
"cmds": [[sys.executable, "scripts/verifier_ports.py"]]},
|
Mise en conformité prouvable : registre d'affirmations + make prouver
Le dépôt fait / explique / prouve ce qu'il affirme, vérifiable en une commande.
- Phase 1 : docs/audit/affirmations.md — 54 affirmations publiques tracées vers
une commande de preuve et un statut (✅/🟡/❌/⚪).
- Phase 2 : CLAUDE.md réduit à un pointeur mince ; contradiction SSH levée (le
code applique déjà PasswordAuthentication no + AuthenticationMethods publickey,
conforme à AGENTS.md) ; section AGENTS « Codex » → « agents IA ».
- Phase 3 : parcours démarrage réparé (QUICKSTART renvoyait à un modèle absent,
chemins de voûte faux, commandes make périmées) ; make verifier vert
(ansible-lint 33 → 0 : site.yml généré nommé, pipefail, name[template]) ;
voûte Proxmox unifiée lue par le clonage (all/vault.yml).
- Phase 4 : make prouver → docs/audit/preuve-<date>.md, harnais rejouable qui
rappelle l'outillage existant (aucune validation réimplémentée).
- Phase 5 : parcours QUICKSTART prouvé hors-ligne sur le socle ; modèle socle
rendu valide (autorite interne → auto-heberge) ; split-brain d'inventaire
corrigé (repli sur le répertoire existant, pas principal/).
make prouver : 15 OK, 0 échec, 1 sautée (voûte). ansible-lint : 0 failure.
Écarts découverts en cours de traitement (AFF-097..100) : tous résolus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:53:18 -04:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 14:56:38 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
frontière nord/sud : devis dérivé, lien de transit et les deux routes
La bordure devient un artefact dérivé, comme le devis switch — et le chemin
qui y mène est enfin déclaré.
`make devis-opnsense` (+ preuve P24) dérive la politique de bordure du
registre des flux : les flux `pair: externe`, que `resoudre_flux.py` saute
volontairement parce qu'ils relèvent de la frontière et non du pare-feu
d'hôte. Aucun port, aucune adresse, aucun nom d'hôte dans le générateur.
Le lien manquait dans tous les fichiers : le devis switch ne contenait pas
une seule `ip route`. Un réseau underlay portant `passerelle_sortie` le
déclare — il vit dans l'underlay et non dans un tenant parce que la
frontière route vers TOUS les supernets tenants par le même saut, donc il
ne peut dériver d'aucun `index`. `devis-reseau` en tire deux routes :
l'aller (sortie générale) et le retour vers l'administration, dont l'absence
a coûté la passe de déploiement du 2026-07-29 — la réponse revient au
pare-feu par une autre interface que celle où l'état a été créé, et se fait
jeter en silence.
Les réseaux d'administration viennent de l'intrant `nftables_admin_ssh` :
même source unique que la garde anti-lockout des nftables et l'alias
SETOPS_ADMIN. Les trois pare-feux et les routes ne peuvent plus diverger.
La frontière est réglable depuis la console (section « Frontière » du
panneau Intrants) ; les identifiants d'API restent interdits d'écriture par
le GUI et vivent dans la voûte.
Correctifs de la même passe :
- le panneau refusait d'enregistrer les intrants de la frontière : le
garde-fou confondait une référence de voûte `{{ vault_* }}` préservée
avec un secret soumis. Il regarde désormais la valeur, pas le nom.
- `supprimer_vm_debian.yml` ne chargeait que `proxmox.vault.yml` pour ses
secrets ; retirer ce reliquat aurait cassé `make detruire`. Aligné sur le
playbook de clonage, voûte unique en dernier.
- documentation : la voûte est unique, `proxmox.vault.yml` n'est qu'un
reliquat de compatibilité.
Preuves : 24 OK, 0 échec. Cas de rejet du validateur d'underlay exercés un
par un ; résolution du jeton Proxmox vérifiée en exécution réelle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:32:04 -04:00
|
|
|
|
def _frontiere_absente() -> bool:
|
CI : le harnais ne se declenchait que par memoire
Trente-huit preuves, des tests, un lint — et RIEN ne les executait sans qu'un humain tape
`make`. Le meilleur atout du depot dependait de ne pas oublier. Il a desormais une CI
(.forgejo/workflows/verifier.yml) et une cible qui la rejoue a l'identique : `make ci`.
CE QUE LA CI A TROUVE AVANT D'EXISTER. Ecrire le workflow supposait de repondre a une
question jamais posee : est-ce qu'un depot PUBLIC, seul, se tient ? Mesure sur un clone
nu : non, a cinq endroits.
- `make instancier` echouait sur le modele public — le tout premier geste du QUICKSTART.
Le Makefile forcait `principal/hosts.yml` alors que le modele vit en `production/` ; sa
precedence suit desormais celle du code (fichier, puis REPERTOIRE existant, puis defaut).
- P32 parcourait les 54 roles sans regarder ce que l'instance deploie. Elle passait sur
l'ecosysteme de reference PARCE QU'IL PORTE TOUT. Or les modeles sont des OFFRES : toute
offre plus petite que l'ecosysteme complet echouait son propre harnais, pour des services
qu'elle ne vend pas. Le perimetre se lit maintenant du plan (groupes de l'inventaire,
puis roles composes par leur playbook).
- P24 : le modele public ne declarait aucun reseau d'administration — une flotte qu'on
construit et ou l'on n'entre plus. `nftables_admin_ssh` est pose, avec le pourquoi.
- P33 : verifier_ports.py codait `instance/inventories/principal/hosts.yml` en dur.
- P32 et P24 lisaient le symlink `instance/` au lieu de SETOPS_INSTANCE.
Toutes de la MEME FAMILLE que P03 avant-hier : une resolution d'inventaire recopiee, une
variable d'environnement qui deborde de sa portee. Le depot en compte SEPT ; deux de plus
sont corrigees ici, et la septieme le dit en commentaire plutot que de le taire.
`make ci` NE TOUCHE AUCUN SYMLINK : le modele public est monte comme instance jetable,
vise par SETOPS_INSTANCE/SETOPS_UNDERLAY, detruit en sortant. Deux details mesures parce
que devines faux d'abord : l'instance jetable est un DOSSIER FRERE (la federation se
decouvre ainsi ; ailleurs, quatre preuves tombent) ; et SETOPS_UNDERLAY n'est pose QUE
pour la verification, sinon l'inventaire est ecrit avec une fabric et regenere avec une
autre — la commande fabriquait l'ecart qu'elle denonce.
RESULTAT : clone nu sans instance ni frere -> 38 OK, 0 echec, 0 saute. Depot de
l'exploitant avec ses 3 instances -> 38 OK, 0 echec, 0 saute. Aucun residu.
Et le lint du depot a refuse mon propre fichier de CI avant qu'il ne tourne une seule fois
(`on:` lu par YAML comme le booleen vrai). Le harnais mordait deja.
A AJUSTER AU PREMIER PASSAGE, ecrit en tete du workflow : l'etiquette `runs-on` doit
correspondre a un runner Forgejo enregistre, et le runner a besoin du reseau pour pip et
ansible-galaxy. Le vert de cette CI dira que le moteur et son modele public se tiennent —
pas que la flotte va bien : aucune VM jointe, aucune voute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 19:29:46 -04:00
|
|
|
|
"""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()
|
frontière nord/sud : devis dérivé, lien de transit et les deux routes
La bordure devient un artefact dérivé, comme le devis switch — et le chemin
qui y mène est enfin déclaré.
`make devis-opnsense` (+ preuve P24) dérive la politique de bordure du
registre des flux : les flux `pair: externe`, que `resoudre_flux.py` saute
volontairement parce qu'ils relèvent de la frontière et non du pare-feu
d'hôte. Aucun port, aucune adresse, aucun nom d'hôte dans le générateur.
Le lien manquait dans tous les fichiers : le devis switch ne contenait pas
une seule `ip route`. Un réseau underlay portant `passerelle_sortie` le
déclare — il vit dans l'underlay et non dans un tenant parce que la
frontière route vers TOUS les supernets tenants par le même saut, donc il
ne peut dériver d'aucun `index`. `devis-reseau` en tire deux routes :
l'aller (sortie générale) et le retour vers l'administration, dont l'absence
a coûté la passe de déploiement du 2026-07-29 — la réponse revient au
pare-feu par une autre interface que celle où l'état a été créé, et se fait
jeter en silence.
Les réseaux d'administration viennent de l'intrant `nftables_admin_ssh` :
même source unique que la garde anti-lockout des nftables et l'alias
SETOPS_ADMIN. Les trois pare-feux et les routes ne peuvent plus diverger.
La frontière est réglable depuis la console (section « Frontière » du
panneau Intrants) ; les identifiants d'API restent interdits d'écriture par
le GUI et vivent dans la voûte.
Correctifs de la même passe :
- le panneau refusait d'enregistrer les intrants de la frontière : le
garde-fou confondait une référence de voûte `{{ vault_* }}` préservée
avec un secret soumis. Il regarde désormais la valeur, pas le nom.
- `supprimer_vm_debian.yml` ne chargeait que `proxmox.vault.yml` pour ses
secrets ; retirer ce reliquat aurait cassé `make detruire`. Aligné sur le
playbook de clonage, voûte unique en dernier.
- documentation : la voûte est unique, `proxmox.vault.yml` n'est qu'un
reliquat de compatibilité.
Preuves : 24 OK, 0 échec. Cas de rejet du validateur d'underlay exercés un
par un ; résolution du jeton Proxmox vérifiée en exécution réelle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:32:04 -04:00
|
|
|
|
|
|
|
|
|
|
|
Mise en conformité prouvable : registre d'affirmations + make prouver
Le dépôt fait / explique / prouve ce qu'il affirme, vérifiable en une commande.
- Phase 1 : docs/audit/affirmations.md — 54 affirmations publiques tracées vers
une commande de preuve et un statut (✅/🟡/❌/⚪).
- Phase 2 : CLAUDE.md réduit à un pointeur mince ; contradiction SSH levée (le
code applique déjà PasswordAuthentication no + AuthenticationMethods publickey,
conforme à AGENTS.md) ; section AGENTS « Codex » → « agents IA ».
- Phase 3 : parcours démarrage réparé (QUICKSTART renvoyait à un modèle absent,
chemins de voûte faux, commandes make périmées) ; make verifier vert
(ansible-lint 33 → 0 : site.yml généré nommé, pipefail, name[template]) ;
voûte Proxmox unifiée lue par le clonage (all/vault.yml).
- Phase 4 : make prouver → docs/audit/preuve-<date>.md, harnais rejouable qui
rappelle l'outillage existant (aucune validation réimplémentée).
- Phase 5 : parcours QUICKSTART prouvé hors-ligne sur le socle ; modèle socle
rendu valide (autorite interne → auto-heberge) ; split-brain d'inventaire
corrigé (repli sur le répertoire existant, pas principal/).
make prouver : 15 OK, 0 échec, 1 sautée (voûte). ansible-lint : 0 failure.
Écarts découverts en cours de traitement (AFF-097..100) : tous résolus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:53:18 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-20 21:03:20 -04:00
|
|
|
|
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
|
Mise en conformité prouvable : registre d'affirmations + make prouver
Le dépôt fait / explique / prouve ce qu'il affirme, vérifiable en une commande.
- Phase 1 : docs/audit/affirmations.md — 54 affirmations publiques tracées vers
une commande de preuve et un statut (✅/🟡/❌/⚪).
- Phase 2 : CLAUDE.md réduit à un pointeur mince ; contradiction SSH levée (le
code applique déjà PasswordAuthentication no + AuthenticationMethods publickey,
conforme à AGENTS.md) ; section AGENTS « Codex » → « agents IA ».
- Phase 3 : parcours démarrage réparé (QUICKSTART renvoyait à un modèle absent,
chemins de voûte faux, commandes make périmées) ; make verifier vert
(ansible-lint 33 → 0 : site.yml généré nommé, pipefail, name[template]) ;
voûte Proxmox unifiée lue par le clonage (all/vault.yml).
- Phase 4 : make prouver → docs/audit/preuve-<date>.md, harnais rejouable qui
rappelle l'outillage existant (aucune validation réimplémentée).
- Phase 5 : parcours QUICKSTART prouvé hors-ligne sur le socle ; modèle socle
rendu valide (autorite interne → auto-heberge) ; split-brain d'inventaire
corrigé (repli sur le répertoire existant, pas principal/).
make prouver : 15 OK, 0 échec, 1 sautée (voûte). ansible-lint : 0 failure.
Écarts découverts en cours de traitement (AFF-097..100) : tous résolus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:53:18 -04:00
|
|
|
|
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)",
|
2026-07-20 20:47:36 -04:00
|
|
|
|
"refs": ["AFF-030"], "func": preuve_inventaire_ansible}
|
Mise en conformité prouvable : registre d'affirmations + make prouver
Le dépôt fait / explique / prouve ce qu'il affirme, vérifiable en une commande.
- Phase 1 : docs/audit/affirmations.md — 54 affirmations publiques tracées vers
une commande de preuve et un statut (✅/🟡/❌/⚪).
- Phase 2 : CLAUDE.md réduit à un pointeur mince ; contradiction SSH levée (le
code applique déjà PasswordAuthentication no + AuthenticationMethods publickey,
conforme à AGENTS.md) ; section AGENTS « Codex » → « agents IA ».
- Phase 3 : parcours démarrage réparé (QUICKSTART renvoyait à un modèle absent,
chemins de voûte faux, commandes make périmées) ; make verifier vert
(ansible-lint 33 → 0 : site.yml généré nommé, pipefail, name[template]) ;
voûte Proxmox unifiée lue par le clonage (all/vault.yml).
- Phase 4 : make prouver → docs/audit/preuve-<date>.md, harnais rejouable qui
rappelle l'outillage existant (aucune validation réimplémentée).
- Phase 5 : parcours QUICKSTART prouvé hors-ligne sur le socle ; modèle socle
rendu valide (autorite interne → auto-heberge) ; split-brain d'inventaire
corrigé (repli sur le répertoire existant, pas principal/).
make prouver : 15 OK, 0 échec, 1 sautée (voûte). ansible-lint : 0 failure.
Écarts découverts en cours de traitement (AFF-097..100) : tous résolus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:53:18 -04:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-07-24 14:56:38 -04:00
|
|
|
|
# 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."))
|
|
|
|
|
|
|
frontière nord/sud : devis dérivé, lien de transit et les deux routes
La bordure devient un artefact dérivé, comme le devis switch — et le chemin
qui y mène est enfin déclaré.
`make devis-opnsense` (+ preuve P24) dérive la politique de bordure du
registre des flux : les flux `pair: externe`, que `resoudre_flux.py` saute
volontairement parce qu'ils relèvent de la frontière et non du pare-feu
d'hôte. Aucun port, aucune adresse, aucun nom d'hôte dans le générateur.
Le lien manquait dans tous les fichiers : le devis switch ne contenait pas
une seule `ip route`. Un réseau underlay portant `passerelle_sortie` le
déclare — il vit dans l'underlay et non dans un tenant parce que la
frontière route vers TOUS les supernets tenants par le même saut, donc il
ne peut dériver d'aucun `index`. `devis-reseau` en tire deux routes :
l'aller (sortie générale) et le retour vers l'administration, dont l'absence
a coûté la passe de déploiement du 2026-07-29 — la réponse revient au
pare-feu par une autre interface que celle où l'état a été créé, et se fait
jeter en silence.
Les réseaux d'administration viennent de l'intrant `nftables_admin_ssh` :
même source unique que la garde anti-lockout des nftables et l'alias
SETOPS_ADMIN. Les trois pare-feux et les routes ne peuvent plus diverger.
La frontière est réglable depuis la console (section « Frontière » du
panneau Intrants) ; les identifiants d'API restent interdits d'écriture par
le GUI et vivent dans la voûte.
Correctifs de la même passe :
- le panneau refusait d'enregistrer les intrants de la frontière : le
garde-fou confondait une référence de voûte `{{ vault_* }}` préservée
avec un secret soumis. Il regarde désormais la valeur, pas le nom.
- `supprimer_vm_debian.yml` ne chargeait que `proxmox.vault.yml` pour ses
secrets ; retirer ce reliquat aurait cassé `make detruire`. Aligné sur le
playbook de clonage, voûte unique en dernier.
- documentation : la voûte est unique, `proxmox.vault.yml` n'est qu'un
reliquat de compatibilité.
Preuves : 24 OK, 0 échec. Cas de rejet du validateur d'underlay exercés un
par un ; résolution du jeton Proxmox vérifiée en exécution réelle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:32:04 -04:00
|
|
|
|
# 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."))
|
|
|
|
|
|
|
Mise en conformité prouvable : registre d'affirmations + make prouver
Le dépôt fait / explique / prouve ce qu'il affirme, vérifiable en une commande.
- Phase 1 : docs/audit/affirmations.md — 54 affirmations publiques tracées vers
une commande de preuve et un statut (✅/🟡/❌/⚪).
- Phase 2 : CLAUDE.md réduit à un pointeur mince ; contradiction SSH levée (le
code applique déjà PasswordAuthentication no + AuthenticationMethods publickey,
conforme à AGENTS.md) ; section AGENTS « Codex » → « agents IA ».
- Phase 3 : parcours démarrage réparé (QUICKSTART renvoyait à un modèle absent,
chemins de voûte faux, commandes make périmées) ; make verifier vert
(ansible-lint 33 → 0 : site.yml généré nommé, pipefail, name[template]) ;
voûte Proxmox unifiée lue par le clonage (all/vault.yml).
- Phase 4 : make prouver → docs/audit/preuve-<date>.md, harnais rejouable qui
rappelle l'outillage existant (aucune validation réimplémentée).
- Phase 5 : parcours QUICKSTART prouvé hors-ligne sur le socle ; modèle socle
rendu valide (autorite interne → auto-heberge) ; split-brain d'inventaire
corrigé (repli sur le répertoire existant, pas principal/).
make prouver : 15 OK, 0 échec, 1 sautée (voûte). ansible-lint : 0 failure.
Écarts découverts en cours de traitement (AFF-097..100) : tous résolus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:53:18 -04:00
|
|
|
|
for preuve in liste:
|
|
|
|
|
|
statut, detail = _executer(preuve)
|
|
|
|
|
|
resultats.append((preuve, statut, detail))
|
|
|
|
|
|
|
2026-07-22 21:32:42 -04:00
|
|
|
|
# Tri : garder l'ordre d'execution (P01..P19) ; la preuve sautee a ete inseree tot.
|
Mise en conformité prouvable : registre d'affirmations + make prouver
Le dépôt fait / explique / prouve ce qu'il affirme, vérifiable en une commande.
- Phase 1 : docs/audit/affirmations.md — 54 affirmations publiques tracées vers
une commande de preuve et un statut (✅/🟡/❌/⚪).
- Phase 2 : CLAUDE.md réduit à un pointeur mince ; contradiction SSH levée (le
code applique déjà PasswordAuthentication no + AuthenticationMethods publickey,
conforme à AGENTS.md) ; section AGENTS « Codex » → « agents IA ».
- Phase 3 : parcours démarrage réparé (QUICKSTART renvoyait à un modèle absent,
chemins de voûte faux, commandes make périmées) ; make verifier vert
(ansible-lint 33 → 0 : site.yml généré nommé, pipefail, name[template]) ;
voûte Proxmox unifiée lue par le clonage (all/vault.yml).
- Phase 4 : make prouver → docs/audit/preuve-<date>.md, harnais rejouable qui
rappelle l'outillage existant (aucune validation réimplémentée).
- Phase 5 : parcours QUICKSTART prouvé hors-ligne sur le socle ; modèle socle
rendu valide (autorite interne → auto-heberge) ; split-brain d'inventaire
corrigé (repli sur le répertoire existant, pas principal/).
make prouver : 15 OK, 0 échec, 1 sautée (voûte). ansible-lint : 0 failure.
Écarts découverts en cours de traitement (AFF-097..100) : tous résolus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:53:18 -04:00
|
|
|
|
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
|
2026-07-20 21:03:20 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
Mise en conformité prouvable : registre d'affirmations + make prouver
Le dépôt fait / explique / prouve ce qu'il affirme, vérifiable en une commande.
- Phase 1 : docs/audit/affirmations.md — 54 affirmations publiques tracées vers
une commande de preuve et un statut (✅/🟡/❌/⚪).
- Phase 2 : CLAUDE.md réduit à un pointeur mince ; contradiction SSH levée (le
code applique déjà PasswordAuthentication no + AuthenticationMethods publickey,
conforme à AGENTS.md) ; section AGENTS « Codex » → « agents IA ».
- Phase 3 : parcours démarrage réparé (QUICKSTART renvoyait à un modèle absent,
chemins de voûte faux, commandes make périmées) ; make verifier vert
(ansible-lint 33 → 0 : site.yml généré nommé, pipefail, name[template]) ;
voûte Proxmox unifiée lue par le clonage (all/vault.yml).
- Phase 4 : make prouver → docs/audit/preuve-<date>.md, harnais rejouable qui
rappelle l'outillage existant (aucune validation réimplémentée).
- Phase 5 : parcours QUICKSTART prouvé hors-ligne sur le socle ; modèle socle
rendu valide (autorite interne → auto-heberge) ; split-brain d'inventaire
corrigé (repli sur le répertoire existant, pas principal/).
make prouver : 15 OK, 0 échec, 1 sautée (voûte). ansible-lint : 0 failure.
Écarts découverts en cours de traitement (AFF-097..100) : tous résolus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:53:18 -04:00
|
|
|
|
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__":
|
2026-07-20 21:03:20 -04:00
|
|
|
|
raise SystemExit(main(sys.argv[1:]))
|